Skip to content

Commit a43f4e5

Browse files
authored
Merge pull request #54 from sgerner/codex/qbittorrent-json-response
accept JSON add-torrent responses from qBittorrent
2 parents d64acc8 + 65134b7 commit a43f4e5

2 files changed

Lines changed: 202 additions & 2 deletions

File tree

internal/download/qbittorrent.go

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package download
22

33
import (
4+
"encoding/json"
5+
"errors"
46
"fmt"
57
"io"
68
"log/slog"
@@ -202,14 +204,48 @@ func (q *QBittorrentClient) AddTorrent(torrentURL, title, savePath, category str
202204
defer resp.Body.Close()
203205

204206
body, _ := io.ReadAll(resp.Body)
205-
if string(body) != "Ok." {
206-
return fmt.Errorf("add torrent failed: %s", string(body))
207+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
208+
return fmt.Errorf("add torrent HTTP %d: %s", resp.StatusCode, string(body))
209+
}
210+
211+
if err := parseQBittorrentAddTorrentResponse(body); err != nil {
212+
return fmt.Errorf("add torrent failed: %s", err.Error())
207213
}
208214

209215
slog.Info("torrent added to qBittorrent", "title", title)
210216
return nil
211217
}
212218

219+
type qbittorrentAddTorrentResponse struct {
220+
AddedTorrentIDs []string `json:"added_torrent_ids"`
221+
SuccessCount int `json:"success_count"`
222+
FailureCount int `json:"failure_count"`
223+
PendingCount int `json:"pending_count"`
224+
Error string `json:"error"`
225+
}
226+
227+
func parseQBittorrentAddTorrentResponse(body []byte) error {
228+
trimmed := strings.TrimSpace(string(body))
229+
if trimmed == "" || trimmed == "Ok." {
230+
return nil
231+
}
232+
233+
var parsed qbittorrentAddTorrentResponse
234+
if err := json.Unmarshal(body, &parsed); err == nil {
235+
if parsed.Error != "" {
236+
return errors.New(parsed.Error)
237+
}
238+
if parsed.FailureCount > 0 {
239+
return fmt.Errorf("success_count=%d failure_count=%d pending_count=%d", parsed.SuccessCount, parsed.FailureCount, parsed.PendingCount)
240+
}
241+
if parsed.SuccessCount > 0 || parsed.PendingCount > 0 || len(parsed.AddedTorrentIDs) > 0 {
242+
return nil
243+
}
244+
}
245+
246+
return errors.New(trimmed)
247+
}
248+
213249
// TorrentInfo represents a torrent from the qBittorrent API.
214250
type TorrentInfo struct {
215251
Name string `json:"name"`

internal/download/qbittorrent_test.go

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,3 +238,167 @@ func TestValidTransitions(t *testing.T) {
238238
})
239239
}
240240
}
241+
242+
func TestQBittorrentAddTorrentAcceptsJSONSuccess(t *testing.T) {
243+
mux := http.NewServeMux()
244+
mux.HandleFunc("/api/v2/auth/login", func(w http.ResponseWriter, r *http.Request) {
245+
http.SetCookie(w, &http.Cookie{Name: "QBT_SID", Value: "abc123", Path: "/"})
246+
w.WriteHeader(http.StatusOK)
247+
_, _ = w.Write([]byte("Ok."))
248+
})
249+
mux.HandleFunc("/api/v2/torrents/add", func(w http.ResponseWriter, r *http.Request) {
250+
if _, err := r.Cookie("QBT_SID"); err != nil {
251+
t.Fatalf("expected auth cookie on add request: %v", err)
252+
}
253+
w.Header().Set("Content-Type", "application/json")
254+
w.WriteHeader(http.StatusOK)
255+
_, _ = w.Write([]byte(`{"added_torrent_ids":["e2f71d638953c009f17594d6982c6de68b06d985"],"failure_count":0,"pending_count":0,"success_count":1}`))
256+
})
257+
258+
srv := httptest.NewServer(mux)
259+
defer srv.Close()
260+
261+
q := newAddTorrentTestQBClient(srv.URL, srv.Client())
262+
263+
if err := q.AddTorrent("https://example.com/file.torrent", "Test Book", "", ""); err != nil {
264+
t.Fatalf("AddTorrent returned error: %v", err)
265+
}
266+
}
267+
268+
func TestQBittorrentAddTorrentAcceptsOkBody(t *testing.T) {
269+
mux := http.NewServeMux()
270+
mux.HandleFunc("/api/v2/auth/login", func(w http.ResponseWriter, r *http.Request) {
271+
http.SetCookie(w, &http.Cookie{Name: "QBT_SID", Value: "abc123", Path: "/"})
272+
w.WriteHeader(http.StatusOK)
273+
_, _ = w.Write([]byte("Ok."))
274+
})
275+
mux.HandleFunc("/api/v2/torrents/add", func(w http.ResponseWriter, r *http.Request) {
276+
w.WriteHeader(http.StatusOK)
277+
_, _ = w.Write([]byte("Ok."))
278+
})
279+
280+
srv := httptest.NewServer(mux)
281+
defer srv.Close()
282+
283+
q := newAddTorrentTestQBClient(srv.URL, srv.Client())
284+
285+
if err := q.AddTorrent("https://example.com/file.torrent", "Test Book", "", ""); err != nil {
286+
t.Fatalf("AddTorrent returned error: %v", err)
287+
}
288+
}
289+
290+
func TestQBittorrentAddTorrentRejectsHTTPError(t *testing.T) {
291+
mux := http.NewServeMux()
292+
mux.HandleFunc("/api/v2/auth/login", func(w http.ResponseWriter, r *http.Request) {
293+
http.SetCookie(w, &http.Cookie{Name: "QBT_SID", Value: "abc123", Path: "/"})
294+
w.WriteHeader(http.StatusOK)
295+
_, _ = w.Write([]byte("Ok."))
296+
})
297+
mux.HandleFunc("/api/v2/torrents/add", func(w http.ResponseWriter, r *http.Request) {
298+
w.WriteHeader(http.StatusBadRequest)
299+
_, _ = w.Write([]byte("bad request"))
300+
})
301+
302+
srv := httptest.NewServer(mux)
303+
defer srv.Close()
304+
305+
q := newAddTorrentTestQBClient(srv.URL, srv.Client())
306+
307+
err := q.AddTorrent("https://example.com/file.torrent", "Test Book", "", "")
308+
if err == nil {
309+
t.Fatal("expected error")
310+
}
311+
if got := err.Error(); !strings.Contains(got, "add torrent HTTP 400: bad request") {
312+
t.Fatalf("error = %q, want HTTP 400 response", got)
313+
}
314+
}
315+
316+
func TestQBittorrentAddTorrentRejectsJSONFailure(t *testing.T) {
317+
mux := http.NewServeMux()
318+
mux.HandleFunc("/api/v2/auth/login", func(w http.ResponseWriter, r *http.Request) {
319+
http.SetCookie(w, &http.Cookie{Name: "QBT_SID", Value: "abc123", Path: "/"})
320+
w.WriteHeader(http.StatusOK)
321+
_, _ = w.Write([]byte("Ok."))
322+
})
323+
mux.HandleFunc("/api/v2/torrents/add", func(w http.ResponseWriter, r *http.Request) {
324+
w.Header().Set("Content-Type", "application/json")
325+
w.WriteHeader(http.StatusOK)
326+
_, _ = w.Write([]byte(`{"added_torrent_ids":[],"failure_count":1,"pending_count":0,"success_count":0,"error":"invalid torrent"}`))
327+
})
328+
329+
srv := httptest.NewServer(mux)
330+
defer srv.Close()
331+
332+
q := newAddTorrentTestQBClient(srv.URL, srv.Client())
333+
334+
err := q.AddTorrent("https://example.com/file.torrent", "Test Book", "", "")
335+
if err == nil {
336+
t.Fatal("expected error")
337+
}
338+
if got := err.Error(); !strings.Contains(got, "invalid torrent") {
339+
t.Fatalf("error = %q, want invalid torrent", got)
340+
}
341+
}
342+
343+
func TestQBittorrentAddTorrentAcceptsJSONPending(t *testing.T) {
344+
mux := http.NewServeMux()
345+
mux.HandleFunc("/api/v2/auth/login", func(w http.ResponseWriter, r *http.Request) {
346+
http.SetCookie(w, &http.Cookie{Name: "QBT_SID", Value: "abc123", Path: "/"})
347+
w.WriteHeader(http.StatusOK)
348+
_, _ = w.Write([]byte("Ok."))
349+
})
350+
mux.HandleFunc("/api/v2/torrents/add", func(w http.ResponseWriter, r *http.Request) {
351+
w.Header().Set("Content-Type", "application/json")
352+
w.WriteHeader(http.StatusOK)
353+
_, _ = w.Write([]byte(`{"added_torrent_ids":[],"failure_count":0,"pending_count":1,"success_count":0}`))
354+
})
355+
356+
srv := httptest.NewServer(mux)
357+
defer srv.Close()
358+
359+
q := newAddTorrentTestQBClient(srv.URL, srv.Client())
360+
361+
if err := q.AddTorrent("https://example.com/file.torrent", "Test Book", "", ""); err != nil {
362+
t.Fatalf("AddTorrent returned error: %v", err)
363+
}
364+
}
365+
366+
func TestQBittorrentAddTorrentRejectsJSONPartialFailure(t *testing.T) {
367+
mux := http.NewServeMux()
368+
mux.HandleFunc("/api/v2/auth/login", func(w http.ResponseWriter, r *http.Request) {
369+
http.SetCookie(w, &http.Cookie{Name: "QBT_SID", Value: "abc123", Path: "/"})
370+
w.WriteHeader(http.StatusOK)
371+
_, _ = w.Write([]byte("Ok."))
372+
})
373+
mux.HandleFunc("/api/v2/torrents/add", func(w http.ResponseWriter, r *http.Request) {
374+
w.Header().Set("Content-Type", "application/json")
375+
w.WriteHeader(http.StatusOK)
376+
_, _ = w.Write([]byte(`{"added_torrent_ids":["e2f71d638953c009f17594d6982c6de68b06d985"],"failure_count":1,"pending_count":0,"success_count":1}`))
377+
})
378+
379+
srv := httptest.NewServer(mux)
380+
defer srv.Close()
381+
382+
q := newAddTorrentTestQBClient(srv.URL, srv.Client())
383+
384+
err := q.AddTorrent("https://example.com/file.torrent", "Test Book", "", "")
385+
if err == nil {
386+
t.Fatal("expected error")
387+
}
388+
if got := err.Error(); !strings.Contains(got, "success_count=1 failure_count=1 pending_count=0") {
389+
t.Fatalf("error = %q, want partial failure counts", got)
390+
}
391+
}
392+
393+
func newAddTorrentTestQBClient(serverURL string, client *http.Client) *QBittorrentClient {
394+
cfg := &config.Config{
395+
QBUrl: serverURL,
396+
QBUser: "admin",
397+
QBPass: "secret",
398+
QBSavePath: "/downloads",
399+
QBCategory: "librarr",
400+
}
401+
q := NewQBittorrentClient(cfg)
402+
q.client = client
403+
return q
404+
}

0 commit comments

Comments
 (0)