Skip to content

Commit 4fc550f

Browse files
authored
fix(crossseed): use autobrr indexer ids for webhooks (#1614)
1 parent aa2f3da commit 4fc550f

10 files changed

Lines changed: 143 additions & 43 deletions

File tree

documentation/docs/features/cross-seed/autobrr.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ qui integrates with autobrr through webhook endpoints, enabling real-time cross-
1010
## How It Works
1111

1212
1. autobrr sees a new release from a tracker
13-
2. autobrr sends the torrent name to qui's `/api/cross-seed/webhook/check` endpoint
13+
2. autobrr sends the torrent name and indexer identifier to qui's `/api/cross-seed/webhook/check` endpoint
1414
3. qui searches your qBittorrent instances for matching content
1515
4. qui responds with:
1616
- `200 OK` – matching torrent is complete and ready to cross-seed
@@ -62,22 +62,25 @@ In your new autobrr filter, go to **External** tab → **Add new**:
6262
```json
6363
{
6464
"torrentName": {{ toRawJson .TorrentName }},
65-
"instanceIds": [1]
65+
"instanceIds": [1],
66+
"indexer": {{ toRawJson .Indexer }}
6667
}
6768
```
6869

6970
To search all instances, omit `instanceIds`:
7071

7172
```json
7273
{
73-
"torrentName": {{ toRawJson .TorrentName }}
74+
"torrentName": {{ toRawJson .TorrentName }},
75+
"indexer": {{ toRawJson .Indexer }}
7476
}
7577
```
7678

7779
**Field descriptions:**
7880

7981
- `torrentName` (required): The release name as announced
8082
- `instanceIds` (optional): qBittorrent instance IDs to scan. Omit to search all instances.
83+
- `indexer` (optional): autobrr indexer identifier (for example `hdb`). Required for qui's HDBits-specific missing-collection fallback on `/check`.
8184
- `findIndividualEpisodes` (optional): Override the global episode matching setting
8285

8386
### 3. Configure Retry Handling
@@ -106,17 +109,17 @@ When `/check` returns `200 OK`, send the torrent to `/api/cross-seed/apply`:
106109
{
107110
"torrentData": "{{ .TorrentDataRawBytes | toString | b64enc }}",
108111
"instanceIds": [1],
109-
"indexerName": {{ toRawJson .IndexerName }}
112+
"indexer": {{ toRawJson .Indexer }}
110113
}
111114
```
112115

113116
**Field descriptions:**
114117

115118
- `torrentData` (required) - Base64-encoded torrent file bytes
116119
- `instanceIds` (optional) - Target instances (omit to apply to any matching instance)
117-
- `indexerName` (optional) - Indexer display name (e.g., "TorrentDB"). Only used when "Use indexer name as category" mode is enabled; ignored otherwise
120+
- `indexer` (optional) - autobrr indexer identifier (for example `hdb`). When "Use indexer name as category" mode is enabled, qui uses this identifier value as the category; ignored otherwise
118121
- `tags` (optional) - Override webhook tags from settings
119-
- `category` (optional) - Override category. Takes precedence over `indexerName`
122+
- `category` (optional) - Override category. Takes precedence over `indexer`
120123
- `startPaused` (optional) - Override whether torrents are added paused
121124
- `skipIfExists` (optional) - Skip adding if the torrent already exists
122125
- `findIndividualEpisodes` (optional) - Override the global episode matching setting

documentation/static/openapi.yaml

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3034,6 +3034,10 @@ paths:
30343034
type: integer
30353035
format: uint64
30363036
description: Total torrent size in bytes (optional - enables size validation when provided)
3037+
indexer:
3038+
type: string
3039+
minLength: 1
3040+
description: Optional autobrr indexer identifier (for example "hdb"). Required if you want qui's HDBits-specific missing collection fallback on webhook checks.
30373041
findIndividualEpisodes:
30383042
type: boolean
30393043
description: Optional override for matching season packs vs episodes. Defaults to the Cross-Seed automation setting when omitted.
@@ -3109,9 +3113,10 @@ paths:
31093113
type: boolean
31103114
findIndividualEpisodes:
31113115
type: boolean
3112-
indexerName:
3116+
indexer:
31133117
type: string
3114-
description: Indexer display name (e.g., "TorrentDB"). Only used when "Use indexer name as category" mode is enabled; ignored otherwise. Webhook applies cannot derive the indexer from the torrent file, so this field is required for that mode.
3118+
minLength: 1
3119+
description: autobrr indexer identifier (for example "hdb"). Only used when "Use indexer name as category" mode is enabled; when provided in that mode, qui uses this identifier as the category value. If omitted, qui falls back to the matched torrent category. Webhook applies cannot derive tracker identity from the torrent file.
31153120
responses:
31163121
'200':
31173122
description: Torrent processed

internal/api/handlers/crossseed.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -639,7 +639,7 @@ func (h *CrossSeedHandler) AutobrrApply(w http.ResponseWriter, r *http.Request)
639639
Int64("size", totalSize).
640640
Int("fileCount", fileCount).
641641
Ints("instanceIds", req.InstanceIDs).
642-
Str("indexerName", req.IndexerName).
642+
Str("indexer", req.Indexer).
643643
Str("category", req.Category).
644644
Msg("Webhook apply: received request")
645645

internal/services/crossseed/autobrr_apply_test.go

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -136,27 +136,27 @@ func TestAutobrrApplyTargetInstanceIDs(t *testing.T) {
136136
}
137137
}
138138

139-
// TestAutobrrApply_IndexerNamePassthrough verifies that IndexerName from the request
139+
// TestAutobrrApply_IndexerPassthrough verifies that Indexer from the request
140140
// is passed through to the CrossSeedRequest, enabling "Use indexer name as category" mode
141141
// for webhook applies where the indexer cannot be derived from the torrent file.
142-
func TestAutobrrApply_IndexerNamePassthrough(t *testing.T) {
142+
func TestAutobrrApply_IndexerPassthrough(t *testing.T) {
143143
t.Parallel()
144144

145145
ctx := context.Background()
146146

147147
tests := []struct {
148148
name string
149-
indexerName string
149+
indexer string
150150
expectIndexerName string
151151
}{
152152
{
153-
name: "indexer name passed through",
154-
indexerName: "TorrentDB",
155-
expectIndexerName: "TorrentDB",
153+
name: "indexer passed through",
154+
indexer: "hdb",
155+
expectIndexerName: "hdb",
156156
},
157157
{
158-
name: "empty indexer name remains empty",
159-
indexerName: "",
158+
name: "empty indexer remains empty",
159+
indexer: "",
160160
expectIndexerName: "",
161161
},
162162
}
@@ -175,7 +175,7 @@ func TestAutobrrApply_IndexerNamePassthrough(t *testing.T) {
175175
req := &AutobrrApplyRequest{
176176
TorrentData: "ZGF0YQ==",
177177
InstanceIDs: []int{1},
178-
IndexerName: tt.indexerName,
178+
Indexer: tt.indexer,
179179
}
180180

181181
_, err := service.AutobrrApply(ctx, req)

internal/services/crossseed/crossseed_test.go

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2184,10 +2184,11 @@ func TestCheckWebhook_AutobrrPayload(t *testing.T) {
21842184
wantMatchType: "metadata",
21852185
},
21862186
{
2187-
name: "tv webhook tolerates missing incoming collection when group matches",
2187+
name: "tv webhook tolerates missing incoming collection for hdb when group matches",
21882188
request: &WebhookCheckRequest{
21892189
InstanceIDs: instanceIDs,
21902190
TorrentName: "Sample Show S08E11 1080p WEB-DL DD+5.1 H.264-NTb",
2191+
Indexer: "hdb",
21912192
},
21922193
existingTorrents: []qbt.Torrent{
21932194
{
@@ -2201,11 +2202,30 @@ func TestCheckWebhook_AutobrrPayload(t *testing.T) {
22012202
wantRecommendation: "download",
22022203
wantMatchType: "metadata",
22032204
},
2205+
{
2206+
name: "tv webhook missing collection stays strict for non-hdb even when group matches",
2207+
request: &WebhookCheckRequest{
2208+
InstanceIDs: instanceIDs,
2209+
TorrentName: "Sample Show S08E11 1080p WEB-DL DD+5.1 H.264-NTb",
2210+
Indexer: "btn",
2211+
},
2212+
existingTorrents: []qbt.Torrent{
2213+
{
2214+
Hash: "sample-show-dsnp-non-hdb",
2215+
Name: "Sample.Show.S08E11.Episode.Title.1080p.DSNP.WEB-DL.DDP5.1.H.264-NTb",
2216+
Progress: 1.0,
2217+
},
2218+
},
2219+
wantCanCrossSeed: false,
2220+
wantMatchCount: 0,
2221+
wantRecommendation: "skip",
2222+
},
22042223
{
22052224
name: "tv webhook missing collection still requires matching group or site",
22062225
request: &WebhookCheckRequest{
22072226
InstanceIDs: instanceIDs,
22082227
TorrentName: "Sample Show S08E11 1080p WEB-DL DD+5.1 H.264",
2228+
Indexer: "hdb",
22092229
},
22102230
existingTorrents: []qbt.Torrent{
22112231
{
@@ -2219,10 +2239,11 @@ func TestCheckWebhook_AutobrrPayload(t *testing.T) {
22192239
wantRecommendation: "skip",
22202240
},
22212241
{
2222-
name: "movie webhook tolerates missing incoming collection when group matches",
2242+
name: "movie webhook tolerates missing incoming collection for hdb when group matches",
22232243
request: &WebhookCheckRequest{
22242244
InstanceIDs: instanceIDs,
22252245
TorrentName: "Sample Movie 2024 1080p WEB-DL DD+5.1 H.264-NTb",
2246+
Indexer: "hdb",
22262247
},
22272248
existingTorrents: []qbt.Torrent{
22282249
{
@@ -2241,6 +2262,7 @@ func TestCheckWebhook_AutobrrPayload(t *testing.T) {
22412262
request: &WebhookCheckRequest{
22422263
InstanceIDs: instanceIDs,
22432264
TorrentName: "Sample Movie 2024 1080p WEB-DL DD+5.1 H.264",
2265+
Indexer: "hdb",
22442266
},
22452267
existingTorrents: []qbt.Torrent{
22462268
{

internal/services/crossseed/matching.go

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -393,12 +393,14 @@ func (s *Service) releasesMatch(source, candidate *rls.Release, findIndividualEp
393393
return true
394394
}
395395

396-
func (s *Service) releasesMatchWebhook(source, candidate *rls.Release, findIndividualEpisodes bool) bool {
396+
const hdbitsAutobrrIndexer = "hdb"
397+
398+
func (s *Service) releasesMatchWebhook(source, candidate *rls.Release, findIndividualEpisodes bool, indexer string) bool {
397399
if s.releasesMatch(source, candidate, findIndividualEpisodes) {
398400
return true
399401
}
400402

401-
if !canFillMissingWebhookCollection(source, candidate, s.stringNormalizer) {
403+
if !canUseWebhookCollectionFallback(source, candidate, indexer, s.stringNormalizer) {
402404
return false
403405
}
404406

@@ -408,31 +410,45 @@ func (s *Service) releasesMatchWebhook(source, candidate *rls.Release, findIndiv
408410
return s.releasesMatch(&sourceWithCollection, candidate, findIndividualEpisodes)
409411
}
410412

411-
func canFillMissingWebhookCollection(
413+
func canUseWebhookCollectionFallback(
412414
source, candidate *rls.Release,
415+
indexer string,
413416
normalizer *stringutils.Normalizer[string, string],
414417
) bool {
418+
if !supportsWebhookCollectionFallback(indexer) {
419+
return false
420+
}
421+
415422
if source == nil || candidate == nil {
416423
return false
417424
}
418425

419-
// Some webhook titles omit the collection/service tag entirely ("WEB-DL")
420-
// while the existing torrent keeps the canonical source service (for example
421-
// "DSNP"). Only retry when the incoming title is missing Collection and the
422-
// group or site already anchors the release identity.
426+
// Some indexers can announce generic WEB-DL titles without the collection/
427+
// service tag while the existing torrent keeps the canonical source service
428+
// (for example "DSNP"). Only retry when the incoming title is missing
429+
// Collection and the group or site already anchors the release identity.
423430
if source.Collection != "" || candidate.Collection == "" {
424431
return false
425432
}
426433

427-
if !supportsWebhookCollectionFallback(source, candidate) {
434+
if !supportsWebhookCollectionFallbackContent(source, candidate) {
428435
return false
429436
}
430437

431438
return hasNonEmptyNormalizedMatch(normalizer, source.Group, candidate.Group) ||
432439
hasNonEmptyNormalizedMatch(normalizer, source.Site, candidate.Site)
433440
}
434441

435-
func supportsWebhookCollectionFallback(source, candidate *rls.Release) bool {
442+
func supportsWebhookCollectionFallback(indexer string) bool {
443+
switch indexer {
444+
case hdbitsAutobrrIndexer:
445+
return true
446+
default:
447+
return false
448+
}
449+
}
450+
451+
func supportsWebhookCollectionFallbackContent(source, candidate *rls.Release) bool {
436452
if source == nil || candidate == nil {
437453
return false
438454
}

internal/services/crossseed/matching_webhook_test.go

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,14 @@ func TestReleasesMatchWebhook_FillsMissingCollection(t *testing.T) {
1717

1818
tests := []struct {
1919
name string
20+
indexer string
2021
source rls.Release
2122
candidate rls.Release
2223
wantMatch bool
2324
}{
2425
{
25-
name: "tv missing collection matches when group anchors release",
26+
name: "hdb tv missing collection matches when group anchors release",
27+
indexer: "hdb",
2628
source: rls.Release{
2729
Title: "Sample Show",
2830
Series: 8,
@@ -43,7 +45,8 @@ func TestReleasesMatchWebhook_FillsMissingCollection(t *testing.T) {
4345
wantMatch: true,
4446
},
4547
{
46-
name: "web movie missing collection matches when group anchors release",
48+
name: "hdb web movie missing collection matches when group anchors release",
49+
indexer: "hdb",
4750
source: rls.Release{
4851
Title: "Sample Movie",
4952
Year: 2024,
@@ -62,7 +65,47 @@ func TestReleasesMatchWebhook_FillsMissingCollection(t *testing.T) {
6265
wantMatch: true,
6366
},
6467
{
65-
name: "missing collection still needs group or site anchor",
68+
name: "non-hdb missing collection stays strict",
69+
indexer: "btn",
70+
source: rls.Release{
71+
Title: "Sample Movie",
72+
Year: 2024,
73+
Source: "WEB-DL",
74+
Resolution: "1080p",
75+
Group: "NTb",
76+
},
77+
candidate: rls.Release{
78+
Title: "Sample Movie",
79+
Year: 2024,
80+
Source: "WEB-DL",
81+
Resolution: "1080p",
82+
Collection: "DSNP",
83+
Group: "NTb",
84+
},
85+
wantMatch: false,
86+
},
87+
{
88+
name: "missing indexer stays strict",
89+
source: rls.Release{
90+
Title: "Sample Movie",
91+
Year: 2024,
92+
Source: "WEB-DL",
93+
Resolution: "1080p",
94+
Group: "NTb",
95+
},
96+
candidate: rls.Release{
97+
Title: "Sample Movie",
98+
Year: 2024,
99+
Source: "WEB-DL",
100+
Resolution: "1080p",
101+
Collection: "DSNP",
102+
Group: "NTb",
103+
},
104+
wantMatch: false,
105+
},
106+
{
107+
name: "hdb missing collection still needs group or site anchor",
108+
indexer: "hdb",
66109
source: rls.Release{
67110
Title: "Sample Movie",
68111
Year: 2024,
@@ -80,7 +123,8 @@ func TestReleasesMatchWebhook_FillsMissingCollection(t *testing.T) {
80123
wantMatch: false,
81124
},
82125
{
83-
name: "non-web movie missing collection stays strict",
126+
name: "hdb non-web movie missing collection stays strict",
127+
indexer: "hdb",
84128
source: rls.Release{
85129
Title: "Sample Movie",
86130
Year: 2024,
@@ -99,7 +143,8 @@ func TestReleasesMatchWebhook_FillsMissingCollection(t *testing.T) {
99143
wantMatch: false,
100144
},
101145
{
102-
name: "explicit source collection mismatch stays strict",
146+
name: "hdb explicit source collection mismatch stays strict",
147+
indexer: "hdb",
103148
source: rls.Release{
104149
Title: "Sample Movie",
105150
Year: 2024,
@@ -122,7 +167,7 @@ func TestReleasesMatchWebhook_FillsMissingCollection(t *testing.T) {
122167

123168
for _, tt := range tests {
124169
t.Run(tt.name, func(t *testing.T) {
125-
require.Equal(t, tt.wantMatch, s.releasesMatchWebhook(&tt.source, &tt.candidate, false))
170+
require.Equal(t, tt.wantMatch, s.releasesMatchWebhook(&tt.source, &tt.candidate, false, tt.indexer))
126171
})
127172
}
128173
}

internal/services/crossseed/models.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,9 @@ type WebhookCheckRequest struct {
384384
InstanceIDs []int `json:"instanceIds,omitempty"`
385385
// Size is the total torrent size in bytes (optional - enables size validation if provided)
386386
Size uint64 `json:"size,omitempty"`
387+
// Indexer is autobrr's stable indexer identifier (for example "hdb").
388+
// Used to apply tracker-specific webhook matching rules.
389+
Indexer string `json:"indexer,omitempty"`
387390
// FindIndividualEpisodes overrides the default behavior when matching season packs vs episodes.
388391
// When omitted, qui uses the automation setting; when set, this explicitly forces the behavior.
389392
FindIndividualEpisodes *bool `json:"findIndividualEpisodes,omitempty"`
@@ -418,8 +421,8 @@ type AutobrrApplyRequest struct {
418421
SkipIfExists *bool `json:"skipIfExists,omitempty"`
419422
// FindIndividualEpisodes overrides the automation-level episode matching behavior when set.
420423
FindIndividualEpisodes *bool `json:"findIndividualEpisodes,omitempty"`
421-
// IndexerName is the display name of the indexer (e.g., "TorrentDB") used when
422-
// "Use indexer name as category" mode is enabled. Without this field, webhook applies
423-
// cannot derive the indexer from the torrent file itself.
424-
IndexerName string `json:"indexerName,omitempty"`
424+
// Indexer is autobrr's stable indexer identifier (for example "hdb").
425+
// Used when "Use indexer name as category" mode is enabled because webhook applies
426+
// cannot derive tracker identity from the torrent file itself.
427+
Indexer string `json:"indexer,omitempty"`
425428
}

0 commit comments

Comments
 (0)