From 8da62038a7104f76ec3563322da0f42775abbe9f Mon Sep 17 00:00:00 2001 From: soup Date: Mon, 8 Dec 2025 20:34:42 +0100 Subject: [PATCH 1/6] fix(rss): skip download when torrent already exists by infohash When RSS automation finds candidates, check if the feed item's infohash already exists on all candidate instances before downloading the torrent file. This prevents unnecessary downloads (and Prowlarr "Grabbed" notifications) when a torrent was already added via another source like autobrr/IRC. Also adds fallback extraction of infohash from magneturl attribute. Indexers providing infohash via Prowlarr: - Generic Torznab (infohash attribute) - HDBits, BeyondHD, Avistaz/PrivateHD/ExoticaZ, BroadcastheNet - Knaben, TorrentsCSV - Cardigann (per-definition, with magnet fallback) - TorrentRssParser-based indexers (from magnet URL if present) Not supported (no infohash in feed): - PassThePopcorn, UNIT3D-based trackers - Anidex, Animedia, SubsPlease, Shizaproject --- internal/services/crossseed/service.go | 50 ++++++++ internal/services/jackett/service.go | 59 ++++++++- internal/services/jackett/service_test.go | 146 ++++++++++++++++++++++ 3 files changed, 249 insertions(+), 6 deletions(-) diff --git a/internal/services/crossseed/service.go b/internal/services/crossseed/service.go index 3febfdba1..4f379b59d 100644 --- a/internal/services/crossseed/service.go +++ b/internal/services/crossseed/service.go @@ -1634,6 +1634,56 @@ func (s *Service) processAutomationCandidate(ctx context.Context, run *models.Cr return models.CrossSeedFeedItemStatusSkipped, nil, nil } + // Optimization: If RSS feed provides infohash, check if torrent already exists + // on ALL candidate instances before downloading. This avoids unnecessary downloads + // when the exact torrent (by hash) is already present - commonly happens when + // autobrr grabs via IRC before RSS automation processes the same release. + if result.InfoHashV1 != "" { + allExist := true + var existingResults []models.CrossSeedRunResult + + for _, candidate := range candidatesResp.Candidates { + existing, exists, hashErr := s.syncManager.HasTorrentByAnyHash(ctx, candidate.InstanceID, []string{result.InfoHashV1}) + if hashErr != nil { + log.Debug(). + Err(hashErr). + Int("instanceID", candidate.InstanceID). + Str("hash", result.InfoHashV1). + Msg("[RSS] Failed to check existing hash, will proceed with download") + allExist = false + break + } + + if exists && existing != nil { + existingResults = append(existingResults, models.CrossSeedRunResult{ + InstanceID: candidate.InstanceID, + InstanceName: candidate.InstanceName, + Success: false, + Status: "exists", + Message: "Torrent already exists (infohash pre-check)", + MatchedTorrentHash: func() *string { h := existing.Hash; return &h }(), + MatchedTorrentName: func() *string { n := existing.Name; return &n }(), + }) + } else { + allExist = false + break + } + } + + if allExist && len(existingResults) > 0 { + run.TorrentsSkipped += len(existingResults) + run.Results = append(run.Results, existingResults...) + + log.Debug(). + Str("title", result.Title). + Str("hash", result.InfoHashV1). + Int("instances", len(existingResults)). + Msg("[RSS] Skipped download - torrent already exists on all candidate instances (infohash pre-check)") + + return models.CrossSeedFeedItemStatusProcessed, &result.InfoHashV1, nil + } + } + torrentBytes, err := s.downloadTorrent(ctx, jackett.TorrentDownloadRequest{ IndexerID: result.IndexerID, DownloadURL: result.DownloadURL, diff --git a/internal/services/jackett/service.go b/internal/services/jackett/service.go index cea1d9495..2a150ab21 100644 --- a/internal/services/jackett/service.go +++ b/internal/services/jackett/service.go @@ -3042,15 +3042,62 @@ func extractInfoHashFromAttributes(attrs map[string]string) string { return "" } + // First try direct infohash attributes for _, key := range infohashAttributeKeys { if value, ok := attrs[key]; ok { - // Validate that it's a valid hex string (40 chars for SHA1, 64 for SHA256) - value = strings.TrimSpace(strings.ToLower(value)) - if len(value) == 40 || len(value) == 64 { - if _, err := hex.DecodeString(value); err == nil { - return value - } + if hash := validateInfoHash(value); hash != "" { + return hash + } + } + } + + // Fallback: extract from magneturl attribute if present + // Prowlarr provides magneturl when direct infohash isn't available + if magnetURL, ok := attrs["magneturl"]; ok { + if hash := extractInfoHashFromMagnet(magnetURL); hash != "" { + return hash + } + } + + return "" +} + +// validateInfoHash checks if the value is a valid hex infohash (40 chars for SHA1, 64 for SHA256) +func validateInfoHash(value string) string { + value = strings.TrimSpace(strings.ToLower(value)) + if len(value) == 40 || len(value) == 64 { + if _, err := hex.DecodeString(value); err == nil { + return value + } + } + return "" +} + +// extractInfoHashFromMagnet extracts the infohash from a magnet URL +// Format: magnet:?xt=urn:btih:&... +func extractInfoHashFromMagnet(magnetURL string) string { + magnetURL = strings.TrimSpace(magnetURL) + if magnetURL == "" || !strings.HasPrefix(strings.ToLower(magnetURL), "magnet:") { + return "" + } + + // Parse the magnet URL to extract the xt parameter + // The xt parameter format is: urn:btih: + parts := strings.Split(magnetURL, "?") + if len(parts) < 2 { + return "" + } + + params := strings.Split(parts[1], "&") + for _, param := range params { + if strings.HasPrefix(strings.ToLower(param), "xt=urn:btih:") { + // Extract the hash part after "xt=urn:btih:" + hashPart := param[12:] // len("xt=urn:btih:") == 12 + // Hash might be followed by other parameters or URL encoding + if idx := strings.Index(hashPart, "&"); idx > 0 { + hashPart = hashPart[:idx] } + return validateInfoHash(hashPart) } } diff --git a/internal/services/jackett/service_test.go b/internal/services/jackett/service_test.go index e1d1841bd..647eb8722 100644 --- a/internal/services/jackett/service_test.go +++ b/internal/services/jackett/service_test.go @@ -2004,3 +2004,149 @@ func TestParseTorznabCaps_ProwlarrCompatibility(t *testing.T) { } } } + +func TestExtractInfoHashFromAttributes(t *testing.T) { + tests := []struct { + name string + attrs map[string]string + expected string + }{ + { + name: "empty attributes", + attrs: nil, + expected: "", + }, + { + name: "direct infohash attribute", + attrs: map[string]string{"infohash": "63e07ff523710ca268567dad344ce1e0e6b7e8a3"}, + expected: "63e07ff523710ca268567dad344ce1e0e6b7e8a3", + }, + { + name: "infohash with uppercase", + attrs: map[string]string{"infohash": "63E07FF523710CA268567DAD344CE1E0E6B7E8A3"}, + expected: "63e07ff523710ca268567dad344ce1e0e6b7e8a3", + }, + { + name: "info_hash variant", + attrs: map[string]string{"info_hash": "63e07ff523710ca268567dad344ce1e0e6b7e8a3"}, + expected: "63e07ff523710ca268567dad344ce1e0e6b7e8a3", + }, + { + name: "hash variant", + attrs: map[string]string{"hash": "63e07ff523710ca268567dad344ce1e0e6b7e8a3"}, + expected: "63e07ff523710ca268567dad344ce1e0e6b7e8a3", + }, + { + name: "SHA256 v2 hash", + attrs: map[string]string{"infohash": "63e07ff523710ca268567dad344ce1e0e6b7e8a363e07ff523710ca268567dad"}, + expected: "63e07ff523710ca268567dad344ce1e0e6b7e8a363e07ff523710ca268567dad", + }, + { + name: "invalid hash length", + attrs: map[string]string{"infohash": "63e07ff523710ca268"}, + expected: "", + }, + { + name: "invalid hex characters", + attrs: map[string]string{"infohash": "63e07ff523710ca268567dad344ce1e0e6b7ZZZZ"}, + expected: "", + }, + { + name: "magnet URL fallback", + attrs: map[string]string{"magneturl": "magnet:?xt=urn:btih:63e07ff523710ca268567dad344ce1e0e6b7e8a3&dn=Test"}, + expected: "63e07ff523710ca268567dad344ce1e0e6b7e8a3", + }, + { + name: "magnet URL with uppercase hash", + attrs: map[string]string{"magneturl": "magnet:?xt=urn:btih:63E07FF523710CA268567DAD344CE1E0E6B7E8A3&dn=Test"}, + expected: "63e07ff523710ca268567dad344ce1e0e6b7e8a3", + }, + { + name: "magnet URL without other params", + attrs: map[string]string{"magneturl": "magnet:?xt=urn:btih:63e07ff523710ca268567dad344ce1e0e6b7e8a3"}, + expected: "63e07ff523710ca268567dad344ce1e0e6b7e8a3", + }, + { + name: "infohash takes priority over magneturl", + attrs: map[string]string{"infohash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "magneturl": "magnet:?xt=urn:btih:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}, + expected: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }, + { + name: "invalid magnet URL", + attrs: map[string]string{"magneturl": "not-a-magnet-url"}, + expected: "", + }, + { + name: "magnet URL missing hash", + attrs: map[string]string{"magneturl": "magnet:?dn=Test&tr=http://tracker"}, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := extractInfoHashFromAttributes(tt.attrs) + if result != tt.expected { + t.Errorf("extractInfoHashFromAttributes() = %q, want %q", result, tt.expected) + } + }) + } +} + +func TestExtractInfoHashFromMagnet(t *testing.T) { + tests := []struct { + name string + magnet string + expected string + }{ + { + name: "empty string", + magnet: "", + expected: "", + }, + { + name: "valid magnet with hash first", + magnet: "magnet:?xt=urn:btih:63e07ff523710ca268567dad344ce1e0e6b7e8a3&dn=Test&tr=http://tracker", + expected: "63e07ff523710ca268567dad344ce1e0e6b7e8a3", + }, + { + name: "valid magnet with hash last", + magnet: "magnet:?dn=Test&tr=http://tracker&xt=urn:btih:63e07ff523710ca268567dad344ce1e0e6b7e8a3", + expected: "63e07ff523710ca268567dad344ce1e0e6b7e8a3", + }, + { + name: "magnet with only hash", + magnet: "magnet:?xt=urn:btih:63e07ff523710ca268567dad344ce1e0e6b7e8a3", + expected: "63e07ff523710ca268567dad344ce1e0e6b7e8a3", + }, + { + name: "uppercase MAGNET prefix", + magnet: "MAGNET:?xt=urn:btih:63e07ff523710ca268567dad344ce1e0e6b7e8a3", + expected: "63e07ff523710ca268567dad344ce1e0e6b7e8a3", + }, + { + name: "not a magnet URL", + magnet: "http://example.com/torrent.torrent", + expected: "", + }, + { + name: "magnet without query string", + magnet: "magnet:", + expected: "", + }, + { + name: "magnet with invalid hash", + magnet: "magnet:?xt=urn:btih:tooshort", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := extractInfoHashFromMagnet(tt.magnet) + if result != tt.expected { + t.Errorf("extractInfoHashFromMagnet() = %q, want %q", result, tt.expected) + } + }) + } +} From 8fa696d8dbd00f59bea6630e480ecef06a0f9801 Mon Sep 17 00:00:00 2001 From: soup Date: Mon, 8 Dec 2025 21:17:37 +0100 Subject: [PATCH 2/6] feat(crossseed): add comment URL fallback for UNIT3D pre-check For UNIT3D trackers that don't expose infohash in RSS feeds, check if the RSS GUID/InfoURL matches torrent comment fields on candidate instances before downloading. --- internal/services/crossseed/crossseed_test.go | 71 ++++++++++++++ internal/services/crossseed/service.go | 95 +++++++++++++++++++ 2 files changed, 166 insertions(+) diff --git a/internal/services/crossseed/crossseed_test.go b/internal/services/crossseed/crossseed_test.go index 77f489ade..f8cf4a320 100644 --- a/internal/services/crossseed/crossseed_test.go +++ b/internal/services/crossseed/crossseed_test.go @@ -3279,3 +3279,74 @@ func TestRecoverErroredTorrents_EmptyList(t *testing.T) { // Should not have made any calls assert.Empty(t, mockSync.calls) } + +func TestExtractTorrentURLForCommentMatch(t *testing.T) { + tests := []struct { + name string + guid string + infoURL string + expected string + }{ + { + name: "UNIT3D style URL in GUID", + guid: "https://seedpool.org/torrents/607803", + infoURL: "", + expected: "https://seedpool.org/torrents/607803", + }, + { + name: "BHD style URL in GUID", + guid: "https://beyond-hd.me/details/500790", + infoURL: "", + expected: "https://beyond-hd.me/details/500790", + }, + { + name: "Aither URL", + guid: "https://aither.cc/torrents/318093", + infoURL: "", + expected: "https://aither.cc/torrents/318093", + }, + { + name: "Blutopia URL", + guid: "https://blutopia.cc/torrents/294836", + infoURL: "", + expected: "https://blutopia.cc/torrents/294836", + }, + { + name: "Falls back to InfoURL when GUID empty", + guid: "", + infoURL: "https://seedpool.org/torrents/123456", + expected: "https://seedpool.org/torrents/123456", + }, + { + name: "Falls back to InfoURL when GUID not a torrent URL", + guid: "some-random-guid-12345", + infoURL: "https://seedpool.org/torrents/123456", + expected: "https://seedpool.org/torrents/123456", + }, + { + name: "HTTP URL rejected", + guid: "http://seedpool.org/torrents/607803", + infoURL: "", + expected: "", + }, + { + name: "Non-torrent URL rejected", + guid: "https://example.com/page/123", + infoURL: "", + expected: "", + }, + { + name: "Empty inputs", + guid: "", + infoURL: "", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := extractTorrentURLForCommentMatch(tt.guid, tt.infoURL) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/internal/services/crossseed/service.go b/internal/services/crossseed/service.go index 4f379b59d..22a18c675 100644 --- a/internal/services/crossseed/service.go +++ b/internal/services/crossseed/service.go @@ -1684,6 +1684,56 @@ func (s *Service) processAutomationCandidate(ctx context.Context, run *models.Cr } } + // Fallback for UNIT3D and similar trackers: check if torrent URL exists in comments. + // UNIT3D torrents include the source URL in the comment field (e.g., "https://seedpool.org/torrents/607803"). + // The GUID from RSS is typically this same URL, so we can match without needing infohash. + if commentURL := extractTorrentURLForCommentMatch(result.GUID, result.InfoURL); commentURL != "" { + allExist := true + var existingResults []models.CrossSeedRunResult + + for _, candidate := range candidatesResp.Candidates { + found := false + var matchedTorrent *qbt.Torrent + + for i := range candidate.Torrents { + t := &candidate.Torrents[i] + if t.Comment != "" && strings.Contains(t.Comment, commentURL) { + found = true + matchedTorrent = t + break + } + } + + if found && matchedTorrent != nil { + existingResults = append(existingResults, models.CrossSeedRunResult{ + InstanceID: candidate.InstanceID, + InstanceName: candidate.InstanceName, + Success: false, + Status: "exists", + Message: "Torrent already exists (comment URL pre-check)", + MatchedTorrentHash: func() *string { h := matchedTorrent.Hash; return &h }(), + MatchedTorrentName: func() *string { n := matchedTorrent.Name; return &n }(), + }) + } else { + allExist = false + break + } + } + + if allExist && len(existingResults) > 0 { + run.TorrentsSkipped += len(existingResults) + run.Results = append(run.Results, existingResults...) + + log.Debug(). + Str("title", result.Title). + Str("commentURL", commentURL). + Int("instances", len(existingResults)). + Msg("[RSS] Skipped download - torrent already exists on all candidate instances (comment URL pre-check)") + + return models.CrossSeedFeedItemStatusProcessed, nil, nil + } + } + torrentBytes, err := s.downloadTorrent(ctx, jackett.TorrentDownloadRequest{ IndexerID: result.IndexerID, DownloadURL: result.DownloadURL, @@ -7372,3 +7422,48 @@ func wrapCrossSeedSearchError(err error) error { return fmt.Errorf("torznab search failed: %w", err) } + +// extractTorrentURLForCommentMatch extracts a torrent URL suitable for matching against +// torrent comments. UNIT3D and similar trackers embed the source URL in the torrent's +// comment field. Returns empty string if no suitable URL pattern is found. +// +// Supported patterns: +// - https://seedpool.org/torrents/607803 +// - https://beyond-hd.me/details/500790 +// - https://aither.cc/torrents/318093 +// - https://blutopia.cc/torrents/294836 +func extractTorrentURLForCommentMatch(guid, infoURL string) string { + // Try GUID first (typically the details URL for UNIT3D) + if url := parseTorrentDetailsURL(guid); url != "" { + return url + } + + // Fall back to InfoURL + if url := parseTorrentDetailsURL(infoURL); url != "" { + return url + } + + return "" +} + +// parseTorrentDetailsURL checks if the URL looks like a tracker torrent details page +// and returns it normalized for comment matching. +func parseTorrentDetailsURL(rawURL string) string { + if rawURL == "" { + return "" + } + + // Must be HTTPS URL + if !strings.HasPrefix(rawURL, "https://") { + return "" + } + + // Check for common torrent details URL patterns: + // - /torrents/ID (UNIT3D style) + // - /details/ID (BHD style) + if strings.Contains(rawURL, "/torrents/") || strings.Contains(rawURL, "/details/") { + return rawURL + } + + return "" +} From 6c9f85fbda0ebaab9b5cf59dc6ecea53a5298456 Mon Sep 17 00:00:00 2001 From: soup Date: Tue, 9 Dec 2025 15:20:34 +0100 Subject: [PATCH 3/6] fix(rss): add tests and improve error handling for infohash skip logic --- internal/services/crossseed/crossseed_test.go | 497 ++++++++++++++++++ internal/services/crossseed/service.go | 14 +- internal/services/jackett/service.go | 8 +- 3 files changed, 510 insertions(+), 9 deletions(-) diff --git a/internal/services/crossseed/crossseed_test.go b/internal/services/crossseed/crossseed_test.go index f8cf4a320..4234c60b2 100644 --- a/internal/services/crossseed/crossseed_test.go +++ b/internal/services/crossseed/crossseed_test.go @@ -22,6 +22,7 @@ import ( "github.com/autobrr/qui/internal/models" internalqb "github.com/autobrr/qui/internal/qbittorrent" + "github.com/autobrr/qui/internal/services/jackett" "github.com/autobrr/qui/pkg/releases" "github.com/autobrr/qui/pkg/stringutils" ) @@ -3350,3 +3351,499 @@ func TestExtractTorrentURLForCommentMatch(t *testing.T) { }) } } + +// infohashTestSyncManager extends episodeSyncManager with configurable HasTorrentByAnyHash behavior +type infohashTestSyncManager struct { + torrents map[int][]qbt.Torrent + files map[int]map[string]qbt.TorrentFiles + props map[int]map[string]*qbt.TorrentProperties + hashResults map[int]*hashCheckResult // instanceID -> result +} + +type hashCheckResult struct { + torrent *qbt.Torrent + exists bool + err error +} + +func newInfohashTestSyncManager() *infohashTestSyncManager { + return &infohashTestSyncManager{ + torrents: make(map[int][]qbt.Torrent), + files: make(map[int]map[string]qbt.TorrentFiles), + props: make(map[int]map[string]*qbt.TorrentProperties), + hashResults: make(map[int]*hashCheckResult), + } +} + +func (f *infohashTestSyncManager) GetTorrents(_ context.Context, instanceID int, _ qbt.TorrentFilterOptions) ([]qbt.Torrent, error) { + list := f.torrents[instanceID] + if list == nil { + return nil, fmt.Errorf("instance %d has no torrents", instanceID) + } + copied := make([]qbt.Torrent, len(list)) + copy(copied, list) + return copied, nil +} + +func (f *infohashTestSyncManager) GetTorrentFilesBatch(_ context.Context, instanceID int, hashes []string) (map[string]qbt.TorrentFiles, error) { + result := make(map[string]qbt.TorrentFiles, len(hashes)) + if instFiles, ok := f.files[instanceID]; ok { + for _, h := range hashes { + if files, ok := instFiles[strings.ToLower(h)]; ok { + cp := make(qbt.TorrentFiles, len(files)) + copy(cp, files) + result[normalizeHash(h)] = cp + } + } + } + return result, nil +} + +func (f *infohashTestSyncManager) HasTorrentByAnyHash(_ context.Context, instanceID int, _ []string) (*qbt.Torrent, bool, error) { + if result, ok := f.hashResults[instanceID]; ok { + return result.torrent, result.exists, result.err + } + return nil, false, nil +} + +func (f *infohashTestSyncManager) GetTorrentProperties(_ context.Context, instanceID int, hash string) (*qbt.TorrentProperties, error) { + if instProps, ok := f.props[instanceID]; ok { + if props, ok := instProps[strings.ToLower(hash)]; ok { + cp := *props + return &cp, nil + } + } + return &qbt.TorrentProperties{SavePath: "/downloads"}, nil +} + +func (f *infohashTestSyncManager) GetAppPreferences(context.Context, int) (qbt.AppPreferences, error) { + return qbt.AppPreferences{TorrentContentLayout: "Original"}, nil +} + +func (f *infohashTestSyncManager) AddTorrent(context.Context, int, []byte, map[string]string) error { + return nil +} + +func (f *infohashTestSyncManager) BulkAction(context.Context, int, []string, string) error { + return nil +} + +func (f *infohashTestSyncManager) SetTags(context.Context, int, []string, string) error { + return nil +} + +func (f *infohashTestSyncManager) GetCachedInstanceTorrents(_ context.Context, instanceID int) ([]internalqb.CrossInstanceTorrentView, error) { + // Build views from torrents + if list, ok := f.torrents[instanceID]; ok { + views := make([]internalqb.CrossInstanceTorrentView, len(list)) + for i, t := range list { + views[i] = internalqb.CrossInstanceTorrentView{ + TorrentView: internalqb.TorrentView{ + Torrent: t, + }, + InstanceID: instanceID, + } + } + return views, nil + } + return nil, nil +} + +func (f *infohashTestSyncManager) ExtractDomainFromURL(string) string { + return "" +} + +func (f *infohashTestSyncManager) GetQBittorrentSyncManager(context.Context, int) (*qbt.SyncManager, error) { + return nil, nil +} + +func (f *infohashTestSyncManager) RenameTorrent(context.Context, int, string, string) error { + return nil +} + +func (f *infohashTestSyncManager) RenameTorrentFile(context.Context, int, string, string, string) error { + return nil +} + +func (f *infohashTestSyncManager) RenameTorrentFolder(context.Context, int, string, string, string) error { + return nil +} + +func (f *infohashTestSyncManager) GetCategories(context.Context, int) (map[string]qbt.Category, error) { + return map[string]qbt.Category{}, nil +} + +func (f *infohashTestSyncManager) CreateCategory(context.Context, int, string, string) error { + return nil +} + +type infohashTestInstanceStore struct { + instances map[int]*models.Instance +} + +func (f *infohashTestInstanceStore) Get(_ context.Context, id int) (*models.Instance, error) { + inst, ok := f.instances[id] + if !ok { + return nil, fmt.Errorf("instance %d not found", id) + } + return inst, nil +} + +func (f *infohashTestInstanceStore) List(_ context.Context) ([]*models.Instance, error) { + list := make([]*models.Instance, 0, len(f.instances)) + for _, inst := range f.instances { + list = append(list, inst) + } + return list, nil +} + +func TestProcessAutomationCandidate_SkipsWhenInfohashExistsOnAllInstances(t *testing.T) { + t.Parallel() + + ctx := context.Background() + instance1ID := 1 + instance2ID := 2 + testHash := "63e07ff523710ca268567dad344ce1e0e6b7e8a3" + torrentName := "Show.S01.1080p.BluRay-GROUP" + + existingTorrent := qbt.Torrent{ + Hash: testHash, + Name: torrentName, + Progress: 1.0, + Category: "tv", + } + + sync := newInfohashTestSyncManager() + // Set up torrents for both instances + sync.torrents[instance1ID] = []qbt.Torrent{existingTorrent} + sync.torrents[instance2ID] = []qbt.Torrent{existingTorrent} + sync.files[instance1ID] = map[string]qbt.TorrentFiles{ + strings.ToLower(testHash): {{Name: "Show.S01E01.1080p.BluRay-GROUP.mkv", Size: 1024}}, + } + sync.files[instance2ID] = map[string]qbt.TorrentFiles{ + strings.ToLower(testHash): {{Name: "Show.S01E01.1080p.BluRay-GROUP.mkv", Size: 1024}}, + } + sync.props[instance1ID] = map[string]*qbt.TorrentProperties{ + strings.ToLower(testHash): {SavePath: "/downloads"}, + } + sync.props[instance2ID] = map[string]*qbt.TorrentProperties{ + strings.ToLower(testHash): {SavePath: "/downloads"}, + } + + // Configure HasTorrentByAnyHash to return existing torrent for both instances + sync.hashResults[instance1ID] = &hashCheckResult{ + torrent: &existingTorrent, + exists: true, + err: nil, + } + sync.hashResults[instance2ID] = &hashCheckResult{ + torrent: &existingTorrent, + exists: true, + err: nil, + } + + downloadCalled := false + service := &Service{ + instanceStore: &infohashTestInstanceStore{ + instances: map[int]*models.Instance{ + instance1ID: {ID: instance1ID, Name: "Instance1"}, + instance2ID: {ID: instance2ID, Name: "Instance2"}, + }, + }, + syncManager: sync, + releaseCache: NewReleaseCache(), + stringNormalizer: stringutils.NewDefaultNormalizer(), + torrentDownloadFunc: func(context.Context, jackett.TorrentDownloadRequest) ([]byte, error) { + downloadCalled = true + return []byte("torrent"), nil + }, + } + + settings := &models.CrossSeedAutomationSettings{ + StartPaused: true, + RSSAutomationTags: []string{"cross-seed"}, + IgnorePatterns: []string{}, + TargetInstanceIDs: []int{instance1ID, instance2ID}, + } + + run := &models.CrossSeedRun{} + result := jackett.SearchResult{ + Indexer: "Example", + IndexerID: 10, + Title: torrentName, + DownloadURL: "https://example.invalid/download.torrent", + GUID: "guid-1", + Size: 1024, + InfoHashV1: testHash, + DownloadVolumeFactor: 1.0, + UploadVolumeFactor: 1.0, + } + + status, returnedHash, err := service.processAutomationCandidate(ctx, run, settings, nil, result, AutomationRunOptions{}, map[int]jackett.EnabledIndexerInfo{}) + + require.NoError(t, err) + assert.Equal(t, models.CrossSeedFeedItemStatusProcessed, status) + assert.NotNil(t, returnedHash) + assert.Equal(t, testHash, *returnedHash) + assert.Equal(t, 2, run.TorrentsSkipped, "should skip for both instances") + assert.False(t, downloadCalled, "should NOT download torrent when it exists on all instances") +} + +func TestProcessAutomationCandidate_ProceedsWhenInfohashExistsOnSomeInstances(t *testing.T) { + t.Parallel() + + ctx := context.Background() + instance1ID := 1 + instance2ID := 2 + testHash := "63e07ff523710ca268567dad344ce1e0e6b7e8a3" + torrentName := "Show.S01.1080p.BluRay-GROUP" + + existingTorrent := qbt.Torrent{ + Hash: testHash, + Name: torrentName, + Progress: 1.0, + Category: "tv", + } + + sync := newInfohashTestSyncManager() + sync.torrents[instance1ID] = []qbt.Torrent{existingTorrent} + sync.torrents[instance2ID] = []qbt.Torrent{existingTorrent} // Both have the torrent for candidate matching + sync.files[instance1ID] = map[string]qbt.TorrentFiles{ + strings.ToLower(testHash): {{Name: "Show.S01E01.1080p.BluRay-GROUP.mkv", Size: 1024}}, + } + sync.files[instance2ID] = map[string]qbt.TorrentFiles{ + strings.ToLower(testHash): {{Name: "Show.S01E01.1080p.BluRay-GROUP.mkv", Size: 1024}}, + } + sync.props[instance1ID] = map[string]*qbt.TorrentProperties{ + strings.ToLower(testHash): {SavePath: "/downloads"}, + } + sync.props[instance2ID] = map[string]*qbt.TorrentProperties{ + strings.ToLower(testHash): {SavePath: "/downloads"}, + } + + // Instance 1 has the torrent by hash, Instance 2 does not (simulating different torrent file) + sync.hashResults[instance1ID] = &hashCheckResult{ + torrent: &existingTorrent, + exists: true, + err: nil, + } + sync.hashResults[instance2ID] = &hashCheckResult{ + torrent: nil, + exists: false, + err: nil, + } + + downloadCalled := false + service := &Service{ + instanceStore: &infohashTestInstanceStore{ + instances: map[int]*models.Instance{ + instance1ID: {ID: instance1ID, Name: "Instance1"}, + instance2ID: {ID: instance2ID, Name: "Instance2"}, + }, + }, + syncManager: sync, + releaseCache: NewReleaseCache(), + stringNormalizer: stringutils.NewDefaultNormalizer(), + torrentDownloadFunc: func(context.Context, jackett.TorrentDownloadRequest) ([]byte, error) { + downloadCalled = true + return []byte("torrent"), nil + }, + } + + // Mock crossSeedInvoker to avoid nil panic + service.crossSeedInvoker = func(ctx context.Context, req *CrossSeedRequest) (*CrossSeedResponse, error) { + return &CrossSeedResponse{ + Success: true, + Results: []InstanceCrossSeedResult{ + {InstanceID: instance2ID, InstanceName: "Instance2", Success: true, Status: "added"}, + }, + }, nil + } + + settings := &models.CrossSeedAutomationSettings{ + StartPaused: true, + RSSAutomationTags: []string{"cross-seed"}, + IgnorePatterns: []string{}, + TargetInstanceIDs: []int{instance1ID, instance2ID}, + } + + run := &models.CrossSeedRun{} + result := jackett.SearchResult{ + Indexer: "Example", + IndexerID: 10, + Title: torrentName, + DownloadURL: "https://example.invalid/download.torrent", + GUID: "guid-1", + Size: 1024, + InfoHashV1: testHash, + DownloadVolumeFactor: 1.0, + UploadVolumeFactor: 1.0, + } + + status, _, err := service.processAutomationCandidate(ctx, run, settings, nil, result, AutomationRunOptions{}, map[int]jackett.EnabledIndexerInfo{}) + + require.NoError(t, err) + // Should proceed with download since not all instances have it + assert.True(t, downloadCalled, "should download torrent when not all instances have it") + assert.Equal(t, models.CrossSeedFeedItemStatusProcessed, status) +} + +func TestProcessAutomationCandidate_ProceedsOnHashCheckError(t *testing.T) { + t.Parallel() + + ctx := context.Background() + instance1ID := 1 + testHash := "63e07ff523710ca268567dad344ce1e0e6b7e8a3" + torrentName := "Show.S01.1080p.BluRay-GROUP" + + existingTorrent := qbt.Torrent{ + Hash: testHash, + Name: torrentName, + Progress: 1.0, + Category: "tv", + } + + sync := newInfohashTestSyncManager() + sync.torrents[instance1ID] = []qbt.Torrent{existingTorrent} + sync.files[instance1ID] = map[string]qbt.TorrentFiles{ + strings.ToLower(testHash): {{Name: "Show.S01E01.1080p.BluRay-GROUP.mkv", Size: 1024}}, + } + sync.props[instance1ID] = map[string]*qbt.TorrentProperties{ + strings.ToLower(testHash): {SavePath: "/downloads"}, + } + + // Configure HasTorrentByAnyHash to return an error + sync.hashResults[instance1ID] = &hashCheckResult{ + torrent: nil, + exists: false, + err: errors.New("connection refused"), + } + + downloadCalled := false + service := &Service{ + instanceStore: &infohashTestInstanceStore{ + instances: map[int]*models.Instance{ + instance1ID: {ID: instance1ID, Name: "Instance1"}, + }, + }, + syncManager: sync, + releaseCache: NewReleaseCache(), + stringNormalizer: stringutils.NewDefaultNormalizer(), + torrentDownloadFunc: func(context.Context, jackett.TorrentDownloadRequest) ([]byte, error) { + downloadCalled = true + return []byte("torrent"), nil + }, + } + + // Mock crossSeedInvoker + service.crossSeedInvoker = func(ctx context.Context, req *CrossSeedRequest) (*CrossSeedResponse, error) { + return &CrossSeedResponse{ + Success: true, + Results: []InstanceCrossSeedResult{ + {InstanceID: instance1ID, InstanceName: "Instance1", Success: true, Status: "added"}, + }, + }, nil + } + + settings := &models.CrossSeedAutomationSettings{ + StartPaused: true, + RSSAutomationTags: []string{"cross-seed"}, + IgnorePatterns: []string{}, + TargetInstanceIDs: []int{instance1ID}, + } + + run := &models.CrossSeedRun{} + result := jackett.SearchResult{ + Indexer: "Example", + IndexerID: 10, + Title: torrentName, + DownloadURL: "https://example.invalid/download.torrent", + GUID: "guid-1", + Size: 1024, + InfoHashV1: testHash, + DownloadVolumeFactor: 1.0, + UploadVolumeFactor: 1.0, + } + + status, _, err := service.processAutomationCandidate(ctx, run, settings, nil, result, AutomationRunOptions{}, map[int]jackett.EnabledIndexerInfo{}) + + require.NoError(t, err) + // Should proceed with download on error (graceful degradation) + assert.True(t, downloadCalled, "should download torrent when hash check fails") + assert.Equal(t, models.CrossSeedFeedItemStatusProcessed, status) +} + +func TestProcessAutomationCandidate_SkipsWhenCommentURLMatches(t *testing.T) { + t.Parallel() + + ctx := context.Background() + instance1ID := 1 + commentURL := "https://seedpool.org/torrents/607803" + torrentName := "Show.S01.1080p.BluRay-GROUP" + torrentHash := "abc123def456abc123def456abc123def456abcd" + + // Torrent with matching comment + existingTorrent := qbt.Torrent{ + Hash: torrentHash, + Name: torrentName, + Progress: 1.0, + Category: "tv", + Comment: "Uploaded from https://seedpool.org/torrents/607803", + } + + sync := newInfohashTestSyncManager() + sync.torrents[instance1ID] = []qbt.Torrent{existingTorrent} + sync.files[instance1ID] = map[string]qbt.TorrentFiles{ + strings.ToLower(torrentHash): {{Name: "Show.S01E01.1080p.BluRay-GROUP.mkv", Size: 1024}}, + } + sync.props[instance1ID] = map[string]*qbt.TorrentProperties{ + strings.ToLower(torrentHash): {SavePath: "/downloads"}, + } + + // No hash results configured - will return nil, false, nil + // This forces the fallback to comment URL matching + + downloadCalled := false + service := &Service{ + instanceStore: &infohashTestInstanceStore{ + instances: map[int]*models.Instance{ + instance1ID: {ID: instance1ID, Name: "Instance1"}, + }, + }, + syncManager: sync, + releaseCache: NewReleaseCache(), + stringNormalizer: stringutils.NewDefaultNormalizer(), + torrentDownloadFunc: func(context.Context, jackett.TorrentDownloadRequest) ([]byte, error) { + downloadCalled = true + return []byte("torrent"), nil + }, + } + + settings := &models.CrossSeedAutomationSettings{ + StartPaused: true, + RSSAutomationTags: []string{"cross-seed"}, + IgnorePatterns: []string{}, + TargetInstanceIDs: []int{instance1ID}, + } + + run := &models.CrossSeedRun{} + result := jackett.SearchResult{ + Indexer: "Example", + IndexerID: 10, + Title: torrentName, + DownloadURL: "https://example.invalid/download.torrent", + GUID: commentURL, // UNIT3D style: GUID is the torrent details URL + Size: 1024, + InfoHashV1: "", // No infohash provided + DownloadVolumeFactor: 1.0, + UploadVolumeFactor: 1.0, + } + + status, returnedHash, err := service.processAutomationCandidate(ctx, run, settings, nil, result, AutomationRunOptions{}, map[int]jackett.EnabledIndexerInfo{}) + + require.NoError(t, err) + assert.Equal(t, models.CrossSeedFeedItemStatusProcessed, status) + assert.Nil(t, returnedHash, "should not return hash for comment URL match") + assert.Equal(t, 1, run.TorrentsSkipped, "should skip for instance with matching comment") + assert.False(t, downloadCalled, "should NOT download torrent when comment URL matches") +} diff --git a/internal/services/crossseed/service.go b/internal/services/crossseed/service.go index 22a18c675..8bf6d253c 100644 --- a/internal/services/crossseed/service.go +++ b/internal/services/crossseed/service.go @@ -1645,11 +1645,13 @@ func (s *Service) processAutomationCandidate(ctx context.Context, run *models.Cr for _, candidate := range candidatesResp.Candidates { existing, exists, hashErr := s.syncManager.HasTorrentByAnyHash(ctx, candidate.InstanceID, []string{result.InfoHashV1}) if hashErr != nil { - log.Debug(). + log.Warn(). Err(hashErr). Int("instanceID", candidate.InstanceID). + Str("instanceName", candidate.InstanceName). Str("hash", result.InfoHashV1). - Msg("[RSS] Failed to check existing hash, will proceed with download") + Str("title", result.Title). + Msg("[RSS] Failed to check existing hash on instance, will proceed with download") allExist = false break } @@ -7427,11 +7429,9 @@ func wrapCrossSeedSearchError(err error) error { // torrent comments. UNIT3D and similar trackers embed the source URL in the torrent's // comment field. Returns empty string if no suitable URL pattern is found. // -// Supported patterns: -// - https://seedpool.org/torrents/607803 -// - https://beyond-hd.me/details/500790 -// - https://aither.cc/torrents/318093 -// - https://blutopia.cc/torrents/294836 +// Matches any HTTPS URL containing these path patterns: +// - /torrents/ (UNIT3D style: seedpool.org, aither.cc, blutopia.cc, etc.) +// - /details/ (BHD style: beyond-hd.me) func extractTorrentURLForCommentMatch(guid, infoURL string) string { // Try GUID first (typically the details URL for UNIT3D) if url := parseTorrentDetailsURL(guid); url != "" { diff --git a/internal/services/jackett/service.go b/internal/services/jackett/service.go index 2a150ab21..14dbc103c 100644 --- a/internal/services/jackett/service.go +++ b/internal/services/jackett/service.go @@ -3062,7 +3062,9 @@ func extractInfoHashFromAttributes(attrs map[string]string) string { return "" } -// validateInfoHash checks if the value is a valid hex infohash (40 chars for SHA1, 64 for SHA256) +// validateInfoHash checks if the value is a valid hex-encoded infohash. +// Accepts SHA1 (20 bytes = 40 hex chars) or SHA256 (32 bytes = 64 hex chars). +// Note: Base32-encoded hashes (32 chars) are NOT supported; only hex encoding. func validateInfoHash(value string) string { value = strings.TrimSpace(strings.ToLower(value)) if len(value) == 40 || len(value) == 64 { @@ -3073,8 +3075,10 @@ func validateInfoHash(value string) string { return "" } -// extractInfoHashFromMagnet extracts the infohash from a magnet URL +// extractInfoHashFromMagnet extracts the infohash from a magnet URL. // Format: magnet:?xt=urn:btih:&... +// Note: Only hex-encoded hashes are supported. Base32-encoded hashes +// (which are also valid per magnet URI spec) will return empty string. func extractInfoHashFromMagnet(magnetURL string) string { magnetURL = strings.TrimSpace(magnetURL) if magnetURL == "" || !strings.HasPrefix(strings.ToLower(magnetURL), "magnet:") { From 33a062dcc1cbbf6ec03997ce368ad23703140cd9 Mon Sep 17 00:00:00 2001 From: soup Date: Tue, 9 Dec 2025 23:34:29 +0100 Subject: [PATCH 4/6] fix(rss): propagate context cancellation in infohash pre-check --- internal/services/crossseed/crossseed_test.go | 77 +++++++++++++++++++ internal/services/crossseed/service.go | 14 ++-- 2 files changed, 86 insertions(+), 5 deletions(-) diff --git a/internal/services/crossseed/crossseed_test.go b/internal/services/crossseed/crossseed_test.go index 4234c60b2..a87365948 100644 --- a/internal/services/crossseed/crossseed_test.go +++ b/internal/services/crossseed/crossseed_test.go @@ -3773,6 +3773,83 @@ func TestProcessAutomationCandidate_ProceedsOnHashCheckError(t *testing.T) { assert.Equal(t, models.CrossSeedFeedItemStatusProcessed, status) } +func TestProcessAutomationCandidate_PropagatesContextCancellation(t *testing.T) { + t.Parallel() + + ctx := context.Background() + instance1ID := 1 + testHash := "63e07ff523710ca268567dad344ce1e0e6b7e8a3" + torrentName := "Show.S01.1080p.BluRay-GROUP" + + existingTorrent := qbt.Torrent{ + Hash: testHash, + Name: torrentName, + Progress: 1.0, + Category: "tv", + } + + sync := newInfohashTestSyncManager() + sync.torrents[instance1ID] = []qbt.Torrent{existingTorrent} + sync.files[instance1ID] = map[string]qbt.TorrentFiles{ + strings.ToLower(testHash): {{Name: "Show.S01E01.1080p.BluRay-GROUP.mkv", Size: 1024}}, + } + sync.props[instance1ID] = map[string]*qbt.TorrentProperties{ + strings.ToLower(testHash): {SavePath: "/downloads"}, + } + + // Configure HasTorrentByAnyHash to return context.Canceled error + sync.hashResults[instance1ID] = &hashCheckResult{ + torrent: nil, + exists: false, + err: context.Canceled, + } + + downloadCalled := false + service := &Service{ + instanceStore: &infohashTestInstanceStore{ + instances: map[int]*models.Instance{ + instance1ID: {ID: instance1ID, Name: "Instance1"}, + }, + }, + syncManager: sync, + releaseCache: NewReleaseCache(), + stringNormalizer: stringutils.NewDefaultNormalizer(), + torrentDownloadFunc: func(context.Context, jackett.TorrentDownloadRequest) ([]byte, error) { + downloadCalled = true + return []byte("torrent"), nil + }, + } + + settings := &models.CrossSeedAutomationSettings{ + StartPaused: true, + RSSAutomationTags: []string{"cross-seed"}, + IgnorePatterns: []string{}, + TargetInstanceIDs: []int{instance1ID}, + } + + run := &models.CrossSeedRun{} + result := jackett.SearchResult{ + Indexer: "Example", + IndexerID: 10, + Title: torrentName, + DownloadURL: "https://example.invalid/download.torrent", + GUID: "guid-1", + Size: 1024, + InfoHashV1: testHash, + DownloadVolumeFactor: 1.0, + UploadVolumeFactor: 1.0, + } + + status, _, err := service.processAutomationCandidate(ctx, run, settings, nil, result, AutomationRunOptions{}, map[int]jackett.EnabledIndexerInfo{}) + + // Context cancellation should propagate as an error, not trigger fallback + require.Error(t, err) + assert.ErrorIs(t, err, context.Canceled) + assert.Contains(t, err.Error(), "hash check canceled") + assert.Equal(t, models.CrossSeedFeedItemStatusFailed, status) + assert.False(t, downloadCalled, "should NOT download torrent when context is canceled") +} + func TestProcessAutomationCandidate_SkipsWhenCommentURLMatches(t *testing.T) { t.Parallel() diff --git a/internal/services/crossseed/service.go b/internal/services/crossseed/service.go index 8bf6d253c..f35548486 100644 --- a/internal/services/crossseed/service.go +++ b/internal/services/crossseed/service.go @@ -1645,6 +1645,10 @@ func (s *Service) processAutomationCandidate(ctx context.Context, run *models.Cr for _, candidate := range candidatesResp.Candidates { existing, exists, hashErr := s.syncManager.HasTorrentByAnyHash(ctx, candidate.InstanceID, []string{result.InfoHashV1}) if hashErr != nil { + // Context cancellation should propagate, not trigger fallback + if errors.Is(hashErr, context.Canceled) || errors.Is(hashErr, context.DeadlineExceeded) { + return models.CrossSeedFeedItemStatusFailed, nil, fmt.Errorf("hash check canceled: %w", hashErr) + } log.Warn(). Err(hashErr). Int("instanceID", candidate.InstanceID). @@ -1658,11 +1662,11 @@ func (s *Service) processAutomationCandidate(ctx context.Context, run *models.Cr if exists && existing != nil { existingResults = append(existingResults, models.CrossSeedRunResult{ - InstanceID: candidate.InstanceID, - InstanceName: candidate.InstanceName, - Success: false, - Status: "exists", - Message: "Torrent already exists (infohash pre-check)", + InstanceID: candidate.InstanceID, + InstanceName: candidate.InstanceName, + Success: false, + Status: "exists", + Message: "Torrent already exists (infohash pre-check)", MatchedTorrentHash: func() *string { h := existing.Hash; return &h }(), MatchedTorrentName: func() *string { n := existing.Name; return &n }(), }) From d6782f5c58a56c612d191c0246723a6db55accda Mon Sep 17 00:00:00 2001 From: soup Date: Tue, 9 Dec 2025 23:55:01 +0100 Subject: [PATCH 5/6] fix(rss): add context cancellation check to comment URL pre-check --- internal/services/crossseed/crossseed_test.go | 77 +++++++++++++++++++ internal/services/crossseed/service.go | 5 ++ 2 files changed, 82 insertions(+) diff --git a/internal/services/crossseed/crossseed_test.go b/internal/services/crossseed/crossseed_test.go index a87365948..40a6f7306 100644 --- a/internal/services/crossseed/crossseed_test.go +++ b/internal/services/crossseed/crossseed_test.go @@ -3850,6 +3850,83 @@ func TestProcessAutomationCandidate_PropagatesContextCancellation(t *testing.T) assert.False(t, downloadCalled, "should NOT download torrent when context is canceled") } +func TestProcessAutomationCandidate_PropagatesContextDeadlineExceeded(t *testing.T) { + t.Parallel() + + ctx := context.Background() + instance1ID := 1 + testHash := "63e07ff523710ca268567dad344ce1e0e6b7e8a3" + torrentName := "Show.S01.1080p.BluRay-GROUP" + + existingTorrent := qbt.Torrent{ + Hash: testHash, + Name: torrentName, + Progress: 1.0, + Category: "tv", + } + + sync := newInfohashTestSyncManager() + sync.torrents[instance1ID] = []qbt.Torrent{existingTorrent} + sync.files[instance1ID] = map[string]qbt.TorrentFiles{ + strings.ToLower(testHash): {{Name: "Show.S01E01.1080p.BluRay-GROUP.mkv", Size: 1024}}, + } + sync.props[instance1ID] = map[string]*qbt.TorrentProperties{ + strings.ToLower(testHash): {SavePath: "/downloads"}, + } + + // Configure HasTorrentByAnyHash to return context.DeadlineExceeded error + sync.hashResults[instance1ID] = &hashCheckResult{ + torrent: nil, + exists: false, + err: context.DeadlineExceeded, + } + + downloadCalled := false + service := &Service{ + instanceStore: &infohashTestInstanceStore{ + instances: map[int]*models.Instance{ + instance1ID: {ID: instance1ID, Name: "Instance1"}, + }, + }, + syncManager: sync, + releaseCache: NewReleaseCache(), + stringNormalizer: stringutils.NewDefaultNormalizer(), + torrentDownloadFunc: func(context.Context, jackett.TorrentDownloadRequest) ([]byte, error) { + downloadCalled = true + return []byte("torrent"), nil + }, + } + + settings := &models.CrossSeedAutomationSettings{ + StartPaused: true, + RSSAutomationTags: []string{"cross-seed"}, + IgnorePatterns: []string{}, + TargetInstanceIDs: []int{instance1ID}, + } + + run := &models.CrossSeedRun{} + result := jackett.SearchResult{ + Indexer: "Example", + IndexerID: 10, + Title: torrentName, + DownloadURL: "https://example.invalid/download.torrent", + GUID: "guid-1", + Size: 1024, + InfoHashV1: testHash, + DownloadVolumeFactor: 1.0, + UploadVolumeFactor: 1.0, + } + + status, _, err := service.processAutomationCandidate(ctx, run, settings, nil, result, AutomationRunOptions{}, map[int]jackett.EnabledIndexerInfo{}) + + // Context deadline exceeded should propagate as an error, not trigger fallback + require.Error(t, err) + assert.ErrorIs(t, err, context.DeadlineExceeded) + assert.Contains(t, err.Error(), "hash check canceled") + assert.Equal(t, models.CrossSeedFeedItemStatusFailed, status) + assert.False(t, downloadCalled, "should NOT download torrent when context deadline exceeded") +} + func TestProcessAutomationCandidate_SkipsWhenCommentURLMatches(t *testing.T) { t.Parallel() diff --git a/internal/services/crossseed/service.go b/internal/services/crossseed/service.go index f35548486..714f2d999 100644 --- a/internal/services/crossseed/service.go +++ b/internal/services/crossseed/service.go @@ -1698,6 +1698,11 @@ func (s *Service) processAutomationCandidate(ctx context.Context, run *models.Cr var existingResults []models.CrossSeedRunResult for _, candidate := range candidatesResp.Candidates { + // Check for context cancellation before processing each candidate + if ctx.Err() != nil { + return models.CrossSeedFeedItemStatusFailed, nil, fmt.Errorf("comment URL pre-check canceled: %w", ctx.Err()) + } + found := false var matchedTorrent *qbt.Torrent From 64734d55c78ad228fdd68c3cf9d526707ee9b476 Mon Sep 17 00:00:00 2001 From: soup Date: Wed, 10 Dec 2025 16:10:59 +0100 Subject: [PATCH 6/6] fix(crossseed): increment TorrentsFailed on context cancellation --- internal/services/crossseed/crossseed_test.go | 2 ++ internal/services/crossseed/service.go | 2 ++ 2 files changed, 4 insertions(+) diff --git a/internal/services/crossseed/crossseed_test.go b/internal/services/crossseed/crossseed_test.go index 40a6f7306..65b620272 100644 --- a/internal/services/crossseed/crossseed_test.go +++ b/internal/services/crossseed/crossseed_test.go @@ -3847,6 +3847,7 @@ func TestProcessAutomationCandidate_PropagatesContextCancellation(t *testing.T) assert.ErrorIs(t, err, context.Canceled) assert.Contains(t, err.Error(), "hash check canceled") assert.Equal(t, models.CrossSeedFeedItemStatusFailed, status) + assert.Equal(t, 1, run.TorrentsFailed, "should increment TorrentsFailed on context cancellation") assert.False(t, downloadCalled, "should NOT download torrent when context is canceled") } @@ -3924,6 +3925,7 @@ func TestProcessAutomationCandidate_PropagatesContextDeadlineExceeded(t *testing assert.ErrorIs(t, err, context.DeadlineExceeded) assert.Contains(t, err.Error(), "hash check canceled") assert.Equal(t, models.CrossSeedFeedItemStatusFailed, status) + assert.Equal(t, 1, run.TorrentsFailed, "should increment TorrentsFailed on context deadline exceeded") assert.False(t, downloadCalled, "should NOT download torrent when context deadline exceeded") } diff --git a/internal/services/crossseed/service.go b/internal/services/crossseed/service.go index 714f2d999..3d122cd51 100644 --- a/internal/services/crossseed/service.go +++ b/internal/services/crossseed/service.go @@ -1647,6 +1647,7 @@ func (s *Service) processAutomationCandidate(ctx context.Context, run *models.Cr if hashErr != nil { // Context cancellation should propagate, not trigger fallback if errors.Is(hashErr, context.Canceled) || errors.Is(hashErr, context.DeadlineExceeded) { + run.TorrentsFailed++ return models.CrossSeedFeedItemStatusFailed, nil, fmt.Errorf("hash check canceled: %w", hashErr) } log.Warn(). @@ -1700,6 +1701,7 @@ func (s *Service) processAutomationCandidate(ctx context.Context, run *models.Cr for _, candidate := range candidatesResp.Candidates { // Check for context cancellation before processing each candidate if ctx.Err() != nil { + run.TorrentsFailed++ return models.CrossSeedFeedItemStatusFailed, nil, fmt.Errorf("comment URL pre-check canceled: %w", ctx.Err()) }