Skip to content

Commit 43fe0a4

Browse files
authored
Merge pull request #55 from sgerner/codex/abb-download-flow
resolve ABB downloads end to end
2 parents a43f4e5 + 20f8a63 commit 43fe0a4

4 files changed

Lines changed: 206 additions & 44 deletions

File tree

internal/api/download.go

Lines changed: 118 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
package api
22

33
import (
4+
"context"
45
"encoding/json"
56
"fmt"
67
"net/http"
8+
neturl "net/url"
79
"strings"
10+
"time"
811

912
"github.com/JeremiahM37/librarr/internal/models"
13+
"github.com/JeremiahM37/librarr/internal/search"
1014
)
1115

1216
func (s *Server) handleDownload(w http.ResponseWriter, r *http.Request) {
@@ -27,6 +31,21 @@ func (s *Server) handleDownload(w http.ResponseWriter, r *http.Request) {
2731
s.db.LogActivity(username, "download_start", req.Title, fmt.Sprintf("Download started from %s", req.Source))
2832

2933
source := s.searchMgr.GetSource(req.Source)
34+
if req.MediaType == "" {
35+
if source != nil {
36+
switch source.SearchTab() {
37+
case "audiobook", "manga":
38+
req.MediaType = source.SearchTab()
39+
}
40+
} else {
41+
switch req.Source {
42+
case "audiobook":
43+
req.MediaType = "audiobook"
44+
case "prowlarr_manga":
45+
req.MediaType = "manga"
46+
}
47+
}
48+
}
3049

3150
// Determine download type.
3251
downloadType := "direct"
@@ -44,13 +63,13 @@ func (s *Server) handleDownload(w http.ResponseWriter, r *http.Request) {
4463

4564
switch downloadType {
4665
case "torrent":
47-
s.handleTorrentDownload(w, req)
66+
s.handleTorrentDownload(w, r, req)
4867
default:
4968
s.handleDirectDownloadReq(w, req)
5069
}
5170
}
5271

53-
func (s *Server) handleTorrentDownload(w http.ResponseWriter, req models.DownloadRequest) {
72+
func (s *Server) handleTorrentDownload(w http.ResponseWriter, r *http.Request, req models.DownloadRequest) {
5473
if !s.cfg.HasQBittorrent() {
5574
writeJSON(w, http.StatusBadRequest, map[string]interface{}{
5675
"success": false,
@@ -59,7 +78,14 @@ func (s *Server) handleTorrentDownload(w http.ResponseWriter, req models.Downloa
5978
return
6079
}
6180

62-
url := resolveTorrentURL(req)
81+
url, err := s.resolveTorrentURL(r.Context(), req, models.SearchResult{})
82+
if err != nil {
83+
writeJSON(w, http.StatusBadRequest, map[string]interface{}{
84+
"success": false,
85+
"error": fmt.Sprintf("Failed to resolve AudioBookBay download: %v", err),
86+
})
87+
return
88+
}
6389
if url == "" {
6490
writeJSON(w, http.StatusBadRequest, map[string]interface{}{
6591
"success": false,
@@ -90,7 +116,7 @@ func (s *Server) handleTorrentDownload(w http.ResponseWriter, req models.Downloa
90116
category = s.cfg.QBMangaCategory
91117
}
92118

93-
err := s.downloadMgr.StartTorrentDownload(url, req.Title, savePath, category)
119+
err = s.downloadMgr.StartTorrentDownload(url, req.Title, savePath, category)
94120
writeJSON(w, http.StatusOK, map[string]interface{}{
95121
"success": err == nil,
96122
"title": req.Title,
@@ -173,7 +199,7 @@ func (s *Server) handleDownloadTorrent(w http.ResponseWriter, r *http.Request) {
173199
req.Title = "Unknown"
174200
}
175201
req.MediaType = "ebook"
176-
s.handleTorrentDownload(w, req)
202+
s.handleTorrentDownload(w, r, req)
177203
}
178204

179205
func (s *Server) handleDownloadAnnas(w http.ResponseWriter, r *http.Request) {
@@ -202,7 +228,7 @@ func (s *Server) handleDownloadAudiobook(w http.ResponseWriter, r *http.Request)
202228
req.Title = "Unknown"
203229
}
204230
req.MediaType = "audiobook"
205-
s.handleTorrentDownload(w, req)
231+
s.handleTorrentDownload(w, r, req)
206232
}
207233

208234
func (s *Server) handleGetDownloads(w http.ResponseWriter, _ *http.Request) {
@@ -256,22 +282,6 @@ func (s *Server) handleCheckDuplicate(w http.ResponseWriter, r *http.Request) {
256282
})
257283
}
258284

259-
func resolveTorrentURL(req models.DownloadRequest) string {
260-
if req.DownloadURL != "" {
261-
return req.DownloadURL
262-
}
263-
if strings.HasPrefix(req.GUID, "magnet:") {
264-
return req.GUID
265-
}
266-
if req.MagnetURL != "" {
267-
return req.MagnetURL
268-
}
269-
if req.InfoHash != "" {
270-
return fmt.Sprintf("magnet:?xt=urn:btih:%s", req.InfoHash)
271-
}
272-
return ""
273-
}
274-
275285
func extractSourceID(req models.DownloadRequest) string {
276286
if req.MD5 != "" {
277287
return req.MD5
@@ -288,6 +298,92 @@ func extractSourceID(req models.DownloadRequest) string {
288298
return ""
289299
}
290300

301+
var resolveABBMagnetFn = search.ResolveABBMagnet
302+
303+
func (s *Server) resolveTorrentURL(ctx context.Context, req models.DownloadRequest, chosen models.SearchResult) (string, error) {
304+
if req.DownloadURL != "" {
305+
return req.DownloadURL, nil
306+
}
307+
if strings.HasPrefix(req.GUID, "magnet:") {
308+
return req.GUID, nil
309+
}
310+
if req.MagnetURL != "" {
311+
return req.MagnetURL, nil
312+
}
313+
if chosen.MagnetURL != "" {
314+
return chosen.MagnetURL, nil
315+
}
316+
if chosen.DownloadURL != "" {
317+
return chosen.DownloadURL, nil
318+
}
319+
if req.InfoHash != "" {
320+
return fmt.Sprintf("magnet:?xt=urn:btih:%s", req.InfoHash), nil
321+
}
322+
323+
abbPath := req.AbbURL
324+
if abbPath == "" {
325+
abbPath = chosen.AbbURL
326+
}
327+
if abbPath == "" {
328+
return "", nil
329+
}
330+
331+
resolved, err := s.resolveABBMagnet(ctx, abbPath)
332+
if err != nil {
333+
return "", err
334+
}
335+
return resolved, nil
336+
}
337+
338+
func (s *Server) resolveABBMagnet(ctx context.Context, abbURL string) (string, error) {
339+
if s.cfg == nil || s.cfg.Sources == nil {
340+
return "", fmt.Errorf("AudioBookBay sources not configured")
341+
}
342+
343+
abbPath := normalizeABBPath(abbURL)
344+
if abbPath == "" {
345+
return "", fmt.Errorf("invalid AudioBookBay URL")
346+
}
347+
348+
client := &http.Client{Timeout: 15 * time.Second}
349+
return resolveABBMagnetFn(ctx, client, s.cfg.UserAgent, abbPath, s.cfg.Sources.AudioBookBay.Mirrors, s.cfg.Sources.AudioBookBay.Trackers)
350+
}
351+
352+
func normalizeABBPath(raw string) string {
353+
raw = strings.TrimSpace(raw)
354+
if raw == "" {
355+
return ""
356+
}
357+
if strings.HasPrefix(raw, "/") {
358+
return raw
359+
}
360+
361+
u, err := neturl.Parse(raw)
362+
if err != nil {
363+
if strings.HasPrefix(raw, "http://") || strings.HasPrefix(raw, "https://") {
364+
return ""
365+
}
366+
return "/" + strings.TrimLeft(raw, "/")
367+
}
368+
if u.Scheme == "" && u.Host == "" {
369+
if strings.HasPrefix(u.Path, "/") {
370+
if u.RawQuery != "" {
371+
return u.Path + "?" + u.RawQuery
372+
}
373+
return u.Path
374+
}
375+
return "/" + strings.TrimLeft(u.Path, "/")
376+
}
377+
path := u.EscapedPath()
378+
if path == "" {
379+
path = "/"
380+
}
381+
if u.RawQuery != "" {
382+
path += "?" + u.RawQuery
383+
}
384+
return path
385+
}
386+
291387
func errString(err error) string {
292388
if err == nil {
293389
return ""

internal/api/regressions_test.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
package api
22

33
import (
4+
"context"
5+
"net/http"
46
"net/http/httptest"
57
"testing"
8+
9+
"github.com/JeremiahM37/librarr/internal/config"
10+
"github.com/JeremiahM37/librarr/internal/models"
11+
"github.com/JeremiahM37/librarr/internal/sources/sourcestest"
612
)
713

814
// queryBoundedInt rejects out-of-range and unparseable values, returning fallback.
@@ -33,3 +39,79 @@ func TestQueryBoundedInt(t *testing.T) {
3339
})
3440
}
3541
}
42+
43+
func TestResolveTorrentURLUsesABBURL(t *testing.T) {
44+
reg, err := sourcestest.Registry()
45+
if err != nil {
46+
t.Fatalf("load registry: %v", err)
47+
}
48+
cfg := &config.Config{
49+
UserAgent: "test-agent",
50+
Sources: reg,
51+
}
52+
cfg.Sources.AudioBookBay.Mirrors = []string{"audiobookbay.lu"}
53+
cfg.Sources.AudioBookBay.Trackers = []string{"udp://tracker.example:1337/announce"}
54+
55+
s := &Server{cfg: cfg}
56+
57+
oldResolve := resolveABBMagnetFn
58+
defer func() { resolveABBMagnetFn = oldResolve }()
59+
60+
called := false
61+
resolveABBMagnetFn = func(ctx context.Context, client *http.Client, userAgent, abbPath string, mirrors, fallbackTrackers []string) (string, error) {
62+
called = true
63+
if userAgent != "test-agent" {
64+
t.Fatalf("userAgent = %q, want test-agent", userAgent)
65+
}
66+
if abbPath != "/abss/the-martian-andy-weir/" {
67+
t.Fatalf("abbPath = %q, want /abss/the-martian-andy-weir/", abbPath)
68+
}
69+
if len(mirrors) != 1 || mirrors[0] != "audiobookbay.lu" {
70+
t.Fatalf("mirrors = %#v, want audiobookbay.lu", mirrors)
71+
}
72+
if len(fallbackTrackers) != 1 || fallbackTrackers[0] != "udp://tracker.example:1337/announce" {
73+
t.Fatalf("fallbackTrackers = %#v, want configured tracker", fallbackTrackers)
74+
}
75+
return "magnet:?xt=urn:btih:0123456789ABCDEF0123456789ABCDEF01234567", nil
76+
}
77+
78+
got, err := s.resolveTorrentURL(context.Background(), models.DownloadRequest{
79+
AbbURL: "/abss/the-martian-andy-weir/",
80+
}, models.SearchResult{})
81+
if err != nil {
82+
t.Fatalf("resolveTorrentURL returned error: %v", err)
83+
}
84+
if !called {
85+
t.Fatal("expected ABB resolver to be called")
86+
}
87+
want := "magnet:?xt=urn:btih:0123456789ABCDEF0123456789ABCDEF01234567"
88+
if got != want {
89+
t.Fatalf("resolveTorrentURL = %q, want %q", got, want)
90+
}
91+
}
92+
93+
func TestResolveTorrentURLPrefersDirectURL(t *testing.T) {
94+
reg, err := sourcestest.Registry()
95+
if err != nil {
96+
t.Fatalf("load registry: %v", err)
97+
}
98+
s := &Server{cfg: &config.Config{Sources: reg}}
99+
100+
oldResolve := resolveABBMagnetFn
101+
defer func() { resolveABBMagnetFn = oldResolve }()
102+
resolveABBMagnetFn = func(context.Context, *http.Client, string, string, []string, []string) (string, error) {
103+
t.Fatal("ABB resolver should not be called when download_url is present")
104+
return "", nil
105+
}
106+
107+
got, err := s.resolveTorrentURL(context.Background(), models.DownloadRequest{
108+
DownloadURL: "https://example.com/book.torrent",
109+
AbbURL: "/abss/the-martian-andy-weir/",
110+
}, models.SearchResult{})
111+
if err != nil {
112+
t.Fatalf("resolveTorrentURL returned error: %v", err)
113+
}
114+
if got != "https://example.com/book.torrent" {
115+
t.Fatalf("resolveTorrentURL = %q, want direct download URL", got)
116+
}
117+
}

internal/api/requests.go

Lines changed: 5 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -506,7 +506,11 @@ func (s *Server) processApprovedRequest(req *models.Request) {
506506

507507
switch downloadType {
508508
case "torrent":
509-
url := resolveTorrentURLForRequest(dlReq, chosen)
509+
url, err := s.resolveTorrentURL(ctx, dlReq, chosen)
510+
if err != nil {
511+
s.failRequest(req, fmt.Sprintf("Torrent download failed: %v", err))
512+
return
513+
}
510514
if url == "" {
511515
s.failRequest(req, "No torrent download URL available")
512516
return
@@ -676,26 +680,6 @@ func scoreResult(r models.SearchResult, bookType string) int {
676680
return score
677681
}
678682

679-
// resolveTorrentURLForRequest resolves the torrent URL from a download request and chosen result.
680-
func resolveTorrentURLForRequest(dlReq models.DownloadRequest, chosen models.SearchResult) string {
681-
if dlReq.DownloadURL != "" {
682-
return dlReq.DownloadURL
683-
}
684-
if dlReq.MagnetURL != "" {
685-
return dlReq.MagnetURL
686-
}
687-
if chosen.MagnetURL != "" {
688-
return chosen.MagnetURL
689-
}
690-
if chosen.DownloadURL != "" {
691-
return chosen.DownloadURL
692-
}
693-
if dlReq.InfoHash != "" {
694-
return "magnet:?xt=urn:btih:" + dlReq.InfoHash
695-
}
696-
return ""
697-
}
698-
699683
// resolveSavePathAndCategory returns the qBittorrent save path and category for a book type.
700684
func (s *Server) resolveSavePathAndCategory(bookType string) (string, string) {
701685
switch bookType {

web/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1836,6 +1836,7 @@ <h3 class="text-sm font-semibold text-white line-clamp-2 mb-1" title="${escapeHt
18361836
const body = {
18371837
title: result.title,
18381838
download_url: result.download_url || result.url || '',
1839+
abb_url: result.abb_url || '',
18391840
source: result.source,
18401841
md5: result.md5 || '',
18411842
author: result.author || '',
@@ -2956,4 +2957,3 @@ <h4 class="text-sm font-medium text-white truncate">${escapeHtml(item.title || '
29562957
</script>
29572958
</body>
29582959
</html>
2959-

0 commit comments

Comments
 (0)