diff --git a/internal/api/handlers/torrents.go b/internal/api/handlers/torrents.go index 9302bf8c7..37c93d0e1 100644 --- a/internal/api/handlers/torrents.go +++ b/internal/api/handlers/torrents.go @@ -23,11 +23,28 @@ import ( "github.com/rs/zerolog/log" "github.com/autobrr/qui/internal/qbittorrent" + "github.com/autobrr/qui/internal/services/jackett" "github.com/autobrr/qui/pkg/torrentname" ) +// torrentAdder is the interface for adding torrents (used for testing) +type torrentAdder interface { + AddTorrent(ctx context.Context, instanceID int, fileContent []byte, options map[string]string) error + AddTorrentFromURLs(ctx context.Context, instanceID int, urls []string, options map[string]string) error + GetAppPreferences(ctx context.Context, instanceID int) (qbt.AppPreferences, error) +} + +// torrentDownloader is the interface for downloading torrents from indexers (used for testing) +type torrentDownloader interface { + DownloadTorrent(ctx context.Context, req jackett.TorrentDownloadRequest) ([]byte, error) +} + type TorrentsHandler struct { - syncManager *qbittorrent.SyncManager + syncManager *qbittorrent.SyncManager + jackettService *jackett.Service + // Testing interfaces - when set, these are used instead of the concrete types + torrentAdder torrentAdder + torrentDownloader torrentDownloader } // truncateExpr truncates long filter expressions for cleaner logging @@ -52,10 +69,56 @@ type SortedPeersResponse struct { SortedPeers []SortedPeer `json:"sorted_peers,omitempty"` } -func NewTorrentsHandler(syncManager *qbittorrent.SyncManager) *TorrentsHandler { +func NewTorrentsHandler(syncManager *qbittorrent.SyncManager, jackettService *jackett.Service) *TorrentsHandler { + return &TorrentsHandler{ + syncManager: syncManager, + jackettService: jackettService, + } +} + +// NewTorrentsHandlerForTesting creates a TorrentsHandler with mock interfaces for testing +func NewTorrentsHandlerForTesting(adder torrentAdder, downloader torrentDownloader) *TorrentsHandler { return &TorrentsHandler{ - syncManager: syncManager, + torrentAdder: adder, + torrentDownloader: downloader, + } +} + +// addTorrent wraps the torrent addition to support both production and test modes +func (h *TorrentsHandler) addTorrent(ctx context.Context, instanceID int, fileContent []byte, options map[string]string) error { + if h.torrentAdder != nil { + return h.torrentAdder.AddTorrent(ctx, instanceID, fileContent, options) + } + return h.syncManager.AddTorrent(ctx, instanceID, fileContent, options) +} + +// addTorrentFromURLs wraps URL-based torrent addition to support both production and test modes +func (h *TorrentsHandler) addTorrentFromURLs(ctx context.Context, instanceID int, urls []string, options map[string]string) error { + if h.torrentAdder != nil { + return h.torrentAdder.AddTorrentFromURLs(ctx, instanceID, urls, options) + } + return h.syncManager.AddTorrentFromURLs(ctx, instanceID, urls, options) +} + +// getAppPreferences wraps preferences retrieval to support both production and test modes +func (h *TorrentsHandler) getAppPreferences(ctx context.Context, instanceID int) (qbt.AppPreferences, error) { + if h.torrentAdder != nil { + return h.torrentAdder.GetAppPreferences(ctx, instanceID) + } + return h.syncManager.GetAppPreferences(ctx, instanceID) +} + +// downloadTorrent wraps torrent download to support both production and test modes +func (h *TorrentsHandler) downloadTorrent(ctx context.Context, req jackett.TorrentDownloadRequest) ([]byte, error) { + if h.torrentDownloader != nil { + return h.torrentDownloader.DownloadTorrent(ctx, req) } + return h.jackettService.DownloadTorrent(ctx, req) +} + +// hasJackettService checks if jackett service is available (either real or mock) +func (h *TorrentsHandler) hasJackettService() bool { + return h.jackettService != nil || h.torrentDownloader != nil } // ListTorrents returns paginated torrents for an instance with enhanced metadata @@ -246,6 +309,13 @@ func (h *TorrentsHandler) AddTorrent(w http.ResponseWriter, r *http.Request) { var torrentFiles [][]byte var urls []string + // Track file processing failures for response + type fileReadFailure struct { + filename string + err string + } + var fileReadFailures []fileReadFailure + // Check for torrent files (multiple files supported) if r.MultipartForm != nil && r.MultipartForm.File != nil { fileHeaders := r.MultipartForm.File["torrent"] @@ -253,14 +323,16 @@ func (h *TorrentsHandler) AddTorrent(w http.ResponseWriter, r *http.Request) { for _, fileHeader := range fileHeaders { file, err := fileHeader.Open() if err != nil { - log.Error().Err(err).Str("filename", fileHeader.Filename).Msg("Failed to open torrent file") + log.Warn().Err(err).Str("filename", fileHeader.Filename).Msg("Failed to open torrent file") + fileReadFailures = append(fileReadFailures, fileReadFailure{filename: fileHeader.Filename, err: "Failed to open file"}) continue } defer file.Close() fileContent, err := io.ReadAll(file) if err != nil { - log.Error().Err(err).Str("filename", fileHeader.Filename).Msg("Failed to read torrent file") + log.Warn().Err(err).Str("filename", fileHeader.Filename).Msg("Failed to read torrent file") + fileReadFailures = append(fileReadFailures, fileReadFailure{filename: fileHeader.Filename, err: "Failed to read file"}) continue } torrentFiles = append(torrentFiles, fileContent) @@ -269,6 +341,7 @@ func (h *TorrentsHandler) AddTorrent(w http.ResponseWriter, r *http.Request) { } // Check for URLs/magnet links if no files + var indexerID int if len(torrentFiles) == 0 { urlsParam := r.FormValue("urls") if urlsParam != "" { @@ -279,6 +352,22 @@ func (h *TorrentsHandler) AddTorrent(w http.ResponseWriter, r *http.Request) { RespondError(w, http.StatusBadRequest, "Either torrent files or URLs are required") return } + + // Parse indexer_id if provided (for downloading torrent from indexer) + if indexerIDStr := r.FormValue("indexer_id"); indexerIDStr != "" { + var err error + indexerID, err = strconv.Atoi(indexerIDStr) + if err != nil { + log.Error().Err(err).Str("indexer_id", indexerIDStr).Msg("Invalid indexer_id provided") + RespondError(w, http.StatusBadRequest, fmt.Sprintf("Invalid indexer_id: %q is not a valid integer", indexerIDStr)) + return + } + if indexerID <= 0 { + log.Error().Int("indexer_id", indexerID).Msg("Invalid indexer_id: must be positive") + RespondError(w, http.StatusBadRequest, "Invalid indexer_id: must be a positive integer") + return + } + } } // Parse options from form @@ -301,7 +390,7 @@ func (h *TorrentsHandler) AddTorrent(w http.ResponseWriter, r *http.Request) { requestedPaused := pausedStr == "true" // Get current preferences to check start_paused_enabled - prefs, err := h.syncManager.GetAppPreferences(ctx, instanceID) + prefs, err := h.getAppPreferences(ctx, instanceID) if err != nil { log.Warn().Err(err).Int("instanceID", instanceID).Msg("Failed to get preferences for paused check, defaulting to explicit paused setting") // If we can't get preferences, apply the requested paused state explicitly @@ -398,10 +487,20 @@ func (h *TorrentsHandler) AddTorrent(w http.ResponseWriter, r *http.Request) { } } - // Track results for multiple files + // Track results for multiple files/URLs var addedCount int var failedCount int var lastError error + type failedURL struct { + URL string `json:"url"` + Error string `json:"error"` + } + var failedURLs []failedURL + type failedFile struct { + Filename string `json:"filename"` + Error string `json:"error"` + } + var failedFiles []failedFile // Add torrent(s) if len(torrentFiles) > 0 { @@ -413,28 +512,108 @@ func (h *TorrentsHandler) AddTorrent(w http.ResponseWriter, r *http.Request) { break } - if err := h.syncManager.AddTorrent(ctx, instanceID, fileContent, options); err != nil { + if err := h.addTorrent(ctx, instanceID, fileContent, options); err != nil { if respondIfInstanceDisabled(w, err, instanceID, "torrents:add") { return } log.Error().Err(err).Int("instanceID", instanceID).Int("fileIndex", i).Msg("Failed to add torrent file") + failedFiles = append(failedFiles, failedFile{Filename: fmt.Sprintf("file_%d", i), Error: err.Error()}) failedCount++ lastError = err } else { addedCount++ } } + // Include file read failures in the count and response + for _, f := range fileReadFailures { + failedFiles = append(failedFiles, failedFile{Filename: f.filename, Error: f.err}) + failedCount++ + } } else if len(urls) > 0 { // Add from URLs - if err := h.syncManager.AddTorrentFromURLs(ctx, instanceID, urls, options); err != nil { - if respondIfInstanceDisabled(w, err, instanceID, "torrents:addFromURLs") { + // If indexer_id is provided, download torrent files from the indexer first + // (needed for remote qBittorrent instances that can't reach the indexer) + if indexerID > 0 { + if !h.hasJackettService() { + log.Error().Int("indexerID", indexerID).Int("instanceID", instanceID). + Msg("Indexer download requested but jackett service is not available") + RespondError(w, http.StatusServiceUnavailable, + "Indexer service is not available. Configure an indexer or remove indexer_id to use direct URL method.") return } - log.Error().Err(err).Int("instanceID", instanceID).Msg("Failed to add torrent from URLs") - RespondError(w, http.StatusInternalServerError, "Failed to add torrent") - return + var skippedEmpty int + for _, url := range urls { + url = strings.TrimSpace(url) + if url == "" { + skippedEmpty++ + continue + } + + // Check if context is already cancelled + if ctx.Err() != nil { + log.Warn().Int("instanceID", instanceID).Msg("Request cancelled, stopping torrent additions") + break + } + + // Magnet links can be added directly to qBittorrent + if strings.HasPrefix(strings.ToLower(url), "magnet:") { + if err := h.addTorrentFromURLs(ctx, instanceID, []string{url}, options); err != nil { + if respondIfInstanceDisabled(w, err, instanceID, "torrents:addFromURLs") { + return + } + log.Error().Err(err).Int("instanceID", instanceID).Str("url", url).Msg("Failed to add magnet link") + failedURLs = append(failedURLs, failedURL{URL: url, Error: err.Error()}) + failedCount++ + lastError = err + } else { + addedCount++ + } + continue + } + + // Download torrent file from indexer + torrentBytes, err := h.downloadTorrent(ctx, jackett.TorrentDownloadRequest{ + IndexerID: indexerID, + DownloadURL: url, + }) + if err != nil { + log.Error().Err(err).Int("indexerID", indexerID).Int("instanceID", instanceID).Str("url", url).Msg("Failed to download torrent from indexer") + failedURLs = append(failedURLs, failedURL{URL: url, Error: err.Error()}) + failedCount++ + lastError = err + continue + } + + // Add torrent from downloaded file content + if err := h.addTorrent(ctx, instanceID, torrentBytes, options); err != nil { + if respondIfInstanceDisabled(w, err, instanceID, "torrents:add") { + return + } + log.Error().Err(err).Int("instanceID", instanceID).Int("indexerID", indexerID).Str("url", url).Msg("Failed to add downloaded torrent") + failedURLs = append(failedURLs, failedURL{URL: url, Error: err.Error()}) + failedCount++ + lastError = err + } else { + addedCount++ + } + } + if skippedEmpty > 0 { + log.Debug().Int("skippedEmpty", skippedEmpty).Int("instanceID", instanceID). + Msg("Skipped empty URLs in add torrent request") + } + } else { + // No indexer_id - use URL method directly + // (works for local qBittorrent instances or magnet links) + if err := h.addTorrentFromURLs(ctx, instanceID, urls, options); err != nil { + if respondIfInstanceDisabled(w, err, instanceID, "torrents:addFromURLs") { + return + } + log.Error().Err(err).Int("instanceID", instanceID).Msg("Failed to add torrent from URLs") + RespondError(w, http.StatusInternalServerError, "Failed to add torrent") + return + } + addedCount = len(urls) // Assume all URLs succeeded for simplicity } - addedCount = len(urls) // Assume all URLs succeeded for simplicity } // Check if any torrents failed @@ -457,11 +636,18 @@ func (h *TorrentsHandler) AddTorrent(w http.ResponseWriter, r *http.Request) { message = "Torrent added successfully" } - RespondJSON(w, http.StatusCreated, map[string]any{ + response := map[string]any{ "message": message, "added": addedCount, "failed": failedCount, - }) + } + if len(failedURLs) > 0 { + response["failedURLs"] = failedURLs + } + if len(failedFiles) > 0 { + response["failedFiles"] = failedFiles + } + RespondJSON(w, http.StatusCreated, response) } // BulkActionRequest represents a bulk action request diff --git a/internal/api/handlers/torrents_add_test.go b/internal/api/handlers/torrents_add_test.go new file mode 100644 index 000000000..b4e755f78 --- /dev/null +++ b/internal/api/handlers/torrents_add_test.go @@ -0,0 +1,1171 @@ +// Copyright (c) 2025, s0up and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package handlers + +import ( + "bytes" + "context" + "errors" + "mime/multipart" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + + qbt "github.com/autobrr/go-qbittorrent" + "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/autobrr/qui/internal/services/jackett" +) + +// mockSyncManager implements the methods needed for AddTorrent testing +type mockSyncManager struct { + addTorrentCalls []addTorrentCall + addTorrentFromURLsCalls []addTorrentFromURLsCall + addTorrentErr error + addTorrentFromURLsErr error +} + +type addTorrentCall struct { + instanceID int + fileContent []byte + options map[string]string +} + +type addTorrentFromURLsCall struct { + instanceID int + urls []string + options map[string]string +} + +func (m *mockSyncManager) AddTorrent(ctx context.Context, instanceID int, fileContent []byte, options map[string]string) error { + m.addTorrentCalls = append(m.addTorrentCalls, addTorrentCall{ + instanceID: instanceID, + fileContent: fileContent, + options: options, + }) + return m.addTorrentErr +} + +func (m *mockSyncManager) AddTorrentFromURLs(ctx context.Context, instanceID int, urls []string, options map[string]string) error { + m.addTorrentFromURLsCalls = append(m.addTorrentFromURLsCalls, addTorrentFromURLsCall{ + instanceID: instanceID, + urls: urls, + options: options, + }) + return m.addTorrentFromURLsErr +} + +// mockJackettService implements the DownloadTorrent method for testing +type mockJackettService struct { + downloadTorrentCalls []jackett.TorrentDownloadRequest + downloadTorrentData []byte + downloadTorrentErr error +} + +func (m *mockJackettService) DownloadTorrent(ctx context.Context, req jackett.TorrentDownloadRequest) ([]byte, error) { + m.downloadTorrentCalls = append(m.downloadTorrentCalls, req) + return m.downloadTorrentData, m.downloadTorrentErr +} + +// syncManagerAdapter wraps mockSyncManager to match the interface expected by TorrentsHandler +type syncManagerAdapter interface { + AddTorrent(ctx context.Context, instanceID int, fileContent []byte, options map[string]string) error + AddTorrentFromURLs(ctx context.Context, instanceID int, urls []string, options map[string]string) error +} + +// jackettServiceAdapter wraps mockJackettService to match the interface expected by TorrentsHandler +type jackettServiceAdapter interface { + DownloadTorrent(ctx context.Context, req jackett.TorrentDownloadRequest) ([]byte, error) +} + +// addTorrentWithIndexer tests the core logic of adding torrents with indexer_id +// This function extracts and tests the indexer-aware torrent addition logic +func addTorrentWithIndexer( + ctx context.Context, + syncManager syncManagerAdapter, + jackettService jackettServiceAdapter, + instanceID int, + urls []string, + indexerID int, + options map[string]string, +) (addedCount int, failedCount int, lastError error) { + if indexerID > 0 && jackettService != nil { + for _, url := range urls { + url = strings.TrimSpace(url) + if url == "" { + continue + } + + // Magnet links can be added directly to qBittorrent + if strings.HasPrefix(strings.ToLower(url), "magnet:") { + if err := syncManager.AddTorrentFromURLs(ctx, instanceID, []string{url}, options); err != nil { + failedCount++ + lastError = err + } else { + addedCount++ + } + continue + } + + // Download torrent file from indexer + torrentBytes, err := jackettService.DownloadTorrent(ctx, jackett.TorrentDownloadRequest{ + IndexerID: indexerID, + DownloadURL: url, + }) + if err != nil { + failedCount++ + lastError = err + continue + } + + // Add torrent from downloaded file content + if err := syncManager.AddTorrent(ctx, instanceID, torrentBytes, options); err != nil { + failedCount++ + lastError = err + } else { + addedCount++ + } + } + } else { + // No indexer_id or no jackett service - use URL method directly + if err := syncManager.AddTorrentFromURLs(ctx, instanceID, urls, options); err != nil { + return 0, len(urls), err + } + addedCount = len(urls) + } + return addedCount, failedCount, lastError +} + +func TestAddTorrentWithIndexer_DownloadsViaBackend(t *testing.T) { + t.Parallel() + + mockSync := &mockSyncManager{} + mockJackett := &mockJackettService{ + downloadTorrentData: []byte("fake torrent data"), + } + + ctx := context.Background() + urls := []string{"http://indexer.example.com/download/123"} + options := map[string]string{"category": "movies"} + + added, failed, err := addTorrentWithIndexer(ctx, mockSync, mockJackett, 1, urls, 42, options) + + require.NoError(t, err) + assert.Equal(t, 1, added) + assert.Equal(t, 0, failed) + + // Verify jackett service was called to download + require.Len(t, mockJackett.downloadTorrentCalls, 1) + assert.Equal(t, 42, mockJackett.downloadTorrentCalls[0].IndexerID) + assert.Equal(t, "http://indexer.example.com/download/123", mockJackett.downloadTorrentCalls[0].DownloadURL) + + // Verify sync manager received the downloaded torrent bytes + require.Len(t, mockSync.addTorrentCalls, 1) + assert.Equal(t, 1, mockSync.addTorrentCalls[0].instanceID) + assert.Equal(t, []byte("fake torrent data"), mockSync.addTorrentCalls[0].fileContent) + assert.Equal(t, "movies", mockSync.addTorrentCalls[0].options["category"]) + + // Verify URL method was NOT called + assert.Empty(t, mockSync.addTorrentFromURLsCalls) +} + +func TestAddTorrentWithIndexer_FallsBackWithoutIndexerID(t *testing.T) { + t.Parallel() + + mockSync := &mockSyncManager{} + mockJackett := &mockJackettService{ + downloadTorrentData: []byte("fake torrent data"), + } + + ctx := context.Background() + urls := []string{"http://indexer.example.com/download/123"} + options := map[string]string{"category": "movies"} + + // indexerID = 0, should fall back to direct URL method + added, failed, err := addTorrentWithIndexer(ctx, mockSync, mockJackett, 1, urls, 0, options) + + require.NoError(t, err) + assert.Equal(t, 1, added) + assert.Equal(t, 0, failed) + + // Verify jackett service was NOT called + assert.Empty(t, mockJackett.downloadTorrentCalls) + + // Verify URL method WAS called + require.Len(t, mockSync.addTorrentFromURLsCalls, 1) + assert.Equal(t, 1, mockSync.addTorrentFromURLsCalls[0].instanceID) + assert.Equal(t, urls, mockSync.addTorrentFromURLsCalls[0].urls) +} + +func TestAddTorrentWithIndexer_FallsBackWithNilJackettService(t *testing.T) { + t.Parallel() + + mockSync := &mockSyncManager{} + + ctx := context.Background() + urls := []string{"http://indexer.example.com/download/123"} + options := map[string]string{"category": "movies"} + + // jackettService = nil, should fall back to direct URL method + added, failed, err := addTorrentWithIndexer(ctx, mockSync, nil, 1, urls, 42, options) + + require.NoError(t, err) + assert.Equal(t, 1, added) + assert.Equal(t, 0, failed) + + // Verify URL method WAS called + require.Len(t, mockSync.addTorrentFromURLsCalls, 1) + assert.Equal(t, 1, mockSync.addTorrentFromURLsCalls[0].instanceID) + assert.Equal(t, urls, mockSync.addTorrentFromURLsCalls[0].urls) +} + +func TestAddTorrentWithIndexer_MagnetLinksPassedDirectly(t *testing.T) { + t.Parallel() + + mockSync := &mockSyncManager{} + mockJackett := &mockJackettService{ + downloadTorrentData: []byte("fake torrent data"), + } + + ctx := context.Background() + magnetURL := "magnet:?xt=urn:btih:1234567890abcdef1234567890abcdef12345678" + urls := []string{magnetURL} + options := map[string]string{"category": "movies"} + + added, failed, err := addTorrentWithIndexer(ctx, mockSync, mockJackett, 1, urls, 42, options) + + require.NoError(t, err) + assert.Equal(t, 1, added) + assert.Equal(t, 0, failed) + + // Verify jackett service was NOT called for magnet links + assert.Empty(t, mockJackett.downloadTorrentCalls) + + // Verify magnet was passed directly to qBittorrent + require.Len(t, mockSync.addTorrentFromURLsCalls, 1) + assert.Equal(t, []string{magnetURL}, mockSync.addTorrentFromURLsCalls[0].urls) + + // Verify AddTorrent (file method) was NOT called + assert.Empty(t, mockSync.addTorrentCalls) +} + +func TestAddTorrentWithIndexer_MixedURLsAndMagnets(t *testing.T) { + t.Parallel() + + mockSync := &mockSyncManager{} + mockJackett := &mockJackettService{ + downloadTorrentData: []byte("fake torrent data"), + } + + ctx := context.Background() + magnetURL := "magnet:?xt=urn:btih:1234567890abcdef1234567890abcdef12345678" + httpURL := "http://indexer.example.com/download/123" + urls := []string{magnetURL, httpURL} + options := map[string]string{"category": "movies"} + + added, failed, err := addTorrentWithIndexer(ctx, mockSync, mockJackett, 1, urls, 42, options) + + require.NoError(t, err) + assert.Equal(t, 2, added) + assert.Equal(t, 0, failed) + + // Verify magnet was passed directly + require.Len(t, mockSync.addTorrentFromURLsCalls, 1) + assert.Equal(t, []string{magnetURL}, mockSync.addTorrentFromURLsCalls[0].urls) + + // Verify HTTP URL was downloaded via jackett + require.Len(t, mockJackett.downloadTorrentCalls, 1) + assert.Equal(t, httpURL, mockJackett.downloadTorrentCalls[0].DownloadURL) + + // Verify downloaded torrent was added + require.Len(t, mockSync.addTorrentCalls, 1) + assert.Equal(t, []byte("fake torrent data"), mockSync.addTorrentCalls[0].fileContent) +} + +func TestAddTorrentWithIndexer_DownloadFailureContinuesWithOthers(t *testing.T) { + t.Parallel() + + mockSync := &mockSyncManager{} + downloadErr := errors.New("download failed") + + // Create a custom mock that fails on first call, second succeeds + customJackett := &customMockJackettService{ + responses: []jackettResponse{ + {err: downloadErr}, + {data: []byte("fake torrent data")}, + }, + } + + ctx := context.Background() + urls := []string{ + "http://indexer.example.com/download/fail", + "http://indexer.example.com/download/success", + } + options := map[string]string{"category": "movies"} + + added, failed, err := addTorrentWithIndexer(ctx, mockSync, customJackett, 1, urls, 42, options) + + // Last error should be from the failed download + require.Error(t, err) + assert.Equal(t, downloadErr, err) + assert.Equal(t, 1, added) + assert.Equal(t, 1, failed) + + // Verify both URLs were attempted + assert.Equal(t, 2, len(customJackett.calls)) + + // Verify only the successful download was added + require.Len(t, mockSync.addTorrentCalls, 1) +} + +func TestAddTorrentWithIndexer_AllDownloadsFail(t *testing.T) { + t.Parallel() + + mockSync := &mockSyncManager{} + downloadErr := errors.New("download failed") + mockJackett := &mockJackettService{ + downloadTorrentErr: downloadErr, + } + + ctx := context.Background() + urls := []string{ + "http://indexer.example.com/download/1", + "http://indexer.example.com/download/2", + } + options := map[string]string{"category": "movies"} + + added, failed, err := addTorrentWithIndexer(ctx, mockSync, mockJackett, 1, urls, 42, options) + + require.Error(t, err) + assert.Equal(t, downloadErr, err) + assert.Equal(t, 0, added) + assert.Equal(t, 2, failed) + + // Verify no torrents were added + assert.Empty(t, mockSync.addTorrentCalls) +} + +func TestAddTorrentWithIndexer_AddTorrentFails(t *testing.T) { + t.Parallel() + + addErr := errors.New("add torrent failed") + mockSync := &mockSyncManager{ + addTorrentErr: addErr, + } + mockJackett := &mockJackettService{ + downloadTorrentData: []byte("fake torrent data"), + } + + ctx := context.Background() + urls := []string{"http://indexer.example.com/download/123"} + options := map[string]string{"category": "movies"} + + added, failed, err := addTorrentWithIndexer(ctx, mockSync, mockJackett, 1, urls, 42, options) + + require.Error(t, err) + assert.Equal(t, addErr, err) + assert.Equal(t, 0, added) + assert.Equal(t, 1, failed) + + // Verify download was attempted + require.Len(t, mockJackett.downloadTorrentCalls, 1) + + // Verify add was attempted + require.Len(t, mockSync.addTorrentCalls, 1) +} + +func TestAddTorrentWithIndexer_UppercaseMagnet(t *testing.T) { + t.Parallel() + + mockSync := &mockSyncManager{} + mockJackett := &mockJackettService{ + downloadTorrentData: []byte("fake torrent data"), + } + + ctx := context.Background() + // Test uppercase MAGNET: prefix + magnetURL := "MAGNET:?xt=urn:btih:1234567890abcdef1234567890abcdef12345678" + urls := []string{magnetURL} + options := map[string]string{} + + added, failed, err := addTorrentWithIndexer(ctx, mockSync, mockJackett, 1, urls, 42, options) + + require.NoError(t, err) + assert.Equal(t, 1, added) + assert.Equal(t, 0, failed) + + // Verify jackett service was NOT called for magnet links + assert.Empty(t, mockJackett.downloadTorrentCalls) + + // Verify magnet was passed directly + require.Len(t, mockSync.addTorrentFromURLsCalls, 1) +} + +func TestAddTorrentWithIndexer_EmptyURLsSkipped(t *testing.T) { + t.Parallel() + + mockSync := &mockSyncManager{} + mockJackett := &mockJackettService{ + downloadTorrentData: []byte("fake torrent data"), + } + + ctx := context.Background() + urls := []string{"", "http://indexer.example.com/download/123", ""} + options := map[string]string{} + + added, failed, err := addTorrentWithIndexer(ctx, mockSync, mockJackett, 1, urls, 42, options) + + require.NoError(t, err) + assert.Equal(t, 1, added) + assert.Equal(t, 0, failed) + + // Verify only non-empty URL was processed + require.Len(t, mockJackett.downloadTorrentCalls, 1) + assert.Equal(t, "http://indexer.example.com/download/123", mockJackett.downloadTorrentCalls[0].DownloadURL) +} + +// customMockJackettService allows per-call response configuration +type customMockJackettService struct { + calls []jackett.TorrentDownloadRequest + responses []jackettResponse + callIndex int +} + +type jackettResponse struct { + data []byte + err error +} + +func (m *customMockJackettService) DownloadTorrent(ctx context.Context, req jackett.TorrentDownloadRequest) ([]byte, error) { + m.calls = append(m.calls, req) + if m.callIndex < len(m.responses) { + resp := m.responses[m.callIndex] + m.callIndex++ + return resp.data, resp.err + } + return nil, errors.New("no more responses configured") +} + +// TestParseIndexerIDFromForm tests parsing the indexer_id form field. +// Note: Negative indexer IDs are parsed correctly but treated as "no indexer" +// by the handler (which checks indexerID > 0), so they effectively behave like 0. +func TestParseIndexerIDFromForm(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + indexerIDValue string + expectedID int + }{ + { + name: "valid positive integer", + indexerIDValue: "42", + expectedID: 42, + }, + { + name: "zero", + indexerIDValue: "0", + expectedID: 0, + }, + { + name: "empty string", + indexerIDValue: "", + expectedID: 0, + }, + { + name: "invalid string", + indexerIDValue: "not-a-number", + expectedID: 0, + }, + { + name: "negative number", + indexerIDValue: "-5", + expectedID: -5, + }, + { + name: "large number", + indexerIDValue: "999999", + expectedID: 999999, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a multipart form request + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + // Add the indexer_id field + if tt.indexerIDValue != "" { + err := writer.WriteField("indexer_id", tt.indexerIDValue) + require.NoError(t, err) + } + + // Add required urls field + err := writer.WriteField("urls", "magnet:?xt=urn:btih:test") + require.NoError(t, err) + + err = writer.Close() + require.NoError(t, err) + + // Create request + req := httptest.NewRequest(http.MethodPost, "/api/instances/1/torrents", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + + // Add chi route context + rctx := chi.NewRouteContext() + rctx.URLParams.Add("instanceID", "1") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + // Parse the form + err = req.ParseMultipartForm(10 << 20) + require.NoError(t, err) + + // Parse indexer_id like the handler does + var indexerID int + if indexerIDStr := req.FormValue("indexer_id"); indexerIDStr != "" { + parsedID, parseErr := strconv.Atoi(indexerIDStr) + if parseErr == nil { + indexerID = parsedID + } + } + + assert.Equal(t, tt.expectedID, indexerID) + }) + } +} + +// TestMultipartFormParsing_IndexerID verifies that indexer_id and related fields +// are correctly parsed from a multipart form request. +func TestMultipartFormParsing_IndexerID(t *testing.T) { + t.Parallel() + + // Create a multipart form request with indexer_id + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + err := writer.WriteField("urls", "http://indexer.example.com/download/123") + require.NoError(t, err) + + err = writer.WriteField("indexer_id", "42") + require.NoError(t, err) + + err = writer.WriteField("category", "movies") + require.NoError(t, err) + + err = writer.Close() + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "/api/instances/1/torrents", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + + // Parse the form + err = req.ParseMultipartForm(10 << 20) + require.NoError(t, err) + + // Verify the form values are parsed correctly + assert.Equal(t, "http://indexer.example.com/download/123", req.FormValue("urls")) + assert.Equal(t, "42", req.FormValue("indexer_id")) + assert.Equal(t, "movies", req.FormValue("category")) +} + +// TestAddTorrentURLProcessing tests the URL processing logic including whitespace handling +func TestAddTorrentURLProcessing(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + urlsInput string + expectedURLs []string + expectedCount int + }{ + { + name: "single URL", + urlsInput: "http://example.com/torrent.torrent", + expectedURLs: []string{"http://example.com/torrent.torrent"}, + expectedCount: 1, + }, + { + name: "newline separated URLs", + urlsInput: "http://example.com/1.torrent\nhttp://example.com/2.torrent", + expectedURLs: []string{"http://example.com/1.torrent", "http://example.com/2.torrent"}, + expectedCount: 2, + }, + { + name: "comma separated URLs", + urlsInput: "http://example.com/1.torrent,http://example.com/2.torrent", + expectedURLs: []string{"http://example.com/1.torrent", "http://example.com/2.torrent"}, + expectedCount: 2, + }, + { + name: "mixed magnet and HTTP", + urlsInput: "magnet:?xt=urn:btih:abc123\nhttp://example.com/torrent.torrent", + expectedURLs: []string{"magnet:?xt=urn:btih:abc123", "http://example.com/torrent.torrent"}, + expectedCount: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + err := writer.WriteField("urls", tt.urlsInput) + require.NoError(t, err) + err = writer.Close() + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "/api/instances/1/torrents", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + + err = req.ParseMultipartForm(10 << 20) + require.NoError(t, err) + + urlsParam := req.FormValue("urls") + require.NotEmpty(t, urlsParam) + + // Process URLs like the handler does + urlsParam = processURLSeparators(urlsParam) + urls := splitURLs(urlsParam) + + assert.Len(t, urls, tt.expectedCount) + for i, expected := range tt.expectedURLs { + if i < len(urls) { + assert.Equal(t, expected, urls[i]) + } + } + }) + } +} + +// processURLSeparators converts newlines to commas (like the handler) +func processURLSeparators(s string) string { + result := make([]byte, 0, len(s)) + for i := 0; i < len(s); i++ { + if s[i] == '\n' { + result = append(result, ',') + } else { + result = append(result, s[i]) + } + } + return string(result) +} + +// splitURLs splits on comma (like the handler) +func splitURLs(s string) []string { + var result []string + var current []byte + for i := 0; i < len(s); i++ { + if s[i] == ',' { + if len(current) > 0 { + result = append(result, string(current)) + current = current[:0] + } + } else { + current = append(current, s[i]) + } + } + if len(current) > 0 { + result = append(result, string(current)) + } + return result +} + +// BenchmarkAddTorrentWithIndexer benchmarks the core logic +func BenchmarkAddTorrentWithIndexer(b *testing.B) { + mockSync := &mockSyncManager{} + mockJackett := &mockJackettService{ + downloadTorrentData: []byte("fake torrent data"), + } + + ctx := context.Background() + urls := []string{"http://indexer.example.com/download/123"} + options := map[string]string{"category": "movies"} + + b.ResetTimer() + for b.Loop() { + mockSync.addTorrentCalls = nil + mockSync.addTorrentFromURLsCalls = nil + mockJackett.downloadTorrentCalls = nil + _, _, _ = addTorrentWithIndexer(ctx, mockSync, mockJackett, 1, urls, 42, options) + } +} + +// ============================================================================= +// HTTP Handler Integration Tests +// ============================================================================= +// These tests exercise the actual TorrentsHandler.AddTorrent method to verify +// error handling and response behavior matches the handler implementation. + +// TestAddTorrentHandler_InvalidIndexerID_Returns400 verifies that providing +// an invalid (non-integer) indexer_id returns a 400 Bad Request error. +func TestAddTorrentHandler_InvalidIndexerID_Returns400(t *testing.T) { + t.Parallel() + + // Create handler with nil dependencies - we won't reach them due to early return + handler := NewTorrentsHandler(nil, nil) + + // Create multipart form with invalid indexer_id + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + _ = writer.WriteField("urls", "http://example.com/torrent.torrent") + _ = writer.WriteField("indexer_id", "not-a-number") + _ = writer.Close() + + req := httptest.NewRequest(http.MethodPost, "/api/instances/1/torrents", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + + // Add chi route context + rctx := chi.NewRouteContext() + rctx.URLParams.Add("instanceID", "1") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + w := httptest.NewRecorder() + handler.AddTorrent(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "Invalid indexer_id") + assert.Contains(t, w.Body.String(), "not-a-number") +} + +// TestAddTorrentHandler_NegativeIndexerID_Returns400 verifies that providing +// a negative indexer_id returns a 400 Bad Request error. +func TestAddTorrentHandler_NegativeIndexerID_Returns400(t *testing.T) { + t.Parallel() + + handler := NewTorrentsHandler(nil, nil) + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + _ = writer.WriteField("urls", "http://example.com/torrent.torrent") + _ = writer.WriteField("indexer_id", "-5") + _ = writer.Close() + + req := httptest.NewRequest(http.MethodPost, "/api/instances/1/torrents", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("instanceID", "1") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + w := httptest.NewRecorder() + handler.AddTorrent(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "must be a positive integer") +} + +// TestAddTorrentHandler_ZeroIndexerID_Returns400 verifies that providing +// indexer_id=0 returns a 400 Bad Request error. +func TestAddTorrentHandler_ZeroIndexerID_Returns400(t *testing.T) { + t.Parallel() + + handler := NewTorrentsHandler(nil, nil) + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + _ = writer.WriteField("urls", "http://example.com/torrent.torrent") + _ = writer.WriteField("indexer_id", "0") + _ = writer.Close() + + req := httptest.NewRequest(http.MethodPost, "/api/instances/1/torrents", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("instanceID", "1") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + w := httptest.NewRecorder() + handler.AddTorrent(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "must be a positive integer") +} + +// TestAddTorrentHandler_JackettServiceUnavailable_Returns503 verifies that +// providing a valid indexer_id when jackett service is nil returns 503. +func TestAddTorrentHandler_JackettServiceUnavailable_Returns503(t *testing.T) { + t.Parallel() + + // Create handler with nil jackettService but valid syncManager + // We need a non-nil syncManager to get past the URL processing, + // but jackettService is nil to trigger the 503 + handler := NewTorrentsHandler(nil, nil) + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + _ = writer.WriteField("urls", "http://example.com/torrent.torrent") + _ = writer.WriteField("indexer_id", "42") + _ = writer.Close() + + req := httptest.NewRequest(http.MethodPost, "/api/instances/1/torrents", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("instanceID", "1") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + w := httptest.NewRecorder() + handler.AddTorrent(w, req) + + assert.Equal(t, http.StatusServiceUnavailable, w.Code) + assert.Contains(t, w.Body.String(), "Indexer service is not available") +} + +// TestAddTorrentHandler_NoURLsOrFiles_Returns400 verifies that providing +// neither URLs nor files returns a 400 Bad Request error. +func TestAddTorrentHandler_NoURLsOrFiles_Returns400(t *testing.T) { + t.Parallel() + + handler := NewTorrentsHandler(nil, nil) + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + // Don't add urls or torrent files + _ = writer.Close() + + req := httptest.NewRequest(http.MethodPost, "/api/instances/1/torrents", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("instanceID", "1") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + w := httptest.NewRecorder() + handler.AddTorrent(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "Either torrent files or URLs are required") +} + +// TestAddTorrentHandler_InvalidInstanceID_Returns400 verifies that providing +// an invalid instance ID in the URL returns a 400 Bad Request error. +func TestAddTorrentHandler_InvalidInstanceID_Returns400(t *testing.T) { + t.Parallel() + + handler := NewTorrentsHandler(nil, nil) + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + _ = writer.WriteField("urls", "http://example.com/torrent.torrent") + _ = writer.Close() + + req := httptest.NewRequest(http.MethodPost, "/api/instances/invalid/torrents", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("instanceID", "invalid") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + w := httptest.NewRecorder() + handler.AddTorrent(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "Invalid instance ID") +} + +// ============================================================================= +// Handler Integration Tests with Mocks (Success Paths) +// ============================================================================= + +// fullMockSyncManager implements torrentAdder interface for full handler testing +type fullMockSyncManager struct { + addTorrentCalls []addTorrentCall + addTorrentFromURLsCalls []addTorrentFromURLsCall + addTorrentErr error + addTorrentFromURLsErr error +} + +func (m *fullMockSyncManager) AddTorrent(ctx context.Context, instanceID int, fileContent []byte, options map[string]string) error { + m.addTorrentCalls = append(m.addTorrentCalls, addTorrentCall{ + instanceID: instanceID, + fileContent: fileContent, + options: options, + }) + return m.addTorrentErr +} + +func (m *fullMockSyncManager) AddTorrentFromURLs(ctx context.Context, instanceID int, urls []string, options map[string]string) error { + m.addTorrentFromURLsCalls = append(m.addTorrentFromURLsCalls, addTorrentFromURLsCall{ + instanceID: instanceID, + urls: urls, + options: options, + }) + return m.addTorrentFromURLsErr +} + +func (m *fullMockSyncManager) GetAppPreferences(ctx context.Context, instanceID int) (qbt.AppPreferences, error) { + return qbt.AppPreferences{}, nil +} + +// fullMockJackettService implements torrentDownloader interface for full handler testing +type fullMockJackettService struct { + downloadTorrentCalls []jackett.TorrentDownloadRequest + downloadTorrentData []byte + downloadTorrentErr error +} + +func (m *fullMockJackettService) DownloadTorrent(ctx context.Context, req jackett.TorrentDownloadRequest) ([]byte, error) { + m.downloadTorrentCalls = append(m.downloadTorrentCalls, req) + return m.downloadTorrentData, m.downloadTorrentErr +} + +// TestAddTorrentHandler_SuccessfulIndexerDownload_Returns201 verifies the full success path: +// 1. Valid indexer_id provided +// 2. Torrent downloaded via jackettService +// 3. Torrent added to qBittorrent via syncManager +// 4. HTTP 201 response with correct counts +func TestAddTorrentHandler_SuccessfulIndexerDownload_Returns201(t *testing.T) { + t.Parallel() + + mockSync := &fullMockSyncManager{} + mockJackett := &fullMockJackettService{ + downloadTorrentData: []byte("fake torrent data"), + } + + handler := NewTorrentsHandlerForTesting(mockSync, mockJackett) + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + _ = writer.WriteField("urls", "http://indexer.example.com/download/123") + _ = writer.WriteField("indexer_id", "42") + _ = writer.WriteField("category", "movies") + _ = writer.Close() + + req := httptest.NewRequest(http.MethodPost, "/api/instances/1/torrents", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("instanceID", "1") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + w := httptest.NewRecorder() + handler.AddTorrent(w, req) + + // Verify HTTP 201 Created response + assert.Equal(t, http.StatusCreated, w.Code) + assert.Contains(t, w.Body.String(), `"added":1`) + assert.Contains(t, w.Body.String(), `"failed":0`) + + // Verify jackettService.DownloadTorrent was called with correct parameters + require.Len(t, mockJackett.downloadTorrentCalls, 1) + assert.Equal(t, 42, mockJackett.downloadTorrentCalls[0].IndexerID) + assert.Equal(t, "http://indexer.example.com/download/123", mockJackett.downloadTorrentCalls[0].DownloadURL) + + // Verify syncManager.AddTorrent was called with downloaded bytes + require.Len(t, mockSync.addTorrentCalls, 1) + assert.Equal(t, 1, mockSync.addTorrentCalls[0].instanceID) + assert.Equal(t, []byte("fake torrent data"), mockSync.addTorrentCalls[0].fileContent) + assert.Equal(t, "movies", mockSync.addTorrentCalls[0].options["category"]) + + // Verify AddTorrentFromURLs was NOT called (since we downloaded via indexer) + assert.Empty(t, mockSync.addTorrentFromURLsCalls) +} + +// TestAddTorrentHandler_SuccessfulMagnetWithIndexer_Returns201 verifies that magnet links +// are passed directly to qBittorrent even when indexer_id is provided. +func TestAddTorrentHandler_SuccessfulMagnetWithIndexer_Returns201(t *testing.T) { + t.Parallel() + + mockSync := &fullMockSyncManager{} + mockJackett := &fullMockJackettService{ + downloadTorrentData: []byte("should not be used"), + } + + handler := NewTorrentsHandlerForTesting(mockSync, mockJackett) + + magnetURL := "magnet:?xt=urn:btih:1234567890abcdef1234567890abcdef12345678" + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + _ = writer.WriteField("urls", magnetURL) + _ = writer.WriteField("indexer_id", "42") + _ = writer.Close() + + req := httptest.NewRequest(http.MethodPost, "/api/instances/1/torrents", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("instanceID", "1") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + w := httptest.NewRecorder() + handler.AddTorrent(w, req) + + // Verify HTTP 201 Created response + assert.Equal(t, http.StatusCreated, w.Code) + assert.Contains(t, w.Body.String(), `"added":1`) + assert.Contains(t, w.Body.String(), `"failed":0`) + + // Verify jackettService was NOT called for magnet links + assert.Empty(t, mockJackett.downloadTorrentCalls) + + // Verify magnet was passed directly via AddTorrentFromURLs + require.Len(t, mockSync.addTorrentFromURLsCalls, 1) + assert.Equal(t, []string{magnetURL}, mockSync.addTorrentFromURLsCalls[0].urls) + + // Verify AddTorrent (file method) was NOT called + assert.Empty(t, mockSync.addTorrentCalls) +} + +// TestAddTorrentHandler_MixedURLsAndMagnets_Returns201 verifies handling of mixed +// HTTP URLs (downloaded via indexer) and magnet links (passed directly). +func TestAddTorrentHandler_MixedURLsAndMagnets_Returns201(t *testing.T) { + t.Parallel() + + mockSync := &fullMockSyncManager{} + mockJackett := &fullMockJackettService{ + downloadTorrentData: []byte("downloaded torrent data"), + } + + handler := NewTorrentsHandlerForTesting(mockSync, mockJackett) + + magnetURL := "magnet:?xt=urn:btih:1234567890abcdef1234567890abcdef12345678" + httpURL := "http://indexer.example.com/download/456" + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + _ = writer.WriteField("urls", magnetURL+"\n"+httpURL) + _ = writer.WriteField("indexer_id", "99") + _ = writer.Close() + + req := httptest.NewRequest(http.MethodPost, "/api/instances/2/torrents", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("instanceID", "2") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + w := httptest.NewRecorder() + handler.AddTorrent(w, req) + + // Verify HTTP 201 Created response + assert.Equal(t, http.StatusCreated, w.Code) + assert.Contains(t, w.Body.String(), `"added":2`) + assert.Contains(t, w.Body.String(), `"failed":0`) + + // Verify HTTP URL was downloaded via jackettService + require.Len(t, mockJackett.downloadTorrentCalls, 1) + assert.Equal(t, 99, mockJackett.downloadTorrentCalls[0].IndexerID) + assert.Equal(t, httpURL, mockJackett.downloadTorrentCalls[0].DownloadURL) + + // Verify magnet was passed directly + require.Len(t, mockSync.addTorrentFromURLsCalls, 1) + assert.Equal(t, []string{magnetURL}, mockSync.addTorrentFromURLsCalls[0].urls) + + // Verify downloaded torrent was added via AddTorrent + require.Len(t, mockSync.addTorrentCalls, 1) + assert.Equal(t, []byte("downloaded torrent data"), mockSync.addTorrentCalls[0].fileContent) +} + +// TestAddTorrentHandler_PartialFailure_Returns201WithFailedURLs verifies that +// partial failures return 201 with accurate counts and failedURLs details. +func TestAddTorrentHandler_PartialFailure_Returns201WithFailedURLs(t *testing.T) { + t.Parallel() + + mockSync := &fullMockSyncManager{} + // Use custom mock that fails on first download, succeeds on second + mockJackett := &customMockJackettServiceForHandler{ + responses: []jackettResponse{ + {err: errors.New("indexer unavailable")}, + {data: []byte("success torrent data")}, + }, + } + + handler := NewTorrentsHandlerForTesting(mockSync, mockJackett) + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + _ = writer.WriteField("urls", "http://fail.example.com/1\nhttp://success.example.com/2") + _ = writer.WriteField("indexer_id", "1") + _ = writer.Close() + + req := httptest.NewRequest(http.MethodPost, "/api/instances/1/torrents", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("instanceID", "1") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + w := httptest.NewRecorder() + handler.AddTorrent(w, req) + + // Verify HTTP 201 Created (partial success) + assert.Equal(t, http.StatusCreated, w.Code) + assert.Contains(t, w.Body.String(), `"added":1`) + assert.Contains(t, w.Body.String(), `"failed":1`) + assert.Contains(t, w.Body.String(), `"failedURLs"`) + assert.Contains(t, w.Body.String(), "http://fail.example.com/1") + assert.Contains(t, w.Body.String(), "indexer unavailable") + + // Verify both URLs were attempted + assert.Len(t, mockJackett.calls, 2) + + // Verify only successful torrent was added + require.Len(t, mockSync.addTorrentCalls, 1) + assert.Equal(t, []byte("success torrent data"), mockSync.addTorrentCalls[0].fileContent) +} + +// customMockJackettServiceForHandler is similar to customMockJackettService but +// implements the torrentDownloader interface +type customMockJackettServiceForHandler struct { + calls []jackett.TorrentDownloadRequest + responses []jackettResponse + callIndex int +} + +func (m *customMockJackettServiceForHandler) DownloadTorrent(ctx context.Context, req jackett.TorrentDownloadRequest) ([]byte, error) { + m.calls = append(m.calls, req) + if m.callIndex < len(m.responses) { + resp := m.responses[m.callIndex] + m.callIndex++ + return resp.data, resp.err + } + return nil, errors.New("no more responses configured") +} + +// TestAddTorrentHandler_NoIndexerID_UsesDirectURL verifies that when no indexer_id +// is provided, URLs are passed directly to qBittorrent. +func TestAddTorrentHandler_NoIndexerID_UsesDirectURL(t *testing.T) { + t.Parallel() + + mockSync := &fullMockSyncManager{} + mockJackett := &fullMockJackettService{ + downloadTorrentData: []byte("should not be used"), + } + + handler := NewTorrentsHandlerForTesting(mockSync, mockJackett) + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + _ = writer.WriteField("urls", "http://example.com/torrent.torrent") + // No indexer_id field + _ = writer.Close() + + req := httptest.NewRequest(http.MethodPost, "/api/instances/1/torrents", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("instanceID", "1") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + w := httptest.NewRecorder() + handler.AddTorrent(w, req) + + // Verify HTTP 201 Created response + assert.Equal(t, http.StatusCreated, w.Code) + assert.Contains(t, w.Body.String(), `"added":1`) + + // Verify jackettService was NOT called + assert.Empty(t, mockJackett.downloadTorrentCalls) + + // Verify URL was passed directly via AddTorrentFromURLs + require.Len(t, mockSync.addTorrentFromURLsCalls, 1) + assert.Equal(t, []string{"http://example.com/torrent.torrent"}, mockSync.addTorrentFromURLsCalls[0].urls) + + // Verify AddTorrent (file method) was NOT called + assert.Empty(t, mockSync.addTorrentCalls) +} diff --git a/internal/api/server.go b/internal/api/server.go index 57ba10ec5..4e5003366 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -249,7 +249,7 @@ func (s *Server) Handler() (*chi.Mux, error) { return nil, err } instancesHandler := handlers.NewInstancesHandler(s.instanceStore, s.instanceReannounce, s.reannounceCache, s.clientPool, s.syncManager, s.reannounceService) - torrentsHandler := handlers.NewTorrentsHandler(s.syncManager) + torrentsHandler := handlers.NewTorrentsHandler(s.syncManager, s.jackettService) preferencesHandler := handlers.NewPreferencesHandler(s.syncManager) clientAPIKeysHandler := handlers.NewClientAPIKeysHandler(s.clientAPIKeyStore, s.instanceStore, s.config.Config.BaseURL) externalProgramsHandler := handlers.NewExternalProgramsHandler(s.externalProgramStore, s.clientPool, s.config.Config) diff --git a/web/src/components/torrents/AddTorrentDialog.tsx b/web/src/components/torrents/AddTorrentDialog.tsx index 870ecfaf9..bec45a57d 100644 --- a/web/src/components/torrents/AddTorrentDialog.tsx +++ b/web/src/components/torrents/AddTorrentDialog.tsx @@ -39,7 +39,8 @@ import { useInstanceMetadata } from "@/hooks/useInstanceMetadata" import { usePersistedStartPaused } from "@/hooks/usePersistedStartPaused" import { api } from "@/lib/api" import { cn } from '@/lib/utils' -import type { Torrent } from "@/types" +import type { AddTorrentResponse, Torrent } from "@/types" +import { toast } from "sonner" import { useForm } from "@tanstack/react-form" import { useMutation, useQueryClient } from "@tanstack/react-query" import { AlertCircle, Link, Loader2, Plus, Upload, X } from "lucide-react" @@ -101,7 +102,7 @@ async function parseTorrentFile(file: File): Promise { export type AddTorrentDropPayload = | { type: "file"; files: File[] } - | { type: "url"; urls: string[] } + | { type: "url"; urls: string[]; indexerId?: number } interface AddTorrentDialogProps { instanceId: number @@ -133,6 +134,7 @@ interface FormData { rename: string tempPathEnabled: boolean tempPath: string + indexerId?: number } interface DuplicateEntryDetails { @@ -524,11 +526,14 @@ export function AddTorrentDialog({ instanceId, open: controlledOpen, onOpenChang submitData.torrentFiles = data.torrentFiles } else if (activeTab === "url" && data.urls) { submitData.urls = data.urls.split("\n").map(u => u.trim()).filter(Boolean) + if (data.indexerId) { + submitData.indexerId = data.indexerId + } } return api.addTorrent(instanceId, submitData) }, - onSuccess: () => { + onSuccess: (response: AddTorrentResponse) => { // Add small delay to allow qBittorrent to process the new torrent setTimeout(() => { // Use refetch instead of invalidate to avoid loading state @@ -544,6 +549,34 @@ export function AddTorrentDialog({ instanceId, open: controlledOpen, onOpenChang type: "active", }) }, 500) // Give qBittorrent time to process + + // Show appropriate toast based on results + if (response.failed === 0) { + toast.success(response.added === 1 + ? "Torrent added successfully" + : `${response.added} torrents added successfully`) + } else if (response.added === 0) { + // All failed + const failedDetails = [ + ...(response.failedURLs?.map(f => `${f.url}: ${f.error}`) ?? []), + ...(response.failedFiles?.map(f => `${f.filename}: ${f.error}`) ?? []) + ] + toast.error(`Failed to add ${response.failed} torrent(s)`, { + description: failedDetails.length > 0 ? failedDetails.slice(0, 3).join("\n") : undefined, + duration: 5000, + }) + } else { + // Partial success + const failedDetails = [ + ...(response.failedURLs?.map(f => `${f.url}: ${f.error}`) ?? []), + ...(response.failedFiles?.map(f => `${f.filename}: ${f.error}`) ?? []) + ] + toast.warning(`Added ${response.added}, failed ${response.failed}`, { + description: failedDetails.length > 0 ? failedDetails.slice(0, 3).join("\n") : undefined, + duration: 5000, + }) + } + setOpen(false) form.reset() setSelectedTags([]) @@ -572,6 +605,7 @@ export function AddTorrentDialog({ instanceId, open: controlledOpen, onOpenChang rename: "", tempPathEnabled: preferences?.temp_path_enabled ?? false, tempPath: preferences?.temp_path || "", + indexerId: undefined as number | undefined, }, onSubmit: async ({ value }) => { // Use the currently selected tags @@ -682,6 +716,7 @@ export function AddTorrentDialog({ instanceId, open: controlledOpen, onOpenChang setShowFileList(false) form.setFieldValue("urls", urls.join("\n")) form.setFieldValue("torrentFiles", null) + form.setFieldValue("indexerId", dropPayload.indexerId) if (fileInputRef.current) { fileInputRef.current.value = "" } diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 13b30e35a..5a6bee273 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -4,6 +4,7 @@ */ import type { + AddTorrentResponse, AppPreferences, AsyncIndexerFilteringState, AuthResponse, @@ -12,26 +13,27 @@ import type { BackupRunsResponse, BackupSettings, Category, + CrossInstanceTorrent, CrossSeedApplyResponse, CrossSeedAutomationSettings, CrossSeedAutomationSettingsPatch, CrossSeedAutomationStatus, - CrossInstanceTorrent, CrossSeedInstanceResult, CrossSeedRun, - CrossSeedTorrentInfo, - CrossSeedTorrentSearchResponse, - CrossSeedTorrentSearchSelection, + CrossSeedSearchRun, CrossSeedSearchSettings, CrossSeedSearchSettingsPatch, - CrossSeedSearchRun, CrossSeedSearchStatus, + CrossSeedTorrentInfo, + CrossSeedTorrentSearchResponse, + CrossSeedTorrentSearchSelection, DuplicateTorrentMatch, ExternalProgram, ExternalProgramCreate, ExternalProgramExecute, ExternalProgramExecuteResponse, ExternalProgramUpdate, + IndexerActivityStatus, InstanceCapabilities, InstanceFormData, InstanceReannounceActivity, @@ -52,20 +54,19 @@ import type { TorrentProperties, TorrentResponse, TorrentTracker, - TrackerRule, - TrackerRuleInput, TorznabIndexer, TorznabIndexerError, TorznabIndexerFormData, TorznabIndexerHealth, TorznabIndexerLatencyStats, - IndexerActivityStatus, - TorznabSearchRequest, - TorznabSearchResult, - TorznabSearchResponse, + TorznabRecentSearch, TorznabSearchCacheMetadata, TorznabSearchCacheStats, - TorznabRecentSearch, + TorznabSearchRequest, + TorznabSearchResponse, + TorznabSearchResult, + TrackerRule, + TrackerRuleInput, User } from "@/types" import { getApiBaseUrl, withBasePath } from "./base-url" @@ -565,8 +566,9 @@ class ApiClient { limitSeedTime?: number contentLayout?: string rename?: string + indexerId?: number } - ): Promise<{ success: boolean; message?: string }> { + ): Promise { const formData = new FormData() // Append each file with the same field name "torrent" if (data.torrentFiles) { @@ -590,6 +592,7 @@ class ApiClient { if (data.savePath && !data.autoTMM) formData.append("savepath", data.savePath) if (data.useDownloadPath !== undefined) formData.append("useDownloadPath", data.useDownloadPath.toString()) if (data.downloadPath) formData.append("downloadPath", data.downloadPath) + if (data.indexerId) formData.append("indexer_id", data.indexerId.toString()) const response = await fetch(`${API_BASE}/instances/${instanceId}/torrents`, { method: "POST", diff --git a/web/src/pages/Search.tsx b/web/src/pages/Search.tsx index 0dfc96ae8..9a54f4a37 100644 --- a/web/src/pages/Search.tsx +++ b/web/src/pages/Search.tsx @@ -746,7 +746,7 @@ export function Search() { } persistSelectedInstanceId(targetId) - setAddDialogPayload({ type: 'url', urls: [result.downloadUrl] }) + setAddDialogPayload({ type: 'url', urls: [result.downloadUrl], indexerId: result.indexerId }) setAddDialogOpen(true) }, [hasInstances, persistSelectedInstanceId, selectedInstanceId, setInstanceMenuOpen]) diff --git a/web/src/types/index.ts b/web/src/types/index.ts index 1f45d6ead..3527d5ffc 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -339,6 +339,24 @@ export interface TorrentResponse { isCrossInstance?: boolean } +export interface AddTorrentFailedURL { + url: string + error: string +} + +export interface AddTorrentFailedFile { + filename: string + error: string +} + +export interface AddTorrentResponse { + message: string + added: number + failed: number + failedURLs?: AddTorrentFailedURL[] + failedFiles?: AddTorrentFailedFile[] +} + export interface CrossInstanceTorrent extends Torrent { instanceId: number instanceName: string