Skip to content

Commit d189fe9

Browse files
authored
feat(crossseed): add indexerName to webhook apply + fix category mode defaults (#916)
1 parent 90e15b4 commit d189fe9

8 files changed

Lines changed: 177 additions & 46 deletions

File tree

README.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -564,15 +564,15 @@ In your autobrr filter, go to **External** tab → **Add new**:
564564
**Data (JSON):**
565565
```json
566566
{
567-
"torrentName": "{{ .TorrentName }}",
567+
"torrentName": {{ toRawJson .TorrentName }},
568568
"instanceIds": [1]
569569
}
570570
```
571571

572572
To search all instances, omit `instanceIds`:
573573
```json
574574
{
575-
"torrentName": "{{ .TorrentName }}"
575+
"torrentName": {{ toRawJson .TorrentName }}
576576
}
577577
```
578578

@@ -605,14 +605,16 @@ When `/check` returns `200 OK`, send the torrent to `/api/cross-seed/apply`:
605605
```json
606606
{
607607
"torrentData": "{{ .TorrentDataRawBytes | toString | b64enc }}",
608-
"instanceIds": [1]
608+
"instanceIds": [1],
609+
"indexerName": {{ toRawJson .IndexerName }}
609610
}
610611
```
611612

612613
- `torrentData` - Base64-encoded torrent file bytes
613614
- `instanceIds` - Target instances (omit to apply to any matching instance)
615+
- `indexerName` (optional) - Indexer display name (e.g., "TorrentDB"). Only used when "Use indexer name as category" mode is enabled; ignored otherwise
614616
- `tags` (optional) - Override webhook tags from settings
615-
- `category` (optional) - Override category (defaults to matched torrent's category)
617+
- `category` (optional) - Override category. Takes precedence over `indexerName`
616618

617619
Cross-seeded torrents are added paused with `skip_checking=true`. qui polls the torrent state and auto-resumes if progress meets the size tolerance threshold. If progress is too low, it remains paused for manual review.
618620

docs/CROSS_SEEDING.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,15 @@ The `.cross` category is created automatically with the same save path as the ba
207207

208208
Uses the indexer name (from the search source) as the category. Useful for organizing cross-seeds by tracker.
209209

210+
**Note for webhook applies:** When using the `/api/cross-seed/apply` endpoint (autobrr webhook), the indexer name cannot be derived from the torrent file. To use this mode with webhook applies, include the `indexerName` field in your payload (ignored if this mode is not enabled):
211+
212+
```json
213+
{
214+
"torrentData": "{{ .TorrentDataRawBytes | toString | b64enc }}",
215+
"indexerName": {{ toRawJson .IndexerName }}
216+
}
217+
```
218+
210219
### 3. Custom Category
211220

212221
Uses a user-specified static category name for all cross-seeds. No suffix is applied—the exact category name you enter is used.
@@ -293,6 +302,33 @@ The incoming torrent has files not present in your matched torrent, and those fi
293302
- Verify the "Size mismatch tolerance" setting in Rules
294303
- Torrents below the auto-resume threshold stay paused for manual review
295304

305+
### Webhook returns HTTP 400 "invalid character" error
306+
307+
This typically means the torrent name contains special characters (like double quotes `"`) that break JSON encoding. The error often looks like:
308+
309+
```json
310+
{"level":"error","error":"invalid character 'V' after object key:value pair","time":"...","message":"Failed to decode webhook check request"}
311+
```
312+
313+
**Solution:** In your autobrr webhook configuration, use `toRawJson` instead of quoting the template variable directly:
314+
315+
```json
316+
{
317+
"torrentName": {{ toRawJson .TorrentName }},
318+
"instanceIds": [1]
319+
}
320+
```
321+
322+
**Not:**
323+
```json
324+
{
325+
"torrentName": "{{ .TorrentName }}",
326+
"instanceIds": [1]
327+
}
328+
```
329+
330+
The `toRawJson` function (from Sprig) properly escapes special characters and outputs a valid JSON string including the quotes.
331+
296332
### Cross-seed in wrong category
297333

298334
- Check your cross-seed settings in qui

internal/api/handlers/crossseed.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package handlers
55

66
import (
77
"context"
8+
"encoding/base64"
89
"encoding/json"
910
"errors"
1011
"fmt"
@@ -520,6 +521,29 @@ func (h *CrossSeedHandler) AutobrrApply(w http.ResponseWriter, r *http.Request)
520521
return
521522
}
522523

524+
// Parse torrent for logging (cheap operation, also done in service)
525+
var torrentName, torrentHash string
526+
var totalSize int64
527+
var fileCount int
528+
if torrentBytes, err := base64.StdEncoding.DecodeString(req.TorrentData); err == nil {
529+
if name, hash, files, info, err := crossseed.ParseTorrentMetadataWithInfo(torrentBytes); err == nil {
530+
torrentName, torrentHash = name, hash
531+
totalSize = info.TotalLength()
532+
fileCount = len(files)
533+
}
534+
}
535+
536+
log.Debug().
537+
Str("source", "cross-seed.webhook").
538+
Str("torrentName", torrentName).
539+
Str("torrentHash", torrentHash).
540+
Int64("size", totalSize).
541+
Int("fileCount", fileCount).
542+
Ints("instanceIds", req.InstanceIDs).
543+
Str("indexerName", req.IndexerName).
544+
Str("category", req.Category).
545+
Msg("Webhook apply: received request")
546+
523547
response, err := h.service.AutobrrApply(context.WithoutCancel(r.Context()), &req)
524548
if err != nil {
525549
status := mapCrossSeedErrorStatus(err)

internal/services/crossseed/autobrr_apply_test.go

Lines changed: 88 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,56 @@ func TestAutobrrApplyTargetInstanceIDs(t *testing.T) {
137137
}
138138
}
139139

140+
// TestAutobrrApply_IndexerNamePassthrough verifies that IndexerName from the request
141+
// is passed through to the CrossSeedRequest, enabling "Use indexer name as category" mode
142+
// for webhook applies where the indexer cannot be derived from the torrent file.
143+
func TestAutobrrApply_IndexerNamePassthrough(t *testing.T) {
144+
t.Parallel()
145+
146+
ctx := context.Background()
147+
148+
tests := []struct {
149+
name string
150+
indexerName string
151+
expectIndexerName string
152+
}{
153+
{
154+
name: "indexer name passed through",
155+
indexerName: "TorrentDB",
156+
expectIndexerName: "TorrentDB",
157+
},
158+
{
159+
name: "empty indexer name remains empty",
160+
indexerName: "",
161+
expectIndexerName: "",
162+
},
163+
}
164+
165+
for _, tt := range tests {
166+
t.Run(tt.name, func(t *testing.T) {
167+
t.Parallel()
168+
169+
service := &Service{}
170+
var captured *CrossSeedRequest
171+
service.crossSeedInvoker = func(ctx context.Context, req *CrossSeedRequest) (*CrossSeedResponse, error) {
172+
captured = req
173+
return &CrossSeedResponse{Success: true}, nil
174+
}
175+
176+
req := &AutobrrApplyRequest{
177+
TorrentData: "ZGF0YQ==",
178+
InstanceIDs: []int{1},
179+
IndexerName: tt.indexerName,
180+
}
181+
182+
_, err := service.AutobrrApply(ctx, req)
183+
require.NoError(t, err)
184+
require.NotNil(t, captured, "CrossSeedRequest should have been captured")
185+
require.Equal(t, tt.expectIndexerName, captured.IndexerName, "IndexerName mismatch")
186+
})
187+
}
188+
}
189+
140190
// TestAutobrrApply_RespectsWebhookSourceFilters verifies that AutobrrApply passes
141191
// webhook source filters through to the CrossSeedRequest. This is an integration test
142192
// that catches the bug where filters worked in isolation but weren't passed through the flow.
@@ -146,52 +196,52 @@ func TestAutobrrApply_RespectsWebhookSourceFilters(t *testing.T) {
146196
ctx := context.Background()
147197

148198
tests := []struct {
149-
name string
150-
settings *models.CrossSeedAutomationSettings
151-
expectCategories []string
152-
expectTags []string
153-
expectExcludeCategories []string
154-
expectExcludeTags []string
199+
name string
200+
settings *models.CrossSeedAutomationSettings
201+
expectCategories []string
202+
expectTags []string
203+
expectExcludeCategories []string
204+
expectExcludeTags []string
155205
}{
156206
{
157207
name: "include categories passed through",
158208
settings: &models.CrossSeedAutomationSettings{
159209
WebhookSourceCategories: []string{"movies", "tv"},
160210
},
161-
expectCategories: []string{"movies", "tv"},
162-
expectTags: nil,
163-
expectExcludeCategories: nil,
164-
expectExcludeTags: nil,
211+
expectCategories: []string{"movies", "tv"},
212+
expectTags: nil,
213+
expectExcludeCategories: nil,
214+
expectExcludeTags: nil,
165215
},
166216
{
167217
name: "include tags passed through",
168218
settings: &models.CrossSeedAutomationSettings{
169219
WebhookSourceTags: []string{"cross-seed", "priority"},
170220
},
171-
expectCategories: nil,
172-
expectTags: []string{"cross-seed", "priority"},
173-
expectExcludeCategories: nil,
174-
expectExcludeTags: nil,
221+
expectCategories: nil,
222+
expectTags: []string{"cross-seed", "priority"},
223+
expectExcludeCategories: nil,
224+
expectExcludeTags: nil,
175225
},
176226
{
177227
name: "exclude categories passed through",
178228
settings: &models.CrossSeedAutomationSettings{
179229
WebhookSourceExcludeCategories: []string{"cross-seed-link", "temp"},
180230
},
181-
expectCategories: nil,
182-
expectTags: nil,
183-
expectExcludeCategories: []string{"cross-seed-link", "temp"},
184-
expectExcludeTags: nil,
231+
expectCategories: nil,
232+
expectTags: nil,
233+
expectExcludeCategories: []string{"cross-seed-link", "temp"},
234+
expectExcludeTags: nil,
185235
},
186236
{
187237
name: "exclude tags passed through",
188238
settings: &models.CrossSeedAutomationSettings{
189239
WebhookSourceExcludeTags: []string{"no-cross-seed", "blocked"},
190240
},
191-
expectCategories: nil,
192-
expectTags: nil,
193-
expectExcludeCategories: nil,
194-
expectExcludeTags: []string{"no-cross-seed", "blocked"},
241+
expectCategories: nil,
242+
expectTags: nil,
243+
expectExcludeCategories: nil,
244+
expectExcludeTags: []string{"no-cross-seed", "blocked"},
195245
},
196246
{
197247
name: "all filters passed through together",
@@ -201,26 +251,26 @@ func TestAutobrrApply_RespectsWebhookSourceFilters(t *testing.T) {
201251
WebhookSourceExcludeCategories: []string{"movies-Race"},
202252
WebhookSourceExcludeTags: []string{"temporary"},
203253
},
204-
expectCategories: []string{"movies-LTS"},
205-
expectTags: []string{"important"},
206-
expectExcludeCategories: []string{"movies-Race"},
207-
expectExcludeTags: []string{"temporary"},
254+
expectCategories: []string{"movies-LTS"},
255+
expectTags: []string{"important"},
256+
expectExcludeCategories: []string{"movies-Race"},
257+
expectExcludeTags: []string{"temporary"},
208258
},
209259
{
210-
name: "nil settings results in empty filters",
211-
settings: nil,
212-
expectCategories: nil,
213-
expectTags: nil,
214-
expectExcludeCategories: nil,
215-
expectExcludeTags: nil,
260+
name: "nil settings results in empty filters",
261+
settings: nil,
262+
expectCategories: nil,
263+
expectTags: nil,
264+
expectExcludeCategories: nil,
265+
expectExcludeTags: nil,
216266
},
217267
{
218-
name: "empty settings results in empty filters",
219-
settings: &models.CrossSeedAutomationSettings{},
220-
expectCategories: nil,
221-
expectTags: nil,
222-
expectExcludeCategories: nil,
223-
expectExcludeTags: nil,
268+
name: "empty settings results in empty filters",
269+
settings: &models.CrossSeedAutomationSettings{},
270+
expectCategories: nil,
271+
expectTags: nil,
272+
expectExcludeCategories: nil,
273+
expectExcludeTags: nil,
224274
},
225275
}
226276

internal/services/crossseed/models.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,4 +384,8 @@ type AutobrrApplyRequest struct {
384384
SkipIfExists *bool `json:"skipIfExists,omitempty"`
385385
// FindIndividualEpisodes overrides the automation-level episode matching behavior when set.
386386
FindIndividualEpisodes *bool `json:"findIndividualEpisodes,omitempty"`
387+
// IndexerName is the display name of the indexer (e.g., "TorrentDB") used when
388+
// "Use indexer name as category" mode is enabled. Without this field, webhook applies
389+
// cannot derive the indexer from the torrent file itself.
390+
IndexerName string `json:"indexerName,omitempty"`
387391
}

internal/services/crossseed/service.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2542,6 +2542,7 @@ func (s *Service) AutobrrApply(ctx context.Context, req *AutobrrApplyRequest) (*
25422542
SkipAutoResume: skipAutoResume,
25432543
SkipRecheck: skipRecheck,
25442544
SkipPieceBoundarySafetyCheck: skipPieceBoundarySafetyCheck,
2545+
IndexerName: req.IndexerName,
25452546
}
25462547
// Pass webhook source filters so CrossSeed respects them when finding candidates
25472548
if settings != nil {
@@ -3115,9 +3116,10 @@ func (s *Service) processCrossSeedCandidate(
31153116
} else {
31163117
result.Message = fmt.Sprintf("Added torrent with recheck (match: %s, category: %s)", matchType, crossCategory)
31173118
}
3118-
log.Debug().
3119+
log.Info().
31193120
Int("instanceID", candidate.InstanceID).
31203121
Str("instanceName", candidate.InstanceName).
3122+
Str("torrentName", torrentName).
31213123
Msg("Successfully added cross-seed torrent with recheck")
31223124
} else {
31233125
if categoryCreationFailed {
@@ -3215,9 +3217,10 @@ func (s *Service) processCrossSeedCandidate(
32153217
// Execute external program if configured (async, non-blocking)
32163218
s.executeExternalProgram(ctx, candidate.InstanceID, torrentHash)
32173219

3218-
logEvent := log.Debug().
3220+
logEvent := log.Info().
32193221
Int("instanceID", candidate.InstanceID).
32203222
Str("instanceName", candidate.InstanceName).
3223+
Str("torrentName", torrentName).
32213224
Str("torrentHash", torrentHash).
32223225
Str("matchedHash", matchedTorrent.Hash).
32233226
Str("matchType", matchType).

internal/web/swagger/openapi.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3032,6 +3032,9 @@ paths:
30323032
type: boolean
30333033
findIndividualEpisodes:
30343034
type: boolean
3035+
indexerName:
3036+
type: string
3037+
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.
30353038
responses:
30363039
'200':
30373040
description: Torrent processed

web/src/pages/CrossSeedPage.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -721,11 +721,16 @@ export function CrossSeedPage({ activeTab, onTabChange }: CrossSeedPageProps) {
721721

722722
useEffect(() => {
723723
if (settings && !globalSettingsInitialized) {
724+
// If all three category modes are false, default to suffix mode
725+
// This handles legacy databases where none were explicitly set
726+
const hasCategoryMode = settings.useCrossCategorySuffix || settings.useCategoryFromIndexer || settings.useCustomCategory
727+
const useCrossCategorySuffix = hasCategoryMode ? (settings.useCrossCategorySuffix ?? false) : true
728+
724729
setGlobalSettings({
725730
findIndividualEpisodes: settings.findIndividualEpisodes,
726731
sizeMismatchTolerancePercent: settings.sizeMismatchTolerancePercent ?? 5.0,
727732
useCategoryFromIndexer: settings.useCategoryFromIndexer ?? false,
728-
useCrossCategorySuffix: settings.useCrossCategorySuffix ?? true,
733+
useCrossCategorySuffix,
729734
useCustomCategory: settings.useCustomCategory ?? false,
730735
customCategory: settings.customCategory ?? "",
731736
runExternalProgramId: settings.runExternalProgramId ?? null,
@@ -823,13 +828,17 @@ export function CrossSeedPage({ activeTab, onTabChange }: CrossSeedPageProps) {
823828

824829
const ignorePatterns = Array.isArray(settings.ignorePatterns) ? settings.ignorePatterns : []
825830

831+
// If all three category modes are false, default to suffix mode
832+
const hasCategoryMode = settings.useCrossCategorySuffix || settings.useCategoryFromIndexer || settings.useCustomCategory
833+
const defaultCrossCategorySuffix = hasCategoryMode ? (settings.useCrossCategorySuffix ?? false) : true
834+
826835
const globalSource = globalSettingsInitialized
827836
? globalSettings
828837
: {
829838
findIndividualEpisodes: settings.findIndividualEpisodes,
830839
sizeMismatchTolerancePercent: settings.sizeMismatchTolerancePercent,
831840
useCategoryFromIndexer: settings.useCategoryFromIndexer,
832-
useCrossCategorySuffix: settings.useCrossCategorySuffix ?? true,
841+
useCrossCategorySuffix: defaultCrossCategorySuffix,
833842
useCustomCategory: settings.useCustomCategory ?? false,
834843
customCategory: settings.customCategory ?? "",
835844
runExternalProgramId: settings.runExternalProgramId ?? null,

0 commit comments

Comments
 (0)