From 5aca5e9a8d0fa7ba8822e8c3f349a96c3cfb919b Mon Sep 17 00:00:00 2001 From: Audionut Date: Sat, 14 Mar 2026 22:44:58 +1000 Subject: [PATCH 1/4] feat(crossseed): finalize webhook check validation --- .../docs/features/cross-seed/autobrr.md | 16 +- .../features/cross-seed/troubleshooting.md | 14 +- documentation/static/openapi.yaml | 25 +- internal/api/handlers/crossseed.go | 10 +- .../crossseed_webhook_handler_test.go | 59 ++ internal/services/crossseed/crossseed_test.go | 6 + internal/services/crossseed/models.go | 18 +- internal/services/crossseed/service.go | 542 ++++++++++-------- .../services/crossseed/webhook_check_test.go | 279 +++++++++ internal/web/swagger/openapi.yaml | 25 +- 10 files changed, 685 insertions(+), 309 deletions(-) create mode 100644 internal/api/handlers/crossseed_webhook_handler_test.go create mode 100644 internal/services/crossseed/webhook_check_test.go diff --git a/documentation/docs/features/cross-seed/autobrr.md b/documentation/docs/features/cross-seed/autobrr.md index 5617c3de4..4db2f6e30 100644 --- a/documentation/docs/features/cross-seed/autobrr.md +++ b/documentation/docs/features/cross-seed/autobrr.md @@ -10,12 +10,12 @@ qui integrates with autobrr through webhook endpoints, enabling real-time cross- ## How It Works 1. autobrr sees a new release from a tracker -2. autobrr sends the torrent name to qui's `/api/cross-seed/webhook/check` endpoint -3. qui searches your qBittorrent instances for matching content +2. autobrr sends the torrent file to qui's `/api/cross-seed/webhook/check` endpoint +3. qui parses the torrent and runs the full non-mutating cross-seed validation flow against your qBittorrent instances 4. qui responds with: - - `200 OK` – matching torrent is complete and ready to cross-seed - - `202 Accepted` – matching torrent exists but still downloading; retry later - - `404 Not Found` – no matching torrent exists + - `200 OK` – a fully validated matching torrent is complete and ready to cross-seed + - `202 Accepted` – a fully validated match exists, but the local source torrent is still downloading; retry later + - `404 Not Found` – no fully validated cross-seed match exists 5. On `200 OK`, autobrr sends the torrent file to `/api/cross-seed/apply` ## Setup @@ -61,7 +61,7 @@ In your new autobrr filter, go to **External** tab → **Add new**: ```json { - "torrentName": {{ toRawJson .TorrentName }}, + "torrentData": "{{ .TorrentDataRawBytes | toString | b64enc }}", "instanceIds": [1] } ``` @@ -70,13 +70,13 @@ To search all instances, omit `instanceIds`: ```json { - "torrentName": {{ toRawJson .TorrentName }} + "torrentData": "{{ .TorrentDataRawBytes | toString | b64enc }}" } ``` **Field descriptions:** -- `torrentName` (required): The release name as announced +- `torrentData` (required): Base64-encoded torrent file bytes from autobrr - `instanceIds` (optional): qBittorrent instance IDs to scan. Omit to search all instances. - `findIndividualEpisodes` (optional): Override the global episode matching setting diff --git a/documentation/docs/features/cross-seed/troubleshooting.md b/documentation/docs/features/cross-seed/troubleshooting.md index 4d0d859c5..666a004d4 100644 --- a/documentation/docs/features/cross-seed/troubleshooting.md +++ b/documentation/docs/features/cross-seed/troubleshooting.md @@ -116,24 +116,16 @@ This typically means the torrent name contains special characters (like double q {"level":"error","error":"invalid character 'V' after object key:value pair","time":"...","message":"Failed to decode webhook check request"} ``` -**Solution:** In your autobrr webhook configuration, use `toRawJson` instead of quoting the template variable directly: +**Solution:** For `/api/cross-seed/webhook/check`, send the torrent bytes directly instead of building JSON from `TorrentName`: ```json { - "torrentName": {{ toRawJson .TorrentName }}, + "torrentData": "{{ .TorrentDataRawBytes | toString | b64enc }}", "instanceIds": [1] } ``` -**Not:** -```json -{ - "torrentName": "{{ .TorrentName }}", - "instanceIds": [1] -} -``` - -The `toRawJson` function (from Sprig) properly escapes special characters and outputs a valid JSON string including the quotes. +This avoids JSON-escaping issues in release names and lets qui perform final file-level validation before it responds. ## Cross-seed in wrong category diff --git a/documentation/static/openapi.yaml b/documentation/static/openapi.yaml index 5f1407684..a8211777a 100644 --- a/documentation/static/openapi.yaml +++ b/documentation/static/openapi.yaml @@ -2999,10 +2999,10 @@ paths: - Cross-Seed summary: Check if a release can be cross-seeded (autobrr webhook) description: | - Accepts release metadata from autobrr and checks if matching torrents exist on the requested instances (or all instances when no list is provided). The HTTP status describes whether the match is ready: - * `200 OK` – at least one matching torrent is fully downloaded and ready for cross-seeding - * `202 Accepted` – matching torrents exist but the data is still downloading; retry `/check` until it returns `200` (ready) or `404` - * `404 Not Found` – no matches exist (recommendation `skip`) + Accepts a base64-encoded torrent file from autobrr and performs the full non-mutating cross-seed validation flow on the requested instances (or all instances when no list is provided). The HTTP status describes the final verdict: + * `200 OK` – at least one matching local torrent passed file-level validation and is fully downloaded, so the release is ready for cross-seeding + * `202 Accepted` – at least one matching local torrent passed file-level validation, but all valid local sources are still downloading; retry `/check` until it returns `200` or `404` + * `404 Not Found` – no fully validated cross-seed matches exist (recommendation `skip`) This endpoint is designed for autobrr filter external webhooks. When `instanceIds` is omitted or empty, qui will search every configured instance. Provide a subset of IDs to restrict the scan. parameters: - name: apikey @@ -3018,40 +3018,35 @@ paths: schema: type: object required: - - torrentName + - torrentData properties: - torrentName: + torrentData: type: string - description: Release name as announced (parsed using rls library to extract metadata) - example: "That.Movie.2025.1080p.BluRay.x264-GROUP" + description: Base64-encoded torrent file bytes from autobrr (for example, using the TorrentDataRawBytes macro piped through toString|b64enc or toJson) instanceIds: type: array items: type: integer description: Optional list of qBittorrent instance IDs to consider. When omitted or empty, qui searches all configured instances. example: [1, 2, 3] - size: - type: integer - format: uint64 - description: Total torrent size in bytes (optional - enables size validation when provided) findIndividualEpisodes: type: boolean description: Optional override for matching season packs vs episodes. Defaults to the Cross-Seed automation setting when omitted. responses: '200': - description: Webhook check completed successfully with one or more matches + description: Webhook check completed successfully with one or more fully validated ready matches content: application/json: schema: $ref: '#/components/schemas/CrossSeedWebhookCheckResponse' '202': - description: Matches exist but torrents are still downloading (retry until 200 OK). The body mirrors the 200 response to show pending matches. + description: Fully validated matches exist, but the matching local torrent data is still downloading (retry until 200 OK or 404). content: application/json: schema: $ref: '#/components/schemas/CrossSeedWebhookCheckResponse' '404': - description: No cross-seed matches found across the targeted instances, or no instances were available (empty matches array and canCrossSeed=false with recommendation="skip") + description: No fully validated cross-seed matches found across the targeted instances (empty matches array and canCrossSeed=false with recommendation="skip") content: application/json: schema: diff --git a/internal/api/handlers/crossseed.go b/internal/api/handlers/crossseed.go index a641f19a1..933475edf 100644 --- a/internal/api/handlers/crossseed.go +++ b/internal/api/handlers/crossseed.go @@ -1460,14 +1460,14 @@ func (h *CrossSeedHandler) GetCrossSeedStatus(w http.ResponseWriter, r *http.Req // WebhookCheck godoc // @Summary Check if a release can be cross-seeded (autobrr webhook) -// @Description Accepts release metadata from autobrr and checks if matching torrents exist across instances +// @Description Accepts a torrent file from autobrr and performs the full non-mutating cross-seed validation flow before responding // @Tags cross-seed // @Accept json // @Produce json -// @Param request body crossseed.WebhookCheckRequest true "Release metadata from autobrr" -// @Success 200 {object} crossseed.WebhookCheckResponse "Matches found (torrents complete, recommendation=download)" -// @Success 202 {object} crossseed.WebhookCheckResponse "Matches found but torrents still downloading (recommendation=download, retry until 200)" -// @Failure 404 {object} crossseed.WebhookCheckResponse "No matches found (recommendation=skip)" +// @Param request body crossseed.WebhookCheckRequest true "Torrent payload from autobrr" +// @Success 200 {object} crossseed.WebhookCheckResponse "Matches found and fully validated (local source torrent complete, recommendation=download)" +// @Success 202 {object} crossseed.WebhookCheckResponse "Matches found and fully validated, but the local source torrent is still downloading (recommendation=download, retry until 200 or 404)" +// @Failure 404 {object} crossseed.WebhookCheckResponse "No fully validated cross-seed matches found (recommendation=skip)" // @Failure 400 {object} httphelpers.ErrorResponse // @Failure 500 {object} httphelpers.ErrorResponse // @Security ApiKeyAuth diff --git a/internal/api/handlers/crossseed_webhook_handler_test.go b/internal/api/handlers/crossseed_webhook_handler_test.go new file mode 100644 index 000000000..08bc59e3d --- /dev/null +++ b/internal/api/handlers/crossseed_webhook_handler_test.go @@ -0,0 +1,59 @@ +// Copyright (c) 2025-2026, s0up and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package handlers + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/autobrr/qui/internal/services/crossseed" +) + +func TestWebhookCheckHandler_BadRequestPaths(t *testing.T) { + t.Parallel() + + handler := NewCrossSeedHandler(&crossseed.Service{}, nil, nil) + + tests := []struct { + name string + body string + want int + message string + }{ + { + name: "invalid json", + body: "{", + want: http.StatusBadRequest, + message: "Invalid request body", + }, + { + name: "missing torrent data", + body: `{"instanceIds":[1]}`, + want: http.StatusBadRequest, + message: "torrentData is required", + }, + { + name: "invalid base64", + body: `{"torrentData":"not-base64"}`, + want: http.StatusBadRequest, + message: "invalid webhook request", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/api/cross-seed/webhook/check", bytes.NewBufferString(tt.body)) + rec := httptest.NewRecorder() + + handler.WebhookCheck(rec, req) + + require.Equal(t, tt.want, rec.Code) + require.Contains(t, rec.Body.String(), tt.message) + }) + } +} diff --git a/internal/services/crossseed/crossseed_test.go b/internal/services/crossseed/crossseed_test.go index 329de1253..89b22e8f5 100644 --- a/internal/services/crossseed/crossseed_test.go +++ b/internal/services/crossseed/crossseed_test.go @@ -2079,6 +2079,7 @@ func TestMakeReleaseKey_Matching(t *testing.T) { // TestCheckWebhook_AutobrrPayload exercises the webhook handler end-to-end using faked dependencies. func TestCheckWebhook_AutobrrPayload(t *testing.T) { + t.Skip("metadata-only webhook tests replaced by file-aware webhook tests") instance := &models.Instance{ ID: 1, Name: "Test Instance", @@ -2344,6 +2345,7 @@ func TestCheckWebhook_AutobrrPayload(t *testing.T) { func TestCheckWebhook_NotificationRequiresCompleteMatch(t *testing.T) { t.Parallel() + t.Skip("metadata-only webhook tests replaced by file-aware webhook tests") instance := &models.Instance{ ID: 1, @@ -2406,6 +2408,7 @@ func TestCheckWebhook_NotificationRequiresCompleteMatch(t *testing.T) { func TestCheckWebhook_NoInstancesAvailable(t *testing.T) { t.Parallel() + t.Skip("metadata-only webhook tests replaced by file-aware webhook tests") tests := []struct { name string @@ -2454,6 +2457,7 @@ func TestCheckWebhook_NoInstancesAvailable(t *testing.T) { func TestCheckWebhook_MultiInstanceScan(t *testing.T) { t.Parallel() + t.Skip("metadata-only webhook tests replaced by file-aware webhook tests") instanceA := &models.Instance{ID: 1, Name: "A"} instanceB := &models.Instance{ID: 2, Name: "B"} @@ -2847,6 +2851,7 @@ func (f *fakeSyncManager) CreateCategory(_ context.Context, _ int, _, _ string) // TestWebhookCheckRequest_Validation tests request validation func TestWebhookCheckRequest_Validation(t *testing.T) { + t.Skip("metadata-only webhook tests replaced by file-aware webhook tests") tests := []struct { name string request *WebhookCheckRequest @@ -4321,6 +4326,7 @@ func TestProcessAutomationCandidate_SkipsWhenCommentURLMatches(t *testing.T) { func TestCheckWebhook_WebhookSourceFilters(t *testing.T) { t.Parallel() + t.Skip("metadata-only webhook tests replaced by file-aware webhook tests") instance := &models.Instance{ ID: 1, diff --git a/internal/services/crossseed/models.go b/internal/services/crossseed/models.go index 456768105..79747d8f9 100644 --- a/internal/services/crossseed/models.go +++ b/internal/services/crossseed/models.go @@ -155,6 +155,9 @@ type FindCandidatesRequest struct { // incomplete "season pack from episode" outcomes. // If false (default), season packs will only match with other season packs. FindIndividualEpisodes bool `json:"find_individual_episodes,omitempty"` + // IncludeIncompleteCandidates keeps in-progress torrents in the candidate pool. + // Internal-only, used by webhook dry-run evaluation so 202 reflects validated pending matches. + IncludeIncompleteCandidates bool `json:"-"` // Source filters - used to restrict which existing torrents are considered as candidates. // These are applied when fetching torrents (if no pre-built snapshot is provided). @@ -376,14 +379,17 @@ type AsyncTorrentAnalysis struct { } // WebhookCheckRequest represents a request from autobrr to check if a release can be cross-seeded. -// The torrentName is parsed using the rls library to extract all metadata, so only the name is required. +// The torrentData is parsed using torrent metainfo and rls so qui can return a final verdict. type WebhookCheckRequest struct { - // TorrentName is the release name as announced (required) - TorrentName string `json:"torrentName"` + // TorrentData is the base64-encoded torrent file bytes from autobrr (required) + TorrentData string `json:"torrentData"` + // Deprecated: retained only so older internal tests/helpers can compile while the webhook + // flow migrates to torrentData-driven validation. Ignored by the API. + TorrentName string `json:"-"` // InstanceIDs optionally limits the scan to the requested instances; omit or pass an empty array to search all instances. InstanceIDs []int `json:"instanceIds,omitempty"` - // Size is the total torrent size in bytes (optional - enables size validation if provided) - Size uint64 `json:"size,omitempty"` + // Deprecated: ignored by the API. Size is derived from torrentData. + Size uint64 `json:"-"` // FindIndividualEpisodes overrides the default behavior when matching season packs vs episodes. // When omitted, qui uses the automation setting; when set, this explicitly forces the behavior. FindIndividualEpisodes *bool `json:"findIndividualEpisodes,omitempty"` @@ -395,7 +401,7 @@ type WebhookCheckMatch struct { InstanceName string `json:"instanceName"` TorrentHash string `json:"torrentHash"` TorrentName string `json:"torrentName"` - MatchType string `json:"matchType"` // "metadata", "exact", "size" + MatchType string `json:"matchType"` // "exact", "size", "partial-in-pack", "partial-contains" SizeDiff float64 `json:"sizeDiff,omitempty"` Progress float64 `json:"progress"` } diff --git a/internal/services/crossseed/service.go b/internal/services/crossseed/service.go index e49c9babb..c4459ebbc 100644 --- a/internal/services/crossseed/service.go +++ b/internal/services/crossseed/service.go @@ -831,6 +831,12 @@ var ErrInvalidWebhookRequest = errors.New("invalid webhook request") // ErrInvalidRequest indicates a generic cross-seed request validation error. var ErrInvalidRequest = errors.New("cross-seed invalid request") +// ErrTorrentDataDecode indicates a request supplied invalid base64 torrent data. +var ErrTorrentDataDecode = errors.New("cross-seed torrent data decode failed") + +// ErrTorrentParse indicates decoded torrent bytes could not be parsed as a torrent file. +var ErrTorrentParse = errors.New("cross-seed torrent parse failed") + // ErrTorrentNotFound indicates the requested torrent could not be located in qBittorrent. var ErrTorrentNotFound = errors.New("cross-seed torrent not found") @@ -3110,8 +3116,13 @@ func (s *Service) findCandidates(ctx context.Context, req *FindCandidatesRequest log.Warn().Err(recoverErr).Int("instanceID", instanceID).Msg("Failed to recover errored torrents") } + filter := qbt.TorrentFilterCompleted + if req.IncludeIncompleteCandidates { + filter = qbt.TorrentFilterAll + } + // Re-fetch torrents after recovery to get updated states - torrents, err = s.syncManager.GetTorrents(ctx, instanceID, qbt.TorrentFilterOptions{Filter: qbt.TorrentFilterCompleted}) + torrents, err = s.syncManager.GetTorrents(ctx, instanceID, qbt.TorrentFilterOptions{Filter: filter}) if err != nil { log.Warn(). Int("instanceID", instanceID). @@ -3128,8 +3139,9 @@ func (s *Service) findCandidates(ctx context.Context, req *FindCandidatesRequest // Pre-filter torrents before loading files to reduce downstream work for _, torrent := range torrents { - // Only complete torrents can provide data - if torrent.Progress < 1.0 { + // Cross-seed apply only uses complete torrents, but webhook dry-runs need + // to keep valid in-progress matches so they can return 202 after final validation. + if torrent.Progress < 1.0 && !req.IncludeIncompleteCandidates { continue } @@ -3252,51 +3264,19 @@ func (s *Service) findCandidates(ctx context.Context, req *FindCandidatesRequest // It finds existing 100% complete torrents that match the content and adds the new torrent // paused to the same location with matching category and ATM state func (s *Service) CrossSeed(ctx context.Context, req *CrossSeedRequest) (*CrossSeedResponse, error) { - if req.TorrentData == "" { - return nil, errors.New("torrent_data is required") - } - - // Decode base64 torrent data - torrentBytes, err := s.decodeTorrentData(req.TorrentData) + prepared, err := s.prepareCrossSeedEvaluation(ctx, req, false) if err != nil { - return nil, fmt.Errorf("failed to decode torrent data: %w", err) + return nil, err } - // Parse torrent metadata to get name, hash, files, and info for validation - meta, err := ParseTorrentMetadataWithInfo(torrentBytes) - if err != nil { - return nil, fmt.Errorf("failed to parse torrent: %w", err) - } + torrentBytes := prepared.torrentBytes + meta := prepared.meta torrentHash := meta.HashV1 if meta.Info != nil && !meta.Info.HasV1() && meta.HashV2 != "" { torrentHash = meta.HashV2 } - sourceRelease := s.releaseCache.Parse(meta.Name) - - // Use FindCandidates to locate matching torrents - findReq := &FindCandidatesRequest{ - TorrentName: meta.Name, - TargetInstanceIDs: req.TargetInstanceIDs, - FindIndividualEpisodes: req.FindIndividualEpisodes, - } - // Pass through source filters for RSS automation - if len(req.SourceFilterCategories) > 0 { - findReq.SourceFilterCategories = append([]string(nil), req.SourceFilterCategories...) - } - if len(req.SourceFilterTags) > 0 { - findReq.SourceFilterTags = append([]string(nil), req.SourceFilterTags...) - } - if len(req.SourceFilterExcludeCategories) > 0 { - findReq.SourceFilterExcludeCategories = append([]string(nil), req.SourceFilterExcludeCategories...) - } - if len(req.SourceFilterExcludeTags) > 0 { - findReq.SourceFilterExcludeTags = append([]string(nil), req.SourceFilterExcludeTags...) - } - - candidatesResp, err := s.FindCandidates(ctx, findReq) - if err != nil { - return nil, fmt.Errorf("failed to find candidates: %w", err) - } + sourceRelease := prepared.sourceRelease + candidatesResp := prepared.candidatesResp // Detect disc layout from source files isDiscLayout, discMarker := isDiscLayoutTorrent(meta.Files) @@ -4665,9 +4645,6 @@ func (s *Service) batchLoadCandidateFiles(ctx context.Context, instanceID int, t seen := make(map[string]struct{}, len(torrents)) hashes := make([]string, 0, len(torrents)) for _, torrent := range torrents { - if torrent.Progress < 1.0 { - continue - } hash := normalizeHash(torrent.Hash) if hash == "" { continue @@ -4812,6 +4789,189 @@ func (s *Service) findBestCandidateMatch( return matchedTorrent, candidateFiles, matchType, bestRejectReason } +type candidateValidationResult struct { + torrent *qbt.Torrent + candidateFiles qbt.TorrentFiles + matchType string + rejectReason string + score int + hasRoot bool + fileCount int +} + +type candidateValidationSummary struct { + ready *candidateValidationResult + pending *candidateValidationResult + rejectReason string +} + +type preparedCrossSeedEvaluation struct { + torrentBytes []byte + meta TorrentMetadata + sourceRelease *rls.Release + candidatesResp *FindCandidatesResponse +} + +func shouldPromoteValidationResult(current *candidateValidationResult, next *candidateValidationResult) bool { + if next == nil { + return false + } + if current == nil { + return true + } + if next.score != current.score { + return next.score > current.score + } + if next.hasRoot != current.hasRoot { + return next.hasRoot + } + return next.fileCount > current.fileCount +} + +func (s *Service) validateCandidateTorrent( + sourceRelease *rls.Release, + sourceFiles qbt.TorrentFiles, + torrent qbt.Torrent, + filesByHash map[string]qbt.TorrentFiles, + tolerancePercent float64, +) *candidateValidationResult { + hashKey := normalizeHash(torrent.Hash) + files, ok := filesByHash[hashKey] + if !ok || len(files) == 0 { + return nil + } + + candidateRelease := s.releaseCache.Parse(torrent.Name) + + // Force-on safety guard: if the only available local source is a single episode, + // never treat it as a valid source for a season-pack cross-seed. + if reject, reason := rejectSeasonPackFromEpisode(sourceRelease, candidateRelease, true); reject { + return &candidateValidationResult{rejectReason: reason} + } + + matchResult := s.getMatchTypeWithReason(candidateRelease, sourceRelease, files, sourceFiles, tolerancePercent) + if matchResult.MatchType == "" { + return &candidateValidationResult{rejectReason: matchResult.Reason} + } + + actualMatchType := matchResult.MatchType + switch actualMatchType { + case "partial-in-pack": + actualMatchType = "partial-contains" + case "partial-contains": + actualMatchType = "partial-in-pack" + } + + score := matchTypePriority(actualMatchType) + if score == 0 { + return &candidateValidationResult{rejectReason: matchResult.Reason} + } + + copyTorrent := torrent + return &candidateValidationResult{ + torrent: ©Torrent, + candidateFiles: files, + matchType: actualMatchType, + rejectReason: matchResult.Reason, + score: score, + hasRoot: detectCommonRoot(files) != "", + fileCount: len(files), + } +} + +func (s *Service) summarizeCandidateValidation( + candidate CrossSeedCandidate, + sourceRelease *rls.Release, + sourceFiles qbt.TorrentFiles, + tolerancePercent float64, + filesByHash map[string]qbt.TorrentFiles, +) candidateValidationSummary { + summary := candidateValidationSummary{} + + if len(filesByHash) == 0 { + summary.rejectReason = "No candidate torrents with files to match against" + return summary + } + + for _, torrent := range candidate.Torrents { + result := s.validateCandidateTorrent(sourceRelease, sourceFiles, torrent, filesByHash, tolerancePercent) + if result == nil { + continue + } + if result.torrent == nil { + if result.rejectReason != "" && (summary.rejectReason == "" || len(result.rejectReason) > len(summary.rejectReason)) { + summary.rejectReason = result.rejectReason + } + continue + } + + if result.torrent.Progress >= 1.0 { + if shouldPromoteValidationResult(summary.ready, result) { + summary.ready = result + } + continue + } + + if shouldPromoteValidationResult(summary.pending, result) { + summary.pending = result + } + } + + if summary.ready == nil && summary.pending == nil && summary.rejectReason == "" { + summary.rejectReason = "No matching torrents found with required files" + } + + return summary +} + +func (s *Service) prepareCrossSeedEvaluation( + ctx context.Context, + req *CrossSeedRequest, + includeIncompleteCandidates bool, +) (*preparedCrossSeedEvaluation, error) { + if req == nil { + return nil, errors.New("request is required") + } + if req.TorrentData == "" { + return nil, errors.New("torrent_data is required") + } + + torrentBytes, err := s.decodeTorrentData(req.TorrentData) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrTorrentDataDecode, err) + } + + meta, err := ParseTorrentMetadataWithInfo(torrentBytes) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrTorrentParse, err) + } + + findReq := &FindCandidatesRequest{ + TorrentName: meta.Name, + TargetInstanceIDs: req.TargetInstanceIDs, + FindIndividualEpisodes: req.FindIndividualEpisodes, + IncludeIncompleteCandidates: includeIncompleteCandidates, + SourceFilterCategories: append([]string(nil), req.SourceFilterCategories...), + SourceFilterTags: append([]string(nil), req.SourceFilterTags...), + SourceFilterExcludeCategories: append([]string(nil), + req.SourceFilterExcludeCategories..., + ), + SourceFilterExcludeTags: append([]string(nil), req.SourceFilterExcludeTags...), + } + + candidatesResp, err := s.FindCandidates(ctx, findReq) + if err != nil { + return nil, fmt.Errorf("failed to find candidates: %w", err) + } + + return &preparedCrossSeedEvaluation{ + torrentBytes: torrentBytes, + meta: meta, + sourceRelease: s.releaseCache.Parse(meta.Name), + candidatesResp: candidatesResp, + }, nil +} + // decodeTorrentData decodes base64-encoded torrent data func (s *Service) decodeTorrentData(data string) ([]byte, error) { data = strings.TrimSpace(data) @@ -8851,8 +9011,8 @@ func collectWebhookMatchSamples(matches []WebhookCheckMatch, limit int) []string return samples } -func (s *Service) notifyWebhookCheck(ctx context.Context, req *WebhookCheckRequest, matches []WebhookCheckMatch, recommendation string, startedAt time.Time) { - if s == nil || s.notifier == nil || req == nil || len(matches) == 0 { +func (s *Service) notifyWebhookCheck(ctx context.Context, torrentName string, matches []WebhookCheckMatch, recommendation string, startedAt time.Time) { + if s == nil || s.notifier == nil || len(matches) == 0 { return } @@ -8870,7 +9030,7 @@ func (s *Service) notifyWebhookCheck(ctx context.Context, req *WebhookCheckReque } lines := []string{ - "Torrent: " + strings.TrimSpace(req.TorrentName), + "Torrent: " + strings.TrimSpace(torrentName), fmt.Sprintf("Matches: %d", len(matches)), fmt.Sprintf("Complete matches: %d", completeCount), fmt.Sprintf("Pending matches: %d", pendingCount), @@ -8888,7 +9048,7 @@ func (s *Service) notifyWebhookCheck(ctx context.Context, req *WebhookCheckReque Type: notifications.EventCrossSeedWebhookSucceeded, InstanceName: "Cross-seed webhook", Message: strings.Join(lines, "\n"), - TorrentName: strings.TrimSpace(req.TorrentName), + TorrentName: strings.TrimSpace(torrentName), CrossSeed: ¬ifications.CrossSeedEventData{ Matches: len(matches), Complete: completeCount, @@ -8901,8 +9061,8 @@ func (s *Service) notifyWebhookCheck(ctx context.Context, req *WebhookCheckReque }) } -func (s *Service) notifyWebhookCheckFailure(ctx context.Context, req *WebhookCheckRequest, err error, startedAt time.Time) { - if s == nil || s.notifier == nil || req == nil || err == nil { +func (s *Service) notifyWebhookCheckFailure(ctx context.Context, torrentName string, err error, startedAt time.Time) { + if s == nil || s.notifier == nil || err == nil { return } if errors.Is(err, ErrInvalidWebhookRequest) { @@ -8912,7 +9072,7 @@ func (s *Service) notifyWebhookCheckFailure(ctx context.Context, req *WebhookChe errorMessage := strings.TrimSpace(err.Error()) lines := []string{ - "Torrent: " + strings.TrimSpace(req.TorrentName), + "Torrent: " + strings.TrimSpace(torrentName), "Error: " + err.Error(), } @@ -8921,7 +9081,7 @@ func (s *Service) notifyWebhookCheckFailure(ctx context.Context, req *WebhookChe Type: notifications.EventCrossSeedWebhookFailed, InstanceName: "Cross-seed webhook", Message: strings.Join(lines, "\n"), - TorrentName: strings.TrimSpace(req.TorrentName), + TorrentName: strings.TrimSpace(torrentName), ErrorMessage: errorMessage, ErrorMessages: func() []string { if errorMessage == "" { @@ -9566,8 +9726,8 @@ func validateWebhookCheckRequest(req *WebhookCheckRequest) error { if req == nil { return fmt.Errorf("%w: request is required", ErrInvalidWebhookRequest) } - if req.TorrentName == "" { - return fmt.Errorf("%w: torrentName is required", ErrInvalidWebhookRequest) + if strings.TrimSpace(req.TorrentData) == "" { + return fmt.Errorf("%w: torrentData is required", ErrInvalidWebhookRequest) } if len(req.InstanceIDs) > 0 { for _, id := range req.InstanceIDs { @@ -9628,19 +9788,13 @@ func (s *Service) resolveInstances(ctx context.Context, requested []int) ([]*mod } // CheckWebhook checks if a release announced by autobrr can be cross-seeded with existing torrents. -// This endpoint is designed for autobrr webhook integration where autobrr sends parsed release metadata -// and we check if any existing torrents across our instances match, indicating a cross-seed opportunity. +// It performs the same non-mutating candidate/file validation as cross-seed apply so autobrr gets a final verdict. func (s *Service) CheckWebhook(ctx context.Context, req *WebhookCheckRequest) (*WebhookCheckResponse, error) { startedAt := time.Now().UTC() if err := validateWebhookCheckRequest(req); err != nil { return nil, err } - requestedInstanceIDs := normalizeInstanceIDs(req.InstanceIDs) - - // Parse the incoming release using rls - this extracts all metadata from the torrent name - incomingRelease := s.releaseCache.Parse(req.TorrentName) - // Get automation settings for sizeMismatchTolerancePercent and default matching behavior. settings, err := s.GetAutomationSettings(ctx) if err != nil { @@ -9656,221 +9810,111 @@ func (s *Service) CheckWebhook(ctx context.Context, req *WebhookCheckRequest) (* findIndividualEpisodes = *req.FindIndividualEpisodes } - instances, err := s.resolveInstances(ctx, requestedInstanceIDs) - if err != nil { - s.notifyWebhookCheckFailure(ctx, req, err, startedAt) - return nil, err + requestedInstanceIDs := normalizeInstanceIDs(req.InstanceIDs) + crossReq := &CrossSeedRequest{ + TorrentData: req.TorrentData, + TargetInstanceIDs: requestedInstanceIDs, + FindIndividualEpisodes: findIndividualEpisodes, } + crossReq.SourceFilterCategories = append([]string(nil), settings.WebhookSourceCategories...) + crossReq.SourceFilterTags = append([]string(nil), settings.WebhookSourceTags...) + crossReq.SourceFilterExcludeCategories = append([]string(nil), settings.WebhookSourceExcludeCategories...) + crossReq.SourceFilterExcludeTags = append([]string(nil), settings.WebhookSourceExcludeTags...) - if len(instances) == 0 { - log.Warn(). - Str("source", "cross-seed.webhook"). - Ints("requestedInstanceIds", requestedInstanceIDs). - Msg("Webhook check skipped because no instances were available") - return &WebhookCheckResponse{ - CanCrossSeed: false, - Matches: nil, - Recommendation: "skip", - }, nil + prepared, err := s.prepareCrossSeedEvaluation(ctx, crossReq, true) + if err != nil { + invalidErr := err + switch { + case errors.Is(err, ErrTorrentDataDecode), + errors.Is(err, ErrTorrentParse): + invalidErr = fmt.Errorf("%w: %v", ErrInvalidWebhookRequest, err) + } + torrentName := "" + if prepared != nil { + torrentName = prepared.meta.Name + } + s.notifyWebhookCheckFailure(ctx, torrentName, invalidErr, startedAt) + return nil, invalidErr } - targetInstanceIDs := make([]int, len(instances)) - for i, instance := range instances { - targetInstanceIDs[i] = instance.ID + targetInstanceIDs := make([]int, 0, len(prepared.candidatesResp.Candidates)) + for _, candidate := range prepared.candidatesResp.Candidates { + targetInstanceIDs = append(targetInstanceIDs, candidate.InstanceID) } - // Describe the parsed content type for easier debugging and tuning. - contentInfo := DetermineContentType(incomingRelease) + contentInfo := DetermineContentType(prepared.sourceRelease) + sourceSize := int64(0) + for _, file := range prepared.meta.Files { + sourceSize += file.Size + } log.Debug(). Str("source", "cross-seed.webhook"). Ints("requestedInstanceIds", requestedInstanceIDs). Ints("targetInstanceIds", targetInstanceIDs). Bool("globalScan", len(requestedInstanceIDs) == 0). - Str("torrentName", req.TorrentName). - Uint64("size", req.Size). + Str("torrentName", prepared.meta.Name). + Int64("size", sourceSize). Str("contentType", contentInfo.ContentType). Bool("findIndividualEpisodes", findIndividualEpisodes). - Str("title", incomingRelease.Title). - Int("series", incomingRelease.Series). - Int("episode", incomingRelease.Episode). - Int("year", incomingRelease.Year). - Str("group", incomingRelease.Group). - Str("resolution", incomingRelease.Resolution). - Str("sourceRelease", incomingRelease.Source). - Msg("Webhook check: parsed incoming release") + Str("title", prepared.sourceRelease.Title). + Int("series", prepared.sourceRelease.Series). + Int("episode", prepared.sourceRelease.Episode). + Int("year", prepared.sourceRelease.Year). + Str("group", prepared.sourceRelease.Group). + Str("resolution", prepared.sourceRelease.Resolution). + Str("sourceRelease", prepared.sourceRelease.Source). + Msg("Webhook check: parsed incoming torrent") var ( matches []WebhookCheckMatch hasCompleteMatch bool hasPendingMatch bool ) + tolerancePercent := settings.SizeMismatchTolerancePercent + if tolerancePercent <= 0 { + tolerancePercent = 5.0 + } - // Search each instance for matching torrents - for _, instance := range instances { - // Get all torrents from this instance using cached sync data - torrentsView, err := s.syncManager.GetCachedInstanceTorrents(ctx, instance.ID) - if err != nil { - log.Warn().Err(err).Int("instanceID", instance.ID).Msg("Failed to get torrents from instance") - continue - } - - // Apply webhook source filters if configured - hasWebhookSourceFilters := len(settings.WebhookSourceCategories) > 0 || - len(settings.WebhookSourceTags) > 0 || - len(settings.WebhookSourceExcludeCategories) > 0 || - len(settings.WebhookSourceExcludeTags) > 0 + for _, candidate := range prepared.candidatesResp.Candidates { + filesByHash := s.batchLoadCandidateFiles(ctx, candidate.InstanceID, candidate.Torrents) + summary := s.summarizeCandidateValidation(candidate, prepared.sourceRelease, prepared.meta.Files, tolerancePercent, filesByHash) - // Log webhook filter settings once per instance - log.Debug(). - Str("source", "cross-seed.webhook"). - Int("instanceID", instance.ID). - Strs("includeCategories", settings.WebhookSourceCategories). - Strs("excludeCategories", settings.WebhookSourceExcludeCategories). - Strs("includeTags", settings.WebhookSourceTags). - Strs("excludeTags", settings.WebhookSourceExcludeTags). - Bool("hasFilters", hasWebhookSourceFilters). - Msg("[Webhook] Source filter settings for instance") - - // Track filtering stats if we're logging them - var excludedCategories map[string]int - var includedCategories map[string]int - var filteredCount int - if hasWebhookSourceFilters { - excludedCategories = make(map[string]int) - includedCategories = make(map[string]int) + selected := summary.ready + if selected == nil { + selected = summary.pending } - - log.Debug(). - Str("source", "cross-seed.webhook"). - Int("instanceId", instance.ID). - Str("instanceName", instance.Name). - Int("torrentCount", len(torrentsView)). - Msg("Webhook check: scanning instance torrents for metadata matches") - - // Check each torrent for a match - iterate directly over torrentsView to avoid copying - for _, torrentView := range torrentsView { - // Guard against nil torrentView or nil torrentView.Torrent - if torrentView.Torrent == nil { - continue - } - - torrent := torrentView.Torrent - - // Skip torrents that don't match webhook source filters - if hasWebhookSourceFilters { - if matchesWebhookSourceFilters(torrent, settings) { - filteredCount++ - includedCategories[torrent.Category]++ - } else { - excludedCategories[torrent.Category]++ - continue - } - } - // Parse the existing torrent's release info - existingRelease := s.releaseCache.Parse(torrent.Name) - - // Reject forbidden pairing: season pack (incoming) vs single episode (existing). - if reject, _ := rejectSeasonPackFromEpisode(incomingRelease, existingRelease, findIndividualEpisodes); reject { - continue - } - - // Check if releases match using the configured strict or episode-aware matching. - if !s.releasesMatch(incomingRelease, existingRelease, findIndividualEpisodes) { - continue - } - - // Determine match type - matchType := "metadata" - var sizeDiff float64 - - if req.Size > 0 && torrent.Size > 0 { - // Calculate size difference percentage - if torrent.Size > 0 { - diff := math.Abs(float64(req.Size) - float64(torrent.Size)) - sizeDiff = (diff / float64(torrent.Size)) * 100.0 - } - - // Check if size is within tolerance - if s.isSizeWithinTolerance(int64(req.Size), torrent.Size, settings.SizeMismatchTolerancePercent) { - if sizeDiff < 0.1 { - matchType = "exact" - } else { - matchType = "size" - } - } else { - // Size is outside tolerance, skip this match - log.Debug(). - Str("incomingName", req.TorrentName). - Str("existingName", torrent.Name). - Uint64("incomingSize", req.Size). - Int64("existingSize", torrent.Size). - Float64("sizeDiff", sizeDiff). - Float64("tolerance", settings.SizeMismatchTolerancePercent). - Msg("Skipping match due to size mismatch") - continue - } - } - - matchScore, matchReasons := evaluateReleaseMatch(incomingRelease, existingRelease) - + if selected == nil || selected.torrent == nil { log.Debug(). Str("source", "cross-seed.webhook"). - Int("instanceId", instance.ID). - Str("instanceName", instance.Name). - Str("incomingName", req.TorrentName). - Str("incomingTitle", incomingRelease.Title). - Str("existingName", torrent.Name). - Str("existingTitle", existingRelease.Title). - Str("matchType", matchType). - Float64("sizeDiff", sizeDiff). - Float64("matchScore", matchScore). - Str("matchReasons", matchReasons). - Msg("Webhook cross-seed: matched existing torrent") - - // TODO: Consider adding a configuration flag to control whether webhook-based - // cross-seed checks require fully completed torrents or can also treat - // in-progress downloads as matches. This would likely be exposed via the - // "Global Cross-Seed Settings" block in CrossSeedPage.tsx so users can tune - // webhook behavior for their setup. - - matches = append(matches, WebhookCheckMatch{ - InstanceID: instance.ID, - InstanceName: instance.Name, - TorrentHash: torrent.Hash, - TorrentName: torrent.Name, - MatchType: matchType, - SizeDiff: sizeDiff, - Progress: torrent.Progress, - }) + Int("instanceID", candidate.InstanceID). + Str("instanceName", candidate.InstanceName). + Str("torrentName", prepared.meta.Name). + Str("reason", summary.rejectReason). + Msg("Webhook check: candidate rejected after file-level validation") + continue + } - if torrent.Progress >= 1.0 { - hasCompleteMatch = true - } else { - hasPendingMatch = true - } + sizeDiff := 0.0 + if sourceSize > 0 && selected.torrent.Size > 0 { + diff := math.Abs(float64(sourceSize) - float64(selected.torrent.Size)) + sizeDiff = (diff / float64(sourceSize)) * 100.0 } - // Log filter results if we tracked them - if hasWebhookSourceFilters && (len(excludedCategories) > 0 || len(includedCategories) > 0) { - excludedSummary := make([]string, 0, len(excludedCategories)) - for cat, count := range excludedCategories { - excludedSummary = append(excludedSummary, fmt.Sprintf("%s(%d)", cat, count)) - } - includedSummary := make([]string, 0, len(includedCategories)) - for cat, count := range includedCategories { - includedSummary = append(includedSummary, fmt.Sprintf("%s(%d)", cat, count)) - } + matches = append(matches, WebhookCheckMatch{ + InstanceID: candidate.InstanceID, + InstanceName: candidate.InstanceName, + TorrentHash: selected.torrent.Hash, + TorrentName: selected.torrent.Name, + MatchType: selected.matchType, + SizeDiff: sizeDiff, + Progress: selected.torrent.Progress, + }) - log.Debug(). - Str("source", "cross-seed.webhook"). - Int("instanceID", instance.ID). - Str("instanceName", instance.Name). - Int("original", len(torrentsView)). - Int("filtered", filteredCount). - Strs("excludedCategories", excludedSummary). - Strs("includedCategories", includedSummary). - Msg("[Webhook] Source filter results") + if summary.ready != nil { + hasCompleteMatch = true + } else { + hasPendingMatch = true } } @@ -9885,13 +9929,13 @@ func (s *Service) CheckWebhook(ctx context.Context, req *WebhookCheckRequest) (* Str("source", "cross-seed.webhook"). Ints("requestedInstanceIds", requestedInstanceIDs). Ints("targetInstanceIds", targetInstanceIDs). - Str("torrentName", req.TorrentName). + Str("torrentName", prepared.meta.Name). Int("matchCount", len(matches)). Bool("canCrossSeed", canCrossSeed). Str("recommendation", recommendation). Msg("Webhook check completed") - s.notifyWebhookCheck(ctx, req, matches, recommendation, startedAt) + s.notifyWebhookCheck(ctx, prepared.meta.Name, matches, recommendation, startedAt) return &WebhookCheckResponse{ CanCrossSeed: canCrossSeed, diff --git a/internal/services/crossseed/webhook_check_test.go b/internal/services/crossseed/webhook_check_test.go new file mode 100644 index 000000000..b61013d55 --- /dev/null +++ b/internal/services/crossseed/webhook_check_test.go @@ -0,0 +1,279 @@ +// Copyright (c) 2025-2026, s0up and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package crossseed + +import ( + "context" + "encoding/base64" + "testing" + + qbt "github.com/autobrr/go-qbittorrent" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/autobrr/qui/internal/models" + "github.com/autobrr/qui/internal/services/notifications" + "github.com/autobrr/qui/pkg/stringutils" +) + +func makeTorrentDataRequest(t *testing.T, torrentName string, files []string) (*WebhookCheckRequest, TorrentMetadata) { + t.Helper() + + torrentData := createTestTorrent(t, torrentName, files, 256*1024) + meta, err := ParseTorrentMetadataWithInfo(torrentData) + require.NoError(t, err) + + return &WebhookCheckRequest{ + TorrentData: base64.StdEncoding.EncodeToString(torrentData), + }, meta +} + +func TestCheckWebhook_FinalAnswerStatuses(t *testing.T) { + t.Parallel() + + instance := &models.Instance{ID: 1, Name: "Test Instance"} + store := &fakeInstanceStore{ + instances: map[int]*models.Instance{ + instance.ID: instance, + }, + } + + tests := []struct { + name string + progress float64 + candidateFiles qbt.TorrentFiles + wantCanCrossSeed bool + wantMatchCount int + wantRecommendation string + }{ + { + name: "complete validated match returns ready", + progress: 1.0, + wantCanCrossSeed: true, + wantMatchCount: 1, + wantRecommendation: "download", + }, + { + name: "pending validated match returns retryable result", + progress: 0.5, + wantCanCrossSeed: false, + wantMatchCount: 1, + wantRecommendation: "download", + }, + { + name: "metadata hit that fails file validation returns skip", + progress: 1.0, + candidateFiles: qbt.TorrentFiles{{Name: "Webhook.Final.Answer.2025.1080p.BluRay.x264-GROUP.mkv", Size: 1}}, + wantCanCrossSeed: false, + wantMatchCount: 0, + wantRecommendation: "skip", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req, meta := makeTorrentDataRequest(t, "Webhook.Final.Answer.2025.1080p.BluRay.x264-GROUP", []string{"Webhook.Final.Answer.2025.1080p.BluRay.x264-GROUP.mkv"}) + req.InstanceIDs = []int{instance.ID} + + candidateFiles := tt.candidateFiles + if len(candidateFiles) == 0 { + candidateFiles = meta.Files + } + + torrents := []qbt.Torrent{{ + Hash: "candidate", + Name: meta.Name, + Progress: tt.progress, + Size: meta.Files[0].Size, + }} + + service := &Service{ + instanceStore: store, + syncManager: newFakeSyncManager(instance, torrents, map[string]qbt.TorrentFiles{"candidate": candidateFiles}), + releaseCache: NewReleaseCache(), + stringNormalizer: stringutils.NewDefaultNormalizer(), + } + + resp, err := service.CheckWebhook(context.Background(), req) + require.NoError(t, err) + require.NotNil(t, resp) + + assert.Equal(t, tt.wantCanCrossSeed, resp.CanCrossSeed) + assert.Equal(t, tt.wantMatchCount, len(resp.Matches)) + assert.Equal(t, tt.wantRecommendation, resp.Recommendation) + if tt.wantMatchCount == 1 && len(resp.Matches) == 1 { + assert.Equal(t, "exact", resp.Matches[0].MatchType) + } + }) + } +} + +func TestCheckWebhook_InvalidTorrentPayload(t *testing.T) { + t.Parallel() + + service := &Service{} + + tests := []struct { + name string + request *WebhookCheckRequest + errText string + }{ + { + name: "missing torrent data", + request: &WebhookCheckRequest{ + InstanceIDs: []int{1}, + }, + errText: "torrentData is required", + }, + { + name: "invalid base64", + request: &WebhookCheckRequest{ + TorrentData: "not-base64", + }, + errText: "invalid webhook request", + }, + { + name: "invalid torrent bytes", + request: &WebhookCheckRequest{ + TorrentData: base64.StdEncoding.EncodeToString([]byte("not a torrent")), + }, + errText: "invalid webhook request", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp, err := service.CheckWebhook(context.Background(), tt.request) + require.Error(t, err) + require.Nil(t, resp) + assert.ErrorIs(t, err, ErrInvalidWebhookRequest) + assert.Contains(t, err.Error(), tt.errText) + }) + } +} + +func TestCheckWebhook_FinalAnswerNotificationRequiresCompleteMatch(t *testing.T) { + t.Parallel() + + instance := &models.Instance{ID: 1, Name: "Test Instance"} + store := &fakeInstanceStore{ + instances: map[int]*models.Instance{ + instance.ID: instance, + }, + } + + for _, progress := range []float64{0.5, 1.0} { + name := "pending" + if progress >= 1.0 { + name = "complete" + } + t.Run(name, func(t *testing.T) { + req, meta := makeTorrentDataRequest(t, "Notify.Test.2025.1080p.BluRay.x264-GRP", []string{"Notify.Test.2025.1080p.BluRay.x264-GRP.mkv"}) + req.InstanceIDs = []int{instance.ID} + + notifier := &recordingNotifier{} + service := &Service{ + instanceStore: store, + syncManager: newFakeSyncManager(instance, []qbt.Torrent{{Hash: "notify", Name: meta.Name, Progress: progress, Size: meta.Files[0].Size}}, map[string]qbt.TorrentFiles{"notify": meta.Files}), + releaseCache: NewReleaseCache(), + stringNormalizer: stringutils.NewDefaultNormalizer(), + notifier: notifier, + } + + resp, err := service.CheckWebhook(context.Background(), req) + require.NoError(t, err) + require.NotNil(t, resp) + + events := notifier.Events() + if progress >= 1.0 { + require.Len(t, events, 1) + assert.Equal(t, notifications.EventCrossSeedWebhookSucceeded, events[0].Type) + } else { + assert.Empty(t, events) + } + }) + } +} + +func TestCheckWebhook_FinalAnswerMultiInstanceScan(t *testing.T) { + t.Parallel() + + instanceA := &models.Instance{ID: 1, Name: "A"} + instanceB := &models.Instance{ID: 2, Name: "B"} + req, meta := makeTorrentDataRequest(t, "Popular.Movie.2025.1080p.BluRay.x264-GRP", []string{"Popular.Movie.2025.1080p.BluRay.x264-GRP.mkv"}) + + store := &fakeInstanceStore{ + instances: map[int]*models.Instance{ + instanceA.ID: instanceA, + instanceB.ID: instanceB, + }, + } + + sync := &fakeSyncManager{ + all: map[int][]qbt.Torrent{ + instanceA.ID: { + {Hash: "complete", Name: meta.Name, Size: meta.Files[0].Size, Progress: 1.0}, + }, + instanceB.ID: { + {Hash: "pending", Name: meta.Name, Size: meta.Files[0].Size, Progress: 0.6}, + }, + }, + files: map[string]qbt.TorrentFiles{ + normalizeHash("complete"): meta.Files, + normalizeHash("pending"): meta.Files, + }, + } + + service := &Service{ + instanceStore: store, + syncManager: sync, + releaseCache: NewReleaseCache(), + stringNormalizer: stringutils.NewDefaultNormalizer(), + } + + resp, err := service.CheckWebhook(context.Background(), req) + require.NoError(t, err) + require.NotNil(t, resp) + assert.True(t, resp.CanCrossSeed) + assert.Len(t, resp.Matches, 2) + assert.Equal(t, "download", resp.Recommendation) +} + +func TestCheckWebhook_FinalAnswerSourceFilters(t *testing.T) { + t.Parallel() + + instance := &models.Instance{ID: 1, Name: "Test Instance"} + req, meta := makeTorrentDataRequest(t, "Filter.Test.2025.1080p.BluRay.x264-GRP", []string{"Filter.Test.2025.1080p.BluRay.x264-GRP.mkv"}) + req.InstanceIDs = []int{instance.ID} + + service := &Service{ + instanceStore: &fakeInstanceStore{ + instances: map[int]*models.Instance{ + instance.ID: instance, + }, + }, + syncManager: newFakeSyncManager(instance, []qbt.Torrent{ + {Hash: "excluded", Name: meta.Name, Category: "cross-seed-link", Progress: 1.0, Size: meta.Files[0].Size}, + {Hash: "included", Name: meta.Name, Category: "movies", Progress: 1.0, Size: meta.Files[0].Size}, + }, map[string]qbt.TorrentFiles{ + "excluded": meta.Files, + "included": meta.Files, + }), + releaseCache: NewReleaseCache(), + stringNormalizer: stringutils.NewDefaultNormalizer(), + automationSettingsLoader: func(_ context.Context) (*models.CrossSeedAutomationSettings, error) { + return &models.CrossSeedAutomationSettings{ + WebhookSourceExcludeCategories: []string{"cross-seed-link"}, + SizeMismatchTolerancePercent: 5.0, + }, nil + }, + } + + resp, err := service.CheckWebhook(context.Background(), req) + require.NoError(t, err) + require.NotNil(t, resp) + assert.True(t, resp.CanCrossSeed) + assert.Len(t, resp.Matches, 1) + assert.Equal(t, "included", resp.Matches[0].TorrentHash) +} diff --git a/internal/web/swagger/openapi.yaml b/internal/web/swagger/openapi.yaml index 2abafd62f..ea477810d 100644 --- a/internal/web/swagger/openapi.yaml +++ b/internal/web/swagger/openapi.yaml @@ -3841,10 +3841,10 @@ paths: - Cross-Seed summary: Check if a release can be cross-seeded (autobrr webhook) description: | - Accepts release metadata from autobrr and checks if matching torrents exist on the requested instances (or all instances when no list is provided). The HTTP status describes whether the match is ready: - * `200 OK` – at least one matching torrent is fully downloaded and ready for cross-seeding - * `202 Accepted` – matching torrents exist but the data is still downloading; retry `/check` until it returns `200` (ready) or `404` - * `404 Not Found` – no matches exist (recommendation `skip`) + Accepts a base64-encoded torrent file from autobrr and performs the full non-mutating cross-seed validation flow on the requested instances (or all instances when no list is provided). The HTTP status describes the final verdict: + * `200 OK` – at least one matching local torrent passed file-level validation and is fully downloaded, so the release is ready for cross-seeding + * `202 Accepted` – at least one matching local torrent passed file-level validation, but all valid local sources are still downloading; retry `/check` until it returns `200` or `404` + * `404 Not Found` – no fully validated cross-seed matches exist (recommendation `skip`) This endpoint is designed for autobrr filter external webhooks. When `instanceIds` is omitted or empty, qui will search every configured instance. Provide a subset of IDs to restrict the scan. parameters: - name: apikey @@ -3860,40 +3860,35 @@ paths: schema: type: object required: - - torrentName + - torrentData properties: - torrentName: + torrentData: type: string - description: Release name as announced (parsed using rls library to extract metadata) - example: "That.Movie.2025.1080p.BluRay.x264-GROUP" + description: Base64-encoded torrent file bytes from autobrr (for example, using the TorrentDataRawBytes macro piped through toString|b64enc or toJson) instanceIds: type: array items: type: integer description: Optional list of qBittorrent instance IDs to consider. When omitted or empty, qui searches all configured instances. example: [1, 2, 3] - size: - type: integer - format: uint64 - description: Total torrent size in bytes (optional - enables size validation when provided) findIndividualEpisodes: type: boolean description: Optional override for matching season packs vs episodes. Defaults to the Cross-Seed automation setting when omitted. responses: '200': - description: Webhook check completed successfully with one or more matches + description: Webhook check completed successfully with one or more fully validated ready matches content: application/json: schema: $ref: '#/components/schemas/CrossSeedWebhookCheckResponse' '202': - description: Matches exist but torrents are still downloading (retry until 200 OK). The body mirrors the 200 response to show pending matches. + description: Fully validated matches exist, but the matching local torrent data is still downloading (retry until 200 OK or 404). content: application/json: schema: $ref: '#/components/schemas/CrossSeedWebhookCheckResponse' '404': - description: No cross-seed matches found across the targeted instances, or no instances were available (empty matches array and canCrossSeed=false with recommendation="skip") + description: No fully validated cross-seed matches found across the targeted instances (empty matches array and canCrossSeed=false with recommendation="skip") content: application/json: schema: From 39dcf54197bf9c18f3a37b321fa5c015154cf3ff Mon Sep 17 00:00:00 2001 From: Audionut Date: Sat, 14 Mar 2026 23:55:57 +1000 Subject: [PATCH 2/4] linting --- internal/api/handlers/crossseed_webhook_handler_test.go | 2 +- internal/services/crossseed/service.go | 6 +++--- internal/services/crossseed/webhook_check_test.go | 9 +++++---- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/internal/api/handlers/crossseed_webhook_handler_test.go b/internal/api/handlers/crossseed_webhook_handler_test.go index 08bc59e3d..378d33aad 100644 --- a/internal/api/handlers/crossseed_webhook_handler_test.go +++ b/internal/api/handlers/crossseed_webhook_handler_test.go @@ -47,7 +47,7 @@ func TestWebhookCheckHandler_BadRequestPaths(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - req := httptest.NewRequest(http.MethodPost, "/api/cross-seed/webhook/check", bytes.NewBufferString(tt.body)) + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/cross-seed/webhook/check", bytes.NewBufferString(tt.body)) rec := httptest.NewRecorder() handler.WebhookCheck(rec, req) diff --git a/internal/services/crossseed/service.go b/internal/services/crossseed/service.go index c4459ebbc..6c991a432 100644 --- a/internal/services/crossseed/service.go +++ b/internal/services/crossseed/service.go @@ -4938,12 +4938,12 @@ func (s *Service) prepareCrossSeedEvaluation( torrentBytes, err := s.decodeTorrentData(req.TorrentData) if err != nil { - return nil, fmt.Errorf("%w: %v", ErrTorrentDataDecode, err) + return nil, fmt.Errorf("%w: %w", ErrTorrentDataDecode, err) } meta, err := ParseTorrentMetadataWithInfo(torrentBytes) if err != nil { - return nil, fmt.Errorf("%w: %v", ErrTorrentParse, err) + return nil, fmt.Errorf("%w: %w", ErrTorrentParse, err) } findReq := &FindCandidatesRequest{ @@ -9827,7 +9827,7 @@ func (s *Service) CheckWebhook(ctx context.Context, req *WebhookCheckRequest) (* switch { case errors.Is(err, ErrTorrentDataDecode), errors.Is(err, ErrTorrentParse): - invalidErr = fmt.Errorf("%w: %v", ErrInvalidWebhookRequest, err) + invalidErr = fmt.Errorf("%w: %w", ErrInvalidWebhookRequest, err) } torrentName := "" if prepared != nil { diff --git a/internal/services/crossseed/webhook_check_test.go b/internal/services/crossseed/webhook_check_test.go index b61013d55..61548c95b 100644 --- a/internal/services/crossseed/webhook_check_test.go +++ b/internal/services/crossseed/webhook_check_test.go @@ -100,9 +100,10 @@ func TestCheckWebhook_FinalAnswerStatuses(t *testing.T) { require.NotNil(t, resp) assert.Equal(t, tt.wantCanCrossSeed, resp.CanCrossSeed) - assert.Equal(t, tt.wantMatchCount, len(resp.Matches)) + assert.Len(t, resp.Matches, tt.wantMatchCount) assert.Equal(t, tt.wantRecommendation, resp.Recommendation) - if tt.wantMatchCount == 1 && len(resp.Matches) == 1 { + if tt.wantMatchCount == 1 { + require.Len(t, resp.Matches, 1) assert.Equal(t, "exact", resp.Matches[0].MatchType) } }) @@ -147,8 +148,8 @@ func TestCheckWebhook_InvalidTorrentPayload(t *testing.T) { resp, err := service.CheckWebhook(context.Background(), tt.request) require.Error(t, err) require.Nil(t, resp) - assert.ErrorIs(t, err, ErrInvalidWebhookRequest) - assert.Contains(t, err.Error(), tt.errText) + require.ErrorIs(t, err, ErrInvalidWebhookRequest) + require.ErrorContains(t, err, tt.errText) }) } } From c0d8659ae92c5673710672ac2dc9b8ef12d8bcfb Mon Sep 17 00:00:00 2001 From: Audionut Date: Sun, 15 Mar 2026 00:02:32 +1000 Subject: [PATCH 3/4] fix(crossseed): narrow incomplete candidate recovery refetch --- internal/services/crossseed/crossseed_test.go | 113 ++++++++++++++++-- internal/services/crossseed/service.go | 51 ++++++-- 2 files changed, 143 insertions(+), 21 deletions(-) diff --git a/internal/services/crossseed/crossseed_test.go b/internal/services/crossseed/crossseed_test.go index 89b22e8f5..2738b3e58 100644 --- a/internal/services/crossseed/crossseed_test.go +++ b/internal/services/crossseed/crossseed_test.go @@ -3231,16 +3231,18 @@ func TestDetermineSavePathContentLayoutScenarios(t *testing.T) { // mockRecoverSyncManager simulates torrent state changes during recheck operations type mockRecoverSyncManager struct { torrents map[string]*qbt.Torrent // hash -> torrent - calls []string // track method calls for verification - recheckCompletes bool // whether recheck should complete torrents - disappearAfterRecheck bool // whether torrent disappears after recheck - bulkActionFails bool // whether BulkAction should fail - keepInCheckingState bool // whether to keep torrent in checking state - failGetTorrentsAfterRecheck bool // whether GetTorrents should fail after recheck - setProgressToThreshold bool // whether to set progress exactly at threshold - hasRechecked bool // track if recheck has been called - secondRecheckCompletes bool // whether second recheck should complete torrents - recheckCount int // count of recheck calls + files map[string]qbt.TorrentFiles + calls []string // track method calls for verification + filters []qbt.TorrentFilter + recheckCompletes bool // whether recheck should complete torrents + disappearAfterRecheck bool // whether torrent disappears after recheck + bulkActionFails bool // whether BulkAction should fail + keepInCheckingState bool // whether to keep torrent in checking state + failGetTorrentsAfterRecheck bool // whether GetTorrents should fail after recheck + setProgressToThreshold bool // whether to set progress exactly at threshold + hasRechecked bool // track if recheck has been called + secondRecheckCompletes bool // whether second recheck should complete torrents + recheckCount int // count of recheck calls } func newMockRecoverSyncManager(initialTorrents []qbt.Torrent) *mockRecoverSyncManager { @@ -3251,7 +3253,9 @@ func newMockRecoverSyncManager(initialTorrents []qbt.Torrent) *mockRecoverSyncMa } return &mockRecoverSyncManager{ torrents: torrents, + files: map[string]qbt.TorrentFiles{}, calls: []string{}, + filters: []qbt.TorrentFilter{}, recheckCompletes: true, // default to completing disappearAfterRecheck: false, bulkActionFails: false, @@ -3266,6 +3270,7 @@ func newMockRecoverSyncManager(initialTorrents []qbt.Torrent) *mockRecoverSyncMa func (m *mockRecoverSyncManager) GetTorrents(_ context.Context, instanceID int, filter qbt.TorrentFilterOptions) ([]qbt.Torrent, error) { m.calls = append(m.calls, "GetTorrents") + m.filters = append(m.filters, filter.Filter) if m.failGetTorrentsAfterRecheck && m.hasRechecked { // Return empty list to simulate torrent disappearing @@ -3281,7 +3286,23 @@ func (m *mockRecoverSyncManager) GetTorrents(_ context.Context, instanceID int, } } else { for _, torrent := range m.torrents { - result = append(result, *torrent) + if filter.Filter == "" || filter.Filter == qbt.TorrentFilterAll { + result = append(result, *torrent) + continue + } + if filter.Filter == qbt.TorrentFilterCompleted { + if torrent.Progress >= 1.0 { + result = append(result, *torrent) + } + continue + } + if filter.Filter == qbt.TorrentFilterDownloading { + if torrent.Progress < 1.0 && + torrent.State != qbt.TorrentStateError && + torrent.State != qbt.TorrentStateMissingFiles { + result = append(result, *torrent) + } + } } } return result, nil @@ -3346,8 +3367,22 @@ func (m *mockRecoverSyncManager) simulateRecheckComplete(hash string, finalProgr } } -func (m *mockRecoverSyncManager) GetTorrentFilesBatch(context.Context, int, []string) (map[string]qbt.TorrentFiles, error) { - return nil, fmt.Errorf("not implemented") +func (m *mockRecoverSyncManager) GetTorrentFilesBatch(_ context.Context, _ int, hashes []string) (map[string]qbt.TorrentFiles, error) { + if len(m.files) == 0 { + return nil, errors.New("not implemented") + } + + result := make(map[string]qbt.TorrentFiles, len(hashes)) + for _, hash := range hashes { + files, ok := m.files[normalizeHash(hash)] + if !ok { + continue + } + copyFiles := make(qbt.TorrentFiles, len(files)) + copy(copyFiles, files) + result[normalizeHash(hash)] = copyFiles + } + return result, nil } func (m *mockRecoverSyncManager) HasTorrentByAnyHash(context.Context, int, []string) (*qbt.Torrent, bool, error) { @@ -3604,6 +3639,58 @@ func TestRecoverErroredTorrents_EmptyList(t *testing.T) { assert.Empty(t, mockSync.calls) } +func TestFindCandidates_IncludeIncompleteCandidatesRefetchesWithoutErroredStates(t *testing.T) { + t.Parallel() + + instance := &models.Instance{ID: 1, Name: "Test Instance"} + req := &FindCandidatesRequest{ + TorrentName: "Movie.2025.1080p.BluRay.x264-GROUP", + TargetInstanceIDs: []int{instance.ID}, + IncludeIncompleteCandidates: true, + } + + mockSync := newMockRecoverSyncManager([]qbt.Torrent{ + {Hash: "complete", Name: req.TorrentName, State: qbt.TorrentStatePausedUp, Progress: 1.0, Size: 1}, + {Hash: "pending", Name: req.TorrentName, State: qbt.TorrentStateDownloading, Progress: 0.5, Size: 1}, + {Hash: "errored", Name: req.TorrentName, State: qbt.TorrentStateError, Progress: 0.4, Size: 1}, + {Hash: "missing", Name: req.TorrentName, State: qbt.TorrentStateMissingFiles, Progress: 0.4, Size: 1}, + }) + mockSync.recheckCompletes = false + mockSync.files = map[string]qbt.TorrentFiles{ + normalizeHash("complete"): {{Name: "Movie.2025.1080p.BluRay.x264-GROUP.mkv", Size: 1}}, + normalizeHash("pending"): {{Name: "Movie.2025.1080p.BluRay.x264-GROUP.mkv", Size: 1}}, + } + + svc := &Service{ + instanceStore: &fakeInstanceStore{ + instances: map[int]*models.Instance{ + instance.ID: instance, + }, + }, + syncManager: mockSync, + recoverErroredTorrentsEnabled: true, + releaseCache: NewReleaseCache(), + stringNormalizer: stringutils.NewDefaultNormalizer(), + } + + resp, err := svc.FindCandidates(context.Background(), req) + require.NoError(t, err) + require.Len(t, resp.Candidates, 1) + require.Len(t, resp.Candidates[0].Torrents, 2) + + hashes := []string{ + resp.Candidates[0].Torrents[0].Hash, + resp.Candidates[0].Torrents[1].Hash, + } + assert.ElementsMatch(t, []string{"complete", "pending"}, hashes) + require.GreaterOrEqual(t, len(mockSync.filters), 3) + assert.Equal(t, qbt.TorrentFilterAll, mockSync.filters[0]) + assert.Equal(t, []qbt.TorrentFilter{ + qbt.TorrentFilterCompleted, + qbt.TorrentFilterDownloading, + }, mockSync.filters[len(mockSync.filters)-2:]) +} + func TestExtractTorrentURLForCommentMatch(t *testing.T) { tests := []struct { name string diff --git a/internal/services/crossseed/service.go b/internal/services/crossseed/service.go index 6c991a432..d719f2a99 100644 --- a/internal/services/crossseed/service.go +++ b/internal/services/crossseed/service.go @@ -3116,21 +3116,56 @@ func (s *Service) findCandidates(ctx context.Context, req *FindCandidatesRequest log.Warn().Err(recoverErr).Int("instanceID", instanceID).Msg("Failed to recover errored torrents") } - filter := qbt.TorrentFilterCompleted - if req.IncludeIncompleteCandidates { - filter = qbt.TorrentFilterAll - } - - // Re-fetch torrents after recovery to get updated states - torrents, err = s.syncManager.GetTorrents(ctx, instanceID, qbt.TorrentFilterOptions{Filter: filter}) + // Re-fetch torrents after recovery to get updated states. + torrents, err = s.syncManager.GetTorrents(ctx, instanceID, qbt.TorrentFilterOptions{Filter: qbt.TorrentFilterCompleted}) if err != nil { log.Warn(). Int("instanceID", instanceID). Str("instanceName", instance.Name). Err(err). - Msg("Failed to re-get torrents from instance after recovery, skipping") + Msg("Failed to re-get completed torrents from instance after recovery, skipping") continue } + + if req.IncludeIncompleteCandidates { + incompleteTorrents, incompleteErr := s.syncManager.GetTorrents(ctx, instanceID, qbt.TorrentFilterOptions{ + Filter: qbt.TorrentFilterDownloading, + }) + if incompleteErr != nil { + log.Warn(). + Int("instanceID", instanceID). + Str("instanceName", instance.Name). + Err(incompleteErr). + Msg("Failed to re-get incomplete torrents from instance after recovery, skipping") + continue + } + + seenHashes := make(map[string]struct{}, len(torrents)+len(incompleteTorrents)) + merged := make([]qbt.Torrent, 0, len(torrents)+len(incompleteTorrents)) + for _, torrent := range torrents { + hashKey := normalizeHash(torrent.Hash) + if hashKey == "" { + continue + } + if _, exists := seenHashes[hashKey]; exists { + continue + } + seenHashes[hashKey] = struct{}{} + merged = append(merged, torrent) + } + for _, torrent := range incompleteTorrents { + hashKey := normalizeHash(torrent.Hash) + if hashKey == "" { + continue + } + if _, exists := seenHashes[hashKey]; exists { + continue + } + seenHashes[hashKey] = struct{}{} + merged = append(merged, torrent) + } + torrents = merged + } } } From fd17a21dca9c4655ba4b79377dc849345c18503f Mon Sep 17 00:00:00 2001 From: Audionut Date: Sun, 15 Mar 2026 00:46:37 +1000 Subject: [PATCH 4/4] fix(crossseed): align webhook checks with apply preflight --- internal/services/crossseed/crossseed_test.go | 12 +- internal/services/crossseed/service.go | 597 ++++++++++++------ .../services/crossseed/webhook_check_test.go | 135 ++++ 3 files changed, 538 insertions(+), 206 deletions(-) diff --git a/internal/services/crossseed/crossseed_test.go b/internal/services/crossseed/crossseed_test.go index 2738b3e58..4351352ba 100644 --- a/internal/services/crossseed/crossseed_test.go +++ b/internal/services/crossseed/crossseed_test.go @@ -2698,6 +2698,7 @@ type fakeSyncManager struct { cached map[int][]internalqb.CrossInstanceTorrentView all map[int][]qbt.Torrent files map[string]qbt.TorrentFiles + props map[string]*qbt.TorrentProperties } func buildCrossInstanceViews(instance *models.Instance, torrents []qbt.Torrent) []internalqb.CrossInstanceTorrentView { @@ -2735,6 +2736,7 @@ func newFakeSyncManager(instance *models.Instance, torrents []qbt.Torrent, files cached: cached, all: all, files: normalizedFiles, + props: map[string]*qbt.TorrentProperties{}, } } @@ -2794,8 +2796,14 @@ func (f *fakeSyncManager) HasTorrentByAnyHash(_ context.Context, instanceID int, return nil, false, nil } -func (f *fakeSyncManager) GetTorrentProperties(_ context.Context, _ int, _ string) (*qbt.TorrentProperties, error) { - return nil, fmt.Errorf("GetTorrentProperties not implemented in fakeSyncManager") +func (f *fakeSyncManager) GetTorrentProperties(_ context.Context, _ int, hash string) (*qbt.TorrentProperties, error) { + if f.props != nil { + if props, ok := f.props[normalizeHash(hash)]; ok && props != nil { + copyProps := *props + return ©Props, nil + } + } + return &qbt.TorrentProperties{SavePath: "/downloads"}, nil } func (f *fakeSyncManager) GetAppPreferences(_ context.Context, _ int) (qbt.AppPreferences, error) { diff --git a/internal/services/crossseed/service.go b/internal/services/crossseed/service.go index d719f2a99..07641aadc 100644 --- a/internal/services/crossseed/service.go +++ b/internal/services/crossseed/service.go @@ -3136,8 +3136,8 @@ func (s *Service) findCandidates(ctx context.Context, req *FindCandidatesRequest Int("instanceID", instanceID). Str("instanceName", instance.Name). Err(incompleteErr). - Msg("Failed to re-get incomplete torrents from instance after recovery, skipping") - continue + Msg("Failed to re-get incomplete torrents from instance after recovery; proceeding with completed torrents only") + incompleteTorrents = nil } seenHashes := make(map[string]struct{}, len(torrents)+len(incompleteTorrents)) @@ -3547,95 +3547,66 @@ func (s *Service) processCrossSeedCandidate( Status: "error", } - // Check if torrent already exists - hashes := make([]string, 0, 2) - seenHashes := make(map[string]struct{}, 2) - for _, hash := range []string{torrentHash, torrentHashV2} { - trimmed := strings.TrimSpace(hash) - if trimmed == "" { - continue - } - canonical := normalizeHash(trimmed) - if _, ok := seenHashes[canonical]; ok { - continue - } - seenHashes[canonical] = struct{}{} - hashes = append(hashes, trimmed) - } - - if s.blocklistStore != nil { - blockedHash, blocked, err := s.blocklistStore.FindBlocked(ctx, candidate.InstanceID, hashes) - if err != nil { - result.Message = fmt.Sprintf("Failed to check cross-seed blocklist: %v", err) - return result - } - if blocked { - result.Status = "blocked" - result.Message = "Blocked by cross-seed blocklist" - log.Info(). - Int("instanceID", candidate.InstanceID). - Str("instanceName", candidate.InstanceName). - Str("torrentHash", torrentHash). - Str("blockedHash", blockedHash). - Msg("Cross-seed apply skipped: infohash is blocked") - return result - } - } - - existingTorrent, exists, err := s.syncManager.HasTorrentByAnyHash(ctx, candidate.InstanceID, hashes) - if err != nil { - result.Message = fmt.Sprintf("Failed to check existing torrents: %v", err) - return result - } - - if exists && existingTorrent != nil { - result.Success = false - result.Status = "exists" - result.Message = "Torrent already exists in this instance" - result.MatchedTorrent = &MatchedTorrent{ - Hash: existingTorrent.Hash, - Name: existingTorrent.Name, - Progress: existingTorrent.Progress, - Size: existingTorrent.Size, - } - - log.Debug(). - Int("instanceID", candidate.InstanceID). - Str("instanceName", candidate.InstanceName). - Str("torrentHash", torrentHash). - Str("existingHash", existingTorrent.Hash). - Str("existingName", existingTorrent.Name). - Msg("Cross-seed apply skipped: torrent already exists in instance") - - return result - } - candidateFilesByHash := s.batchLoadCandidateFiles(ctx, candidate.InstanceID, candidate.Torrents) tolerancePercent := req.SizeMismatchTolerancePercent if tolerancePercent <= 0 { tolerancePercent = 5.0 // Default to 5% tolerance } matchedTorrent, candidateFiles, matchType, rejectReason := s.findBestCandidateMatch(ctx, candidate, sourceRelease, sourceFiles, candidateFilesByHash, tolerancePercent) - if matchedTorrent == nil { - result.Status = "no_match" - result.Message = rejectReason + var validation *candidateValidationResult + if matchedTorrent != nil { + validation = &candidateValidationResult{ + torrent: matchedTorrent, + candidateFiles: candidateFiles, + matchType: matchType, + rejectReason: rejectReason, + } + } + + preflight, err := s.preflightCandidateChecks( + ctx, + candidate, + validation, + rejectReason, + torrentHash, + torrentHashV2, + torrentName, + req, + sourceRelease, + sourceFiles, + torrentInfo, + nil, + ) + if err != nil { + result.Message = err.Error() + return result + } + if preflight.status != candidatePreflightStatusEligible { + result.Status = string(preflight.status) + result.Message = preflight.reason + if preflight.existingTorrent != nil { + result.MatchedTorrent = &MatchedTorrent{ + Hash: preflight.existingTorrent.Hash, + Name: preflight.existingTorrent.Name, + Progress: preflight.existingTorrent.Progress, + Size: preflight.existingTorrent.Size, + } + } log.Debug(). Int("instanceID", candidate.InstanceID). Str("instanceName", candidate.InstanceName). Str("torrentHash", torrentHash). - Str("reason", rejectReason). - Msg("Cross-seed apply skipped: no best candidate match after file-level validation") - + Str("status", result.Status). + Str("reason", result.Message). + Msg("Cross-seed apply skipped during preflight") return result } - // Get torrent properties to extract save path - props, err := s.syncManager.GetTorrentProperties(ctx, candidate.InstanceID, matchedTorrent.Hash) - if err != nil { - result.Message = fmt.Sprintf("Failed to get torrent properties: %v", err) - return result - } + matchedTorrent = preflight.validation.torrent + candidateFiles = preflight.validation.candidateFiles + matchType = preflight.validation.matchType + props := preflight.props // Build options for adding the torrent options := make(map[string]string) @@ -3653,7 +3624,7 @@ func (s *Service) processCrossSeedCandidate( } // Compute add policy from source files (e.g., disc layout detection) - addPolicy := PolicyForSourceFiles(sourceFiles) + addPolicy := preflight.addPolicy addPolicy.ApplyToAddOptions(options) if addPolicy.DiscLayout { @@ -3665,29 +3636,19 @@ func (s *Service) processCrossSeedCandidate( Msg("[CROSSSEED] Disc layout detected - torrent will be added paused and only resumed after full recheck") } - // Check if we need rename alignment (folder/file names differ) - requiresAlignment := needsRenameAlignment(torrentName, matchedTorrent.Name, sourceFiles, candidateFiles) - - // Check if source has extra files that won't exist on disk (e.g., NFO files not in the candidate) - hasExtraFiles := hasExtraSourceFiles(sourceFiles, candidateFiles) - - // Force recheck is automatic (no user setting): - // - Disc-layout torrents always trigger a recheck after injection - // - Recheck-required matches (alignment/extras) trigger a recheck when SkipRecheck is OFF - forceRecheck := addPolicy.DiscLayout || (!req.SkipRecheck && (requiresAlignment || hasExtraFiles)) + requiresAlignment := preflight.requiresAlignment + hasExtraFiles := preflight.hasExtraFiles + forceRecheck := preflight.forceRecheck // Determine mode selection: reflink vs hardlink vs reuse. // Mode selection must happen BEFORE safety checks because reflink mode bypasses safety // checks that exist to protect the *original* files (reflinks protect originals via CoW). - instance, instanceErr := s.instanceStore.Get(ctx, candidate.InstanceID) - useReflinkMode := instanceErr == nil && instance != nil && instance.UseReflinks - useHardlinkMode := instanceErr == nil && instance != nil && instance.UseHardlinks && !instance.UseReflinks + useReflinkMode := preflight.useReflinkMode + useHardlinkMode := preflight.useHardlinkMode runReuseSafetyChecks := func() bool { - // SAFETY: Reject cross-seeds where main content file sizes don't match. - // This prevents corrupting existing good data with potentially different or corrupted files. - // Scene releases should be byte-for-byte identical across trackers - if sizes differ, - // it indicates either corruption or a different release that shouldn't be cross-seeded. + // Reflink mode can bypass reuse safety when it succeeds, but if it falls back to + // regular handling we must re-apply the same regular-mode protections. if hasMismatch, mismatchedFiles := hasContentFileSizeMismatch(sourceFiles, candidateFiles, s.stringNormalizer); hasMismatch { result.Status = "rejected" result.Message = "Content file sizes do not match - possible corruption or different release" @@ -3702,20 +3663,15 @@ func (s *Service) processCrossSeedCandidate( return false } - // SAFETY: Check piece-boundary alignment when source has extra files. - // If extra/ignored files share pieces with content files, downloading those pieces - // could corrupt the existing content data (piece hashes span both file types). - // In this case, we must skip - only reflink/copy mode could safely handle it. if !hasExtraFiles || torrentInfo == nil { return true } - // Build set of missing file paths (files in source that have no (normalizedKey, size) match in candidate). - // This uses the same multiset matching as hasExtraSourceFiles. type fileKeySize struct { key string size int64 } + candidateKeys := make(map[fileKeySize]int) for _, cf := range candidateFiles { key := fileKeySize{key: normalizeFileKey(cf.Name), size: cf.Size} @@ -3731,15 +3687,10 @@ func (s *Service) processCrossSeedCandidate( } } - // isMissingOnDisk returns true if the file has no (normalizedKey, size) match in candidate files. - // These files will be downloaded by qBittorrent during recheck. - // Note: ignore patterns are NOT checked here - the piece-boundary check applies - // to ALL missing files regardless of whether they match ignore patterns. isMissingOnDisk := func(path string) bool { return missingPaths[path] } - // Check piece boundary safety unless user opted out if !req.SkipPieceBoundarySafetyCheck { unsafe, safetyResult := HasUnsafeIgnoredExtras(torrentInfo, isMissingOnDisk) if unsafe { @@ -3752,8 +3703,6 @@ func (s *Service) processCrossSeedCandidate( Int("violationCount", len(safetyResult.UnsafeBoundaries)). Int64("pieceLength", torrentInfo.PieceLength). Msg("[CROSSSEED] Skipped: piece boundary violation - extra files share pieces with content files") - - // Log first violation for actionable debugging if len(safetyResult.UnsafeBoundaries) > 0 { v := safetyResult.UnsafeBoundaries[0] log.Debug(). @@ -3765,48 +3714,11 @@ func (s *Service) processCrossSeedCandidate( } return false } - } else { - log.Debug(). - Int("instanceID", candidate.InstanceID). - Str("torrentHash", torrentHash). - Msg("[CROSSSEED] Piece boundary safety check skipped by user setting") } return true } - // NOTE: Reflink mode bypasses these checks only when reflink mode is actually used. - if !useReflinkMode { - if !runReuseSafetyChecks() { - return result - } - } - - if req.SkipRecheck && (requiresAlignment || hasExtraFiles) { - result.Status = "skipped_recheck" - result.Message = skippedRecheckMessage - log.Info(). - Int("instanceID", candidate.InstanceID). - Str("torrentHash", torrentHash). - Bool("discLayout", addPolicy.DiscLayout). - Bool("requiresAlignment", requiresAlignment). - Bool("hasExtraFiles", hasExtraFiles). - Msg("Cross-seed skipped because recheck is required and skip recheck is enabled") - return result - } - - if req.SkipRecheck && addPolicy.DiscLayout { - result.Status = "skipped_recheck" - result.Message = skippedRecheckMessage - log.Info(). - Int("instanceID", candidate.InstanceID). - Str("torrentHash", torrentHash). - Bool("discLayout", addPolicy.DiscLayout). - Str("discMarker", addPolicy.DiscMarker). - Msg("Cross-seed skipped because disc layout requires recheck and skip recheck is enabled") - return result - } - // Skip checking for cross-seed adds - the data is already verified by the matched torrent. // We MUST use skip_checking when alignment (renames) is required, because qBittorrent blocks // file rename operations while a torrent is being verified. The manual recheck triggered @@ -3816,8 +3728,8 @@ func (s *Service) processCrossSeedCandidate( } // Detect folder structure for contentLayout decisions - sourceRoot := detectCommonRoot(sourceFiles) - candidateRoot := detectCommonRoot(candidateFiles) + sourceRoot := preflight.sourceRoot + candidateRoot := preflight.candidateRoot // Log first file from each for debugging sourceFirstFile := "" @@ -3839,44 +3751,19 @@ func (s *Service) processCrossSeedCandidate( // Detect episode matched to season pack - these need special handling // to use the season pack's content path instead of category save path - matchedRelease := s.releaseCache.Parse(matchedTorrent.Name) - isEpisodeInPack := matchType == "partial-in-pack" && - sourceRelease.Series > 0 && sourceRelease.Episode > 0 && - matchedRelease.Series > 0 && matchedRelease.Episode == 0 - rootlessContentDir := "" - if !isEpisodeInPack && candidateRoot == "" { - rootlessContentDir = resolveRootlessContentDir(matchedTorrent, candidateFiles) - } - - // Determine final category to apply (with optional .cross suffix for isolation) - baseCategory, crossCategory := s.determineCrossSeedCategory(ctx, req, matchedTorrent, nil) + isEpisodeInPack := preflight.isEpisodeInPack + rootlessContentDir := preflight.rootlessContentDir + baseCategory := preflight.baseCategory + crossCategory := preflight.crossCategory // Determine the SavePath for the cross-seed category. // Priority: base category's configured SavePath > matched torrent's SavePath // actualCategorySavePath tracks the category's real configured path (empty if none configured) // categorySavePath includes the fallback to matched torrent's path for category creation - var categorySavePath string - var actualCategorySavePath string + categorySavePath := preflight.categorySavePath + actualCategorySavePath := preflight.actualCategorySavePath var categoryCreationFailed bool if crossCategory != "" { - // Try to get SavePath from the base category definition in qBittorrent - categories, catErr := s.syncManager.GetCategories(ctx, candidate.InstanceID) - if catErr != nil { - log.Debug().Err(catErr).Int("instanceID", candidate.InstanceID). - Msg("[CROSSSEED] Failed to fetch categories, falling back to torrent SavePath") - } - if catErr == nil && categories != nil { - if cat, exists := categories[baseCategory]; exists && cat.SavePath != "" { - categorySavePath = cat.SavePath - actualCategorySavePath = cat.SavePath - } - } - - // Fallback to matched torrent's SavePath if category has no explicit SavePath - if categorySavePath == "" { - categorySavePath = props.SavePath - } - // Ensure the cross-seed category exists with the correct SavePath if err := s.ensureCrossCategory(ctx, candidate.InstanceID, crossCategory, categorySavePath); err != nil { log.Warn().Err(err). @@ -3900,8 +3787,6 @@ func (s *Service) processCrossSeedCandidate( return rlResult.Result } - // Reflink mode was enabled but not used (e.g., fallback on error). Re-run reuse safety checks - // before continuing into hardlink/regular modes. if !runReuseSafetyChecks() { return result } @@ -3995,13 +3880,6 @@ func (s *Service) processCrossSeedCandidate( options["contentLayout"] = "Original" } - // Check if UseCategoryFromIndexer or UseCustomCategory is enabled (affects TMM decision) - var useCategoryFromIndexer, useCustomCategory bool - if settings, err := s.GetAutomationSettings(ctx); err == nil && settings != nil { - useCategoryFromIndexer = settings.UseCategoryFromIndexer - useCustomCategory = settings.UseCustomCategory - } - // Determine save path strategy: // Cross-seeding should use the matched torrent's SavePath to avoid relocating files. // Auto Torrent Management (autoTMM) can only be enabled when the category has an explicitly @@ -4012,7 +3890,6 @@ func (s *Service) processCrossSeedCandidate( // If false, we should not add the torrent as qBittorrent would use its default location // and fail to find the existing files for cross-seeding. - // Fail early for episode-in-pack if ContentPath is missing if isEpisodeInPack && matchedTorrent.ContentPath == "" { result.Status = "invalid_content_path" result.Message = fmt.Sprintf("Episode-in-pack match but matched torrent has no ContentPath (matchedHash=%s). This may indicate the matched torrent is incomplete or was added without proper metadata.", matchedTorrent.Hash) @@ -4053,7 +3930,7 @@ func (s *Service) processCrossSeedCandidate( } // Evaluate whether autoTMM should be enabled - tmmDecision := shouldEnableAutoTMM(crossCategory, matchedTorrent.AutoManaged, useCategoryFromIndexer, useCustomCategory, actualCategorySavePath, props.SavePath) + tmmDecision := shouldEnableAutoTMM(crossCategory, matchedTorrent.AutoManaged, preflight.useCategoryFromIndexer, preflight.useCustomCategory, actualCategorySavePath, props.SavePath) if forceManualSavePath { tmmDecision.Enabled = false } @@ -4083,7 +3960,6 @@ func (s *Service) processCrossSeedCandidate( } } - // Fail early if no valid save path - don't add orphaned torrents if !hasValidSavePath { result.Status = "no_save_path" result.Message = fmt.Sprintf("No valid save path available. Ensure the matched torrent has a SavePath or the category has an explicit SavePath configured. (matchedSavePath=%q, categorySavePath=%q)", props.SavePath, categorySavePath) @@ -4840,6 +4716,48 @@ type candidateValidationSummary struct { rejectReason string } +type candidatePreflightStatus string + +const ( + candidatePreflightStatusEligible candidatePreflightStatus = "eligible" + candidatePreflightStatusBlocked candidatePreflightStatus = "blocked" + candidatePreflightStatusExists candidatePreflightStatus = "exists" + candidatePreflightStatusNoMatch candidatePreflightStatus = "no_match" + candidatePreflightStatusRejected candidatePreflightStatus = "rejected" + candidatePreflightStatusSkippedUnsafePieces candidatePreflightStatus = "skipped_unsafe_pieces" + candidatePreflightStatusSkippedRecheck candidatePreflightStatus = "skipped_recheck" + candidatePreflightStatusRequiresHardlinkReflink candidatePreflightStatus = "requires_hardlink_reflink" + candidatePreflightStatusInvalidContentPath candidatePreflightStatus = "invalid_content_path" + candidatePreflightStatusNoSavePath candidatePreflightStatus = "no_save_path" +) + +type candidatePreflightResult struct { + status candidatePreflightStatus + reason string + validation *candidateValidationResult + existingTorrent *qbt.Torrent + props *qbt.TorrentProperties + + addPolicy AddPolicy + requiresAlignment bool + hasExtraFiles bool + forceRecheck bool + useReflinkMode bool + useHardlinkMode bool + + sourceRoot string + candidateRoot string + rootlessContentDir string + isEpisodeInPack bool + + baseCategory string + crossCategory string + categorySavePath string + actualCategorySavePath string + useCategoryFromIndexer bool + useCustomCategory bool +} + type preparedCrossSeedEvaluation struct { torrentBytes []byte meta TorrentMetadata @@ -4959,6 +4877,248 @@ func (s *Service) summarizeCandidateValidation( return summary } +func collectCandidateHashes(torrentHash string, torrentHashV2 string) []string { + hashes := make([]string, 0, 2) + seen := make(map[string]struct{}, 2) + for _, hash := range []string{torrentHash, torrentHashV2} { + trimmed := strings.TrimSpace(hash) + if trimmed == "" { + continue + } + canonical := normalizeHash(trimmed) + if canonical == "" { + continue + } + if _, exists := seen[canonical]; exists { + continue + } + seen[canonical] = struct{}{} + hashes = append(hashes, trimmed) + } + return hashes +} + +func defaultCandidateRejectReason(reason string) string { + if trimmed := strings.TrimSpace(reason); trimmed != "" { + return trimmed + } + return "No matching torrents found with required files" +} + +// preflightCandidateChecks runs the read-only apply-path gates that determine whether a +// validated candidate can proceed toward AddTorrent. It never mutates qBittorrent or local state. +func (s *Service) preflightCandidateChecks( + ctx context.Context, + candidate CrossSeedCandidate, + validation *candidateValidationResult, + rejectReason string, + torrentHash string, + torrentHashV2 string, + torrentName string, + req *CrossSeedRequest, + sourceRelease *rls.Release, + sourceFiles qbt.TorrentFiles, + torrentInfo *metainfo.Info, + settings *models.CrossSeedAutomationSettings, +) (candidatePreflightResult, error) { + result := candidatePreflightResult{ + status: candidatePreflightStatusEligible, + validation: validation, + } + + hashes := collectCandidateHashes(torrentHash, torrentHashV2) + + if s.blocklistStore != nil { + _, blocked, err := s.blocklistStore.FindBlocked(ctx, candidate.InstanceID, hashes) + if err != nil { + return result, fmt.Errorf("failed to check cross-seed blocklist: %w", err) + } + if blocked { + result.status = candidatePreflightStatusBlocked + result.reason = "Blocked by cross-seed blocklist" + return result, nil + } + } + + existingTorrent, exists, err := s.syncManager.HasTorrentByAnyHash(ctx, candidate.InstanceID, hashes) + if err != nil { + return result, fmt.Errorf("failed to check existing torrents: %w", err) + } + if exists && existingTorrent != nil { + copyTorrent := *existingTorrent + result.status = candidatePreflightStatusExists + result.reason = "Torrent already exists in this instance" + result.existingTorrent = ©Torrent + return result, nil + } + + if validation == nil || validation.torrent == nil { + result.status = candidatePreflightStatusNoMatch + result.reason = defaultCandidateRejectReason(rejectReason) + return result, nil + } + + props, err := s.syncManager.GetTorrentProperties(ctx, candidate.InstanceID, validation.torrent.Hash) + if err != nil { + return result, fmt.Errorf("failed to get torrent properties: %w", err) + } + result.props = props + + result.addPolicy = PolicyForSourceFiles(sourceFiles) + result.requiresAlignment = needsRenameAlignment(torrentName, validation.torrent.Name, sourceFiles, validation.candidateFiles) + result.hasExtraFiles = hasExtraSourceFiles(sourceFiles, validation.candidateFiles) + result.forceRecheck = result.addPolicy.DiscLayout || (!req.SkipRecheck && (result.requiresAlignment || result.hasExtraFiles)) + + instance, instanceErr := s.instanceStore.Get(ctx, candidate.InstanceID) + result.useReflinkMode = instanceErr == nil && instance != nil && instance.UseReflinks + result.useHardlinkMode = instanceErr == nil && instance != nil && instance.UseHardlinks && !instance.UseReflinks + + if !result.useReflinkMode { + if hasMismatch, mismatchedFiles := hasContentFileSizeMismatch(sourceFiles, validation.candidateFiles, s.stringNormalizer); hasMismatch { + result.status = candidatePreflightStatusRejected + result.reason = "Content file sizes do not match - possible corruption or different release" + _ = mismatchedFiles + return result, nil + } + + if result.hasExtraFiles && torrentInfo != nil { + type fileKeySize struct { + key string + size int64 + } + + candidateKeys := make(map[fileKeySize]int) + for _, cf := range validation.candidateFiles { + key := fileKeySize{key: normalizeFileKey(cf.Name), size: cf.Size} + candidateKeys[key]++ + } + + missingPaths := make(map[string]bool) + for _, sf := range sourceFiles { + key := fileKeySize{key: normalizeFileKey(sf.Name), size: sf.Size} + if count := candidateKeys[key]; count > 0 { + candidateKeys[key]-- + } else { + missingPaths[sf.Name] = true + } + } + + isMissingOnDisk := func(path string) bool { + return missingPaths[path] + } + + if !req.SkipPieceBoundarySafetyCheck { + unsafe, _ := HasUnsafeIgnoredExtras(torrentInfo, isMissingOnDisk) + if unsafe { + result.status = candidatePreflightStatusSkippedUnsafePieces + result.reason = "Skipped: extra files share pieces with content. Disable 'Piece boundary safety check' in Cross-Seed settings to allow" + return result, nil + } + } + } + } + + if req.SkipRecheck && (result.addPolicy.DiscLayout || result.requiresAlignment || result.hasExtraFiles) { + result.status = candidatePreflightStatusSkippedRecheck + result.reason = skippedRecheckMessage + return result, nil + } + + matchedRelease := s.releaseCache.Parse(validation.torrent.Name) + result.sourceRoot = detectCommonRoot(sourceFiles) + result.candidateRoot = detectCommonRoot(validation.candidateFiles) + result.isEpisodeInPack = validation.matchType == "partial-in-pack" && + sourceRelease.Series > 0 && sourceRelease.Episode > 0 && + matchedRelease.Series > 0 && matchedRelease.Episode == 0 + if !result.isEpisodeInPack && result.candidateRoot == "" { + result.rootlessContentDir = resolveRootlessContentDir(validation.torrent, validation.candidateFiles) + } + + if settings == nil { + settings, err = s.GetAutomationSettings(ctx) + if err != nil { + settings = models.DefaultCrossSeedAutomationSettings() + } + } + if settings == nil { + settings = models.DefaultCrossSeedAutomationSettings() + } + + result.baseCategory, result.crossCategory = s.determineCrossSeedCategory(ctx, req, validation.torrent, settings) + if result.crossCategory != "" { + categories, catErr := s.syncManager.GetCategories(ctx, candidate.InstanceID) + if catErr == nil && categories != nil { + if cat, exists := categories[result.baseCategory]; exists && cat.SavePath != "" { + result.categorySavePath = cat.SavePath + result.actualCategorySavePath = cat.SavePath + } + } + if result.categorySavePath == "" { + result.categorySavePath = props.SavePath + } + } + + if !result.useReflinkMode && !result.useHardlinkMode && + result.sourceRoot != "" && result.candidateRoot == "" && result.hasExtraFiles { + result.status = candidatePreflightStatusRequiresHardlinkReflink + result.reason = "Skipped: cross-seed with extra files and rootless content requires hardlink or reflink mode to avoid scattering files in base directory" + return result, nil + } + + if !result.useReflinkMode && !result.useHardlinkMode && result.isEpisodeInPack && validation.torrent.ContentPath == "" { + result.status = candidatePreflightStatusInvalidContentPath + result.reason = fmt.Sprintf("Episode-in-pack match but matched torrent has no ContentPath (matchedHash=%s). This may indicate the matched torrent is incomplete or was added without proper metadata.", validation.torrent.Hash) + return result, nil + } + + result.useCategoryFromIndexer = settings.UseCategoryFromIndexer + result.useCustomCategory = settings.UseCustomCategory + + if !result.useReflinkMode && !result.useHardlinkMode { + hasValidSavePath := false + if result.isEpisodeInPack && validation.torrent.ContentPath != "" { + hasValidSavePath = true + } else { + savePath := props.SavePath + if savePath == "" { + savePath = result.categorySavePath + } + + forceManualSavePath := false + if result.rootlessContentDir != "" { + normalizedSavePath := normalizePath(savePath) + normalizedRootlessDir := normalizePath(result.rootlessContentDir) + if normalizedRootlessDir != "" && normalizedRootlessDir != normalizedSavePath { + savePath = result.rootlessContentDir + forceManualSavePath = true + } + } + + tmmDecision := shouldEnableAutoTMM( + result.crossCategory, + validation.torrent.AutoManaged, + result.useCategoryFromIndexer, + result.useCustomCategory, + result.actualCategorySavePath, + props.SavePath, + ) + if forceManualSavePath { + tmmDecision.Enabled = false + } + + hasValidSavePath = tmmDecision.Enabled || savePath != "" + } + + if !hasValidSavePath { + result.status = candidatePreflightStatusNoSavePath + result.reason = fmt.Sprintf("No valid save path available. Ensure the matched torrent has a SavePath or the category has an explicit SavePath configured. (matchedSavePath=%q, categorySavePath=%q)", props.SavePath, result.categorySavePath) + return result, nil + } + } + + return result, nil +} + func (s *Service) prepareCrossSeedEvaluation( ctx context.Context, req *CrossSeedRequest, @@ -9823,7 +9983,7 @@ func (s *Service) resolveInstances(ctx context.Context, requested []int) ([]*mod } // CheckWebhook checks if a release announced by autobrr can be cross-seeded with existing torrents. -// It performs the same non-mutating candidate/file validation as cross-seed apply so autobrr gets a final verdict. +// It mirrors the apply path's read-only validation and preflight gates without mutating qBittorrent state. func (s *Service) CheckWebhook(ctx context.Context, req *WebhookCheckRequest) (*WebhookCheckResponse, error) { startedAt := time.Now().UTC() if err := validateWebhookCheckRequest(req); err != nil { @@ -9847,9 +10007,13 @@ func (s *Service) CheckWebhook(ctx context.Context, req *WebhookCheckRequest) (* requestedInstanceIDs := normalizeInstanceIDs(req.InstanceIDs) crossReq := &CrossSeedRequest{ - TorrentData: req.TorrentData, - TargetInstanceIDs: requestedInstanceIDs, - FindIndividualEpisodes: findIndividualEpisodes, + TorrentData: req.TorrentData, + TargetInstanceIDs: requestedInstanceIDs, + FindIndividualEpisodes: findIndividualEpisodes, + SizeMismatchTolerancePercent: settings.SizeMismatchTolerancePercent, + SkipAutoResume: settings.SkipAutoResumeWebhook, + SkipRecheck: settings.SkipRecheck, + SkipPieceBoundarySafetyCheck: settings.SkipPieceBoundarySafetyCheck, } crossReq.SourceFilterCategories = append([]string(nil), settings.WebhookSourceCategories...) crossReq.SourceFilterTags = append([]string(nil), settings.WebhookSourceTags...) @@ -9919,34 +10083,59 @@ func (s *Service) CheckWebhook(ctx context.Context, req *WebhookCheckRequest) (* if selected == nil { selected = summary.pending } - if selected == nil || selected.torrent == nil { + preflight, err := s.preflightCandidateChecks( + ctx, + candidate, + selected, + summary.rejectReason, + prepared.meta.HashV1, + prepared.meta.HashV2, + prepared.meta.Name, + crossReq, + prepared.sourceRelease, + prepared.meta.Files, + prepared.meta.Info, + settings, + ) + if err != nil { + log.Warn(). + Err(err). + Int("instanceID", candidate.InstanceID). + Str("instanceName", candidate.InstanceName). + Str("torrentName", prepared.meta.Name). + Msg("Webhook check: candidate preflight failed, skipping instance") + continue + } + + if preflight.status != candidatePreflightStatusEligible { log.Debug(). Str("source", "cross-seed.webhook"). Int("instanceID", candidate.InstanceID). Str("instanceName", candidate.InstanceName). Str("torrentName", prepared.meta.Name). - Str("reason", summary.rejectReason). - Msg("Webhook check: candidate rejected after file-level validation") + Str("status", string(preflight.status)). + Str("reason", preflight.reason). + Msg("Webhook check: candidate rejected during preflight") continue } sizeDiff := 0.0 - if sourceSize > 0 && selected.torrent.Size > 0 { - diff := math.Abs(float64(sourceSize) - float64(selected.torrent.Size)) + if sourceSize > 0 && preflight.validation.torrent.Size > 0 { + diff := math.Abs(float64(sourceSize) - float64(preflight.validation.torrent.Size)) sizeDiff = (diff / float64(sourceSize)) * 100.0 } matches = append(matches, WebhookCheckMatch{ InstanceID: candidate.InstanceID, InstanceName: candidate.InstanceName, - TorrentHash: selected.torrent.Hash, - TorrentName: selected.torrent.Name, - MatchType: selected.matchType, + TorrentHash: preflight.validation.torrent.Hash, + TorrentName: preflight.validation.torrent.Name, + MatchType: preflight.validation.matchType, SizeDiff: sizeDiff, - Progress: selected.torrent.Progress, + Progress: preflight.validation.torrent.Progress, }) - if summary.ready != nil { + if preflight.validation.torrent.Progress >= 1.0 { hasCompleteMatch = true } else { hasPendingMatch = true diff --git a/internal/services/crossseed/webhook_check_test.go b/internal/services/crossseed/webhook_check_test.go index 61548c95b..00c9f1252 100644 --- a/internal/services/crossseed/webhook_check_test.go +++ b/internal/services/crossseed/webhook_check_test.go @@ -241,6 +241,141 @@ func TestCheckWebhook_FinalAnswerMultiInstanceScan(t *testing.T) { assert.Equal(t, "download", resp.Recommendation) } +func TestCheckWebhook_PreflightExistsSkipsDownloadRecommendation(t *testing.T) { + t.Parallel() + + instance := &models.Instance{ID: 1, Name: "Test Instance"} + req, meta := makeTorrentDataRequest(t, "Already.Seeded.2025.1080p.BluRay.x264-GRP", []string{"Already.Seeded.2025.1080p.BluRay.x264-GRP.mkv"}) + req.InstanceIDs = []int{instance.ID} + + service := &Service{ + instanceStore: &fakeInstanceStore{ + instances: map[int]*models.Instance{ + instance.ID: instance, + }, + }, + syncManager: newFakeSyncManager(instance, []qbt.Torrent{ + { + Hash: meta.HashV1, + Name: meta.Name, + Progress: 1.0, + Size: meta.Files[0].Size, + }, + }, map[string]qbt.TorrentFiles{ + meta.HashV1: meta.Files, + }), + releaseCache: NewReleaseCache(), + stringNormalizer: stringutils.NewDefaultNormalizer(), + } + + resp, err := service.CheckWebhook(context.Background(), req) + require.NoError(t, err) + require.NotNil(t, resp) + assert.False(t, resp.CanCrossSeed) + assert.Empty(t, resp.Matches) + assert.Equal(t, "skip", resp.Recommendation) +} + +func TestCheckWebhook_PreflightNoSavePathSkipsDownloadRecommendation(t *testing.T) { + t.Parallel() + + instance := &models.Instance{ID: 1, Name: "Test Instance"} + req, meta := makeTorrentDataRequest(t, "No.Save.Path.2025.1080p.WEB-DL-GRP", []string{"No.Save.Path.2025.1080p.WEB-DL-GRP.mkv"}) + req.InstanceIDs = []int{instance.ID} + + matchedTorrent := qbt.Torrent{ + Hash: "candidate", + Name: meta.Name, + Progress: 1.0, + Size: meta.Files[0].Size, + } + sync := newFakeSyncManager(instance, []qbt.Torrent{matchedTorrent}, map[string]qbt.TorrentFiles{ + matchedTorrent.Hash: meta.Files, + }) + sync.props[normalizeHash(matchedTorrent.Hash)] = &qbt.TorrentProperties{SavePath: ""} + + service := &Service{ + instanceStore: &fakeInstanceStore{ + instances: map[int]*models.Instance{ + instance.ID: instance, + }, + }, + syncManager: sync, + releaseCache: NewReleaseCache(), + stringNormalizer: stringutils.NewDefaultNormalizer(), + } + + resp, err := service.CheckWebhook(context.Background(), req) + require.NoError(t, err) + require.NotNil(t, resp) + assert.False(t, resp.CanCrossSeed) + assert.Empty(t, resp.Matches) + assert.Equal(t, "skip", resp.Recommendation) +} + +func TestCheckWebhook_PreflightSkipRecheckUsesWebhookSettings(t *testing.T) { + t.Parallel() + + instance := &models.Instance{ID: 1, Name: "Test Instance"} + torrentName := "Webhook.Skip.Recheck.2025.1080p.WEB-DL-GRP" + torrentData := createTestTorrent(t, torrentName, []string{ + torrentName + "/" + torrentName + ".mkv", + torrentName + "/Sample/sample.mkv", + }, 256*1024) + meta, err := ParseTorrentMetadataWithInfo(torrentData) + require.NoError(t, err) + + req := &WebhookCheckRequest{ + TorrentData: base64.StdEncoding.EncodeToString(torrentData), + InstanceIDs: []int{instance.ID}, + } + + mainFileSize := int64(0) + for _, file := range meta.Files { + if file.Size > mainFileSize { + mainFileSize = file.Size + } + } + require.Positive(t, mainFileSize) + + matchedTorrent := qbt.Torrent{ + Hash: "candidate", + Name: meta.Name, + Progress: 1.0, + Size: mainFileSize, + ContentPath: "/downloads/" + torrentName + ".mkv", + } + sync := newFakeSyncManager(instance, []qbt.Torrent{matchedTorrent}, map[string]qbt.TorrentFiles{ + matchedTorrent.Hash: { + {Name: torrentName + ".mkv", Size: mainFileSize}, + }, + }) + sync.props[normalizeHash(matchedTorrent.Hash)] = &qbt.TorrentProperties{SavePath: "/downloads"} + + service := &Service{ + instanceStore: &fakeInstanceStore{ + instances: map[int]*models.Instance{ + instance.ID: instance, + }, + }, + syncManager: sync, + releaseCache: NewReleaseCache(), + stringNormalizer: stringutils.NewDefaultNormalizer(), + automationSettingsLoader: func(context.Context) (*models.CrossSeedAutomationSettings, error) { + settings := models.DefaultCrossSeedAutomationSettings() + settings.SkipRecheck = true + return settings, nil + }, + } + + resp, err := service.CheckWebhook(context.Background(), req) + require.NoError(t, err) + require.NotNil(t, resp) + assert.False(t, resp.CanCrossSeed) + assert.Empty(t, resp.Matches) + assert.Equal(t, "skip", resp.Recommendation) +} + func TestCheckWebhook_FinalAnswerSourceFilters(t *testing.T) { t.Parallel()