Skip to content

Commit cd1fcc9

Browse files
authored
feat(crossseed): add custom category option for cross-seeds (#907)
1 parent 4dcdf7f commit cd1fcc9

11 files changed

Lines changed: 413 additions & 266 deletions

File tree

README.md

Lines changed: 7 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -367,11 +367,9 @@ Downloaded backups can be imported into any qui instance. Useful for migrating t
367367
qui includes intelligent cross-seeding capabilities that help you automatically find and add matching torrents across different trackers. This allows you to seed the same content on multiple trackers.
368368

369369
> [!NOTE]
370-
> qui adds cross-seeded torrents by inheriting the **Automatic Torrent Management (AutoTMM)** state from the matched torrent. If the matched torrent uses AutoTMM, the cross-seed will too; if the matched torrent has a custom save path (AutoTMM disabled), the cross-seed will use the same explicit path. This reuses existing files directly without creating hardlinks (unless [hardlink mode](#hardlink-mode-optional) is enabled).
370+
> For detailed information about category behavior, save paths, AutoTMM handling, hardlink mode, and best practices, see the [Cross-Seeding Guide](docs/CROSS_SEEDING.md).
371371
>
372-
> For detailed information about category behavior, save paths, hardlink mode, and best practices, see the [Cross-Seeding Guide](docs/CROSS_SEEDING.md).
373-
>
374-
> If you see a cross-seed skipped with “extra files share pieces with content”, see the explanation and options in the guide.
372+
> If you see a cross-seed skipped with "extra files share pieces with content", see the explanation and options in the guide.
375373
376374
### Prerequisites
377375

@@ -419,16 +417,6 @@ Right-click any torrent in the list to access cross-seed actions:
419417
- **Search Cross-Seeds** - Query indexers for matching torrents on other trackers
420418
- **Filter Cross-Seeds** - Show torrents in your library that share content with the selected torrent (useful for identifying existing cross-seeds)
421419

422-
### How qui Differs from cross-seed
423-
424-
qui takes a different approach than the [cross-seed](https://github.com/cross-seed/cross-seed) project:
425-
426-
| Aspect | cross-seed | qui |
427-
|--------|-----------|-----|
428-
| **File handling** | Creates hardlinks/symlinks to a separate directory | Reuse mode (default) + optional hardlink mode |
429-
| **AutoTMM** | Disabled (uses explicit save paths) | Inherits from matched torrent (unless "Use indexer name as category" is enabled) |
430-
| **Category** | Uses dedicated `linkCategory` (e.g., "cross-seed-link") | Uses matched torrent's category with `.cross` suffix (configurable) |
431-
432420
### Rules
433421

434422
Configure matching behavior in the **Rules** tab on the Cross-Seed page.
@@ -441,8 +429,11 @@ Configure matching behavior in the **Rules** tab on the Cross-Seed page.
441429

442430
#### Categories
443431

444-
- **Add .cross category suffix** (default: enabled) - Appends `.cross` to cross-seed categories (e.g., `movies``movies.cross`). This prevents Sonarr/Radarr from importing cross-seeded files as duplicates, since *arr apps typically monitor specific categories. Disable this for full Automatic Torrent Management (AutoTMM) support where cross-seeds should use identical categories and save paths as the source torrent.
445-
- **Use indexer name as category** - Set the qBittorrent category to the indexer name instead of inheriting from the matched torrent. Uses explicit save paths, so AutoTMM is always disabled for these cross-seeds regardless of the source torrent's AutoTMM state (see [how qui differs from cross-seed](#how-qui-differs-from-cross-seed)). Mutually exclusive with `.cross` suffix.
432+
Choose one of three mutually exclusive category modes:
433+
434+
- **Add .cross category suffix** (default) - Appends `.cross` to cross-seed categories (e.g., `movies``movies.cross`). Prevents Sonarr/Radarr from importing cross-seeded files as duplicates. AutoTMM is inherited from the matched torrent.
435+
- **Use indexer name as category** - Sets category to the indexer name (e.g., `TorrentDB`). AutoTMM is always disabled; uses explicit save paths.
436+
- **Custom category** - Uses a fixed category name for all cross-seeds (e.g., `cross-seed`). AutoTMM is always disabled; uses explicit save paths.
446437

447438
#### Source Tagging
448439

docs/CROSS_SEEDING.md

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,9 @@ The piece-boundary safety check (if enabled) applies here too: if extra files sh
191191

192192
## Category Behavior
193193

194-
### The .cross Suffix
194+
Cross-seeds can be assigned a category using one of three modes:
195+
196+
### 1. Category Suffix (default)
195197

196198
When enabled, cross-seeded torrents get a `.cross` suffix on their category:
197199
- Original torrent: `tv` category
@@ -201,15 +203,29 @@ When enabled, cross-seeded torrents get a `.cross` suffix on their category:
201203

202204
The `.cross` category is created automatically with the same save path as the base category.
203205

206+
### 2. Indexer Name as Category
207+
208+
Uses the indexer name (from the search source) as the category. Useful for organizing cross-seeds by tracker.
209+
210+
### 3. Custom Category
211+
212+
Uses a user-specified static category name for all cross-seeds. No suffix is applied—the exact category name you enter is used.
213+
204214
### autoTMM (Auto Torrent Management)
205215

206-
Cross-seeds inherit the autoTMM setting from the matched torrent:
216+
autoTMM behavior depends on which category mode is active:
217+
218+
| Category Mode | autoTMM Behavior |
219+
|---------------|------------------|
220+
| **Suffix** (`.cross`) | Inherited from matched torrent |
221+
| **Indexer name** | Always disabled (explicit save paths) |
222+
| **Custom** | Always disabled (explicit save paths) |
223+
224+
When autoTMM is inherited (suffix mode):
207225
- If matched torrent uses autoTMM, cross-seed uses autoTMM
208226
- If matched torrent has manual path, cross-seed uses same manual path
209227

210-
This ensures files are always saved to the correct location.
211-
212-
**Note:** When "Use indexer name as category" is enabled, autoTMM is always disabled for cross-seeds (explicit save paths are used instead).
228+
When autoTMM is disabled (indexer/custom modes), cross-seeds always use explicit save paths derived from the matched torrent's location.
213229

214230
## Save Path Determination
215231

@@ -284,9 +300,9 @@ The incoming torrent has files not present in your matched torrent, and those fi
284300

285301
### autoTMM unexpectedly enabled/disabled
286302

287-
- This mirrors the matched torrent's setting (intentional)
303+
- In suffix mode, autoTMM mirrors the matched torrent's setting (intentional)
304+
- In indexer name or custom category mode, autoTMM is always disabled
288305
- Check the original torrent's autoTMM status in qBittorrent
289-
- Note: "Use indexer name as category" always disables autoTMM
290306

291307
## ARR Integration (Sonarr/Radarr)
292308

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
-- Add custom category option for cross-seeds
2+
-- When use_custom_category is TRUE, cross-seeds use the exact custom_category value
3+
-- without any automatic suffixing
4+
5+
ALTER TABLE cross_seed_settings ADD COLUMN use_custom_category BOOLEAN NOT NULL DEFAULT 0;
6+
ALTER TABLE cross_seed_settings ADD COLUMN custom_category TEXT DEFAULT '';

internal/models/crossseed.go

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ type CrossSeedAutomationSettings struct {
5959
// Category isolation: add .cross suffix to prevent *arr import loops
6060
UseCrossCategorySuffix bool `json:"useCrossCategorySuffix"` // Add .cross suffix to categories (e.g., movies → movies.cross)
6161

62+
// Custom category: use exact user-specified category without any suffixing
63+
UseCustomCategory bool `json:"useCustomCategory"` // Use custom category instead of suffix or indexer name
64+
CustomCategory string `json:"customCategory"` // Custom category name when UseCustomCategory is true
65+
6266
// Skip auto-resume settings per source mode.
6367
// When enabled, torrents remain paused after hash check instead of auto-resuming.
6468
SkipAutoResumeRSS bool `json:"skipAutoResumeRss"` // Skip auto-resume for RSS automation results
@@ -115,6 +119,9 @@ func DefaultCrossSeedAutomationSettings() *CrossSeedAutomationSettings {
115119
InheritSourceTags: false, // Don't copy source torrent tags by default
116120
// Category isolation - default to true for backwards compatibility
117121
UseCrossCategorySuffix: true,
122+
// Custom category - default to false (use suffix mode by default)
123+
UseCustomCategory: false,
124+
CustomCategory: "",
118125
// Skip auto-resume - default to false to preserve existing behavior
119126
SkipAutoResumeRSS: false,
120127
SkipAutoResumeSeededSearch: false,
@@ -299,6 +306,7 @@ func (s *CrossSeedStore) GetSettings(ctx context.Context) (*CrossSeedAutomationS
299306
use_category_from_indexer, run_external_program_id,
300307
rss_automation_tags, seeded_search_tags, completion_search_tags,
301308
webhook_tags, inherit_source_tags, use_cross_category_suffix,
309+
use_custom_category, custom_category,
302310
skip_auto_resume_rss, skip_auto_resume_seeded_search,
303311
skip_auto_resume_completion, skip_auto_resume_webhook,
304312
skip_recheck, skip_piece_boundary_safety_check,
@@ -349,6 +357,8 @@ func (s *CrossSeedStore) GetSettings(ctx context.Context) (*CrossSeedAutomationS
349357
&webhookTags,
350358
&settings.InheritSourceTags,
351359
&settings.UseCrossCategorySuffix,
360+
&settings.UseCustomCategory,
361+
&settings.CustomCategory,
352362
&settings.SkipAutoResumeRSS,
353363
&settings.SkipAutoResumeSeededSearch,
354364
&settings.SkipAutoResumeCompletion,
@@ -526,12 +536,13 @@ func (s *CrossSeedStore) UpsertSettings(ctx context.Context, settings *CrossSeed
526536
use_category_from_indexer, run_external_program_id,
527537
rss_automation_tags, seeded_search_tags, completion_search_tags,
528538
webhook_tags, inherit_source_tags, use_cross_category_suffix,
539+
use_custom_category, custom_category,
529540
skip_auto_resume_rss, skip_auto_resume_seeded_search,
530541
skip_auto_resume_completion, skip_auto_resume_webhook,
531542
skip_recheck, skip_piece_boundary_safety_check,
532543
use_hardlinks, hardlink_base_dir, hardlink_dir_preset
533544
) VALUES (
534-
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
545+
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
535546
)
536547
ON CONFLICT(id) DO UPDATE SET
537548
enabled = excluded.enabled,
@@ -560,6 +571,8 @@ func (s *CrossSeedStore) UpsertSettings(ctx context.Context, settings *CrossSeed
560571
webhook_tags = excluded.webhook_tags,
561572
inherit_source_tags = excluded.inherit_source_tags,
562573
use_cross_category_suffix = excluded.use_cross_category_suffix,
574+
use_custom_category = excluded.use_custom_category,
575+
custom_category = excluded.custom_category,
563576
skip_auto_resume_rss = excluded.skip_auto_resume_rss,
564577
skip_auto_resume_seeded_search = excluded.skip_auto_resume_seeded_search,
565578
skip_auto_resume_completion = excluded.skip_auto_resume_completion,
@@ -615,6 +628,8 @@ func (s *CrossSeedStore) UpsertSettings(ctx context.Context, settings *CrossSeed
615628
webhookTags,
616629
settings.InheritSourceTags,
617630
settings.UseCrossCategorySuffix,
631+
settings.UseCustomCategory,
632+
settings.CustomCategory,
618633
settings.SkipAutoResumeRSS,
619634
settings.SkipAutoResumeSeededSearch,
620635
settings.SkipAutoResumeCompletion,

internal/services/crossseed/auto_tmm_test.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ func TestShouldEnableAutoTMM(t *testing.T) {
1212
crossCategory string
1313
matchedAutoManaged bool
1414
useCategoryFromIndexer bool
15+
useCustomCategory bool
1516
actualCategorySavePath string
1617
matchedSavePath string
1718
wantEnabled bool
@@ -22,6 +23,7 @@ func TestShouldEnableAutoTMM(t *testing.T) {
2223
crossCategory: "tv.cross",
2324
matchedAutoManaged: true,
2425
useCategoryFromIndexer: false,
26+
useCustomCategory: false,
2527
actualCategorySavePath: "/downloads/tv",
2628
matchedSavePath: "/downloads/tv",
2729
wantEnabled: true,
@@ -32,6 +34,7 @@ func TestShouldEnableAutoTMM(t *testing.T) {
3234
crossCategory: "",
3335
matchedAutoManaged: true,
3436
useCategoryFromIndexer: false,
37+
useCustomCategory: false,
3538
actualCategorySavePath: "/downloads/tv",
3639
matchedSavePath: "/downloads/tv",
3740
wantEnabled: false,
@@ -42,6 +45,7 @@ func TestShouldEnableAutoTMM(t *testing.T) {
4245
crossCategory: "tv.cross",
4346
matchedAutoManaged: false,
4447
useCategoryFromIndexer: false,
48+
useCustomCategory: false,
4549
actualCategorySavePath: "/downloads/tv",
4650
matchedSavePath: "/downloads/tv",
4751
wantEnabled: false,
@@ -52,6 +56,18 @@ func TestShouldEnableAutoTMM(t *testing.T) {
5256
crossCategory: "tv.cross",
5357
matchedAutoManaged: true,
5458
useCategoryFromIndexer: true,
59+
useCustomCategory: false,
60+
actualCategorySavePath: "/downloads/tv",
61+
matchedSavePath: "/downloads/tv",
62+
wantEnabled: false,
63+
wantPathsMatch: true,
64+
},
65+
{
66+
name: "using custom category - disabled",
67+
crossCategory: "cross-seed",
68+
matchedAutoManaged: true,
69+
useCategoryFromIndexer: false,
70+
useCustomCategory: true,
5571
actualCategorySavePath: "/downloads/tv",
5672
matchedSavePath: "/downloads/tv",
5773
wantEnabled: false,
@@ -62,6 +78,7 @@ func TestShouldEnableAutoTMM(t *testing.T) {
6278
crossCategory: "tv.cross",
6379
matchedAutoManaged: true,
6480
useCategoryFromIndexer: false,
81+
useCustomCategory: false,
6582
actualCategorySavePath: "/downloads/tv",
6683
matchedSavePath: "/downloads/movies",
6784
wantEnabled: true,
@@ -72,6 +89,7 @@ func TestShouldEnableAutoTMM(t *testing.T) {
7289
crossCategory: "tv.cross",
7390
matchedAutoManaged: true,
7491
useCategoryFromIndexer: false,
92+
useCustomCategory: false,
7593
actualCategorySavePath: "",
7694
matchedSavePath: "/downloads/tv",
7795
wantEnabled: true,
@@ -82,6 +100,7 @@ func TestShouldEnableAutoTMM(t *testing.T) {
82100
crossCategory: "tv.cross",
83101
matchedAutoManaged: true,
84102
useCategoryFromIndexer: false,
103+
useCustomCategory: false,
85104
actualCategorySavePath: "/downloads/tv",
86105
matchedSavePath: "",
87106
wantEnabled: true,
@@ -92,6 +111,7 @@ func TestShouldEnableAutoTMM(t *testing.T) {
92111
crossCategory: "tv.cross",
93112
matchedAutoManaged: true,
94113
useCategoryFromIndexer: false,
114+
useCustomCategory: false,
95115
actualCategorySavePath: "",
96116
matchedSavePath: "",
97117
wantEnabled: true,
@@ -102,6 +122,7 @@ func TestShouldEnableAutoTMM(t *testing.T) {
102122
crossCategory: "tv.cross",
103123
matchedAutoManaged: true,
104124
useCategoryFromIndexer: false,
125+
useCustomCategory: false,
105126
actualCategorySavePath: "/downloads/tv/",
106127
matchedSavePath: "/downloads/tv",
107128
wantEnabled: true,
@@ -112,6 +133,7 @@ func TestShouldEnableAutoTMM(t *testing.T) {
112133
crossCategory: "tv.cross",
113134
matchedAutoManaged: true,
114135
useCategoryFromIndexer: false,
136+
useCustomCategory: false,
115137
actualCategorySavePath: "C:\\downloads\\tv",
116138
matchedSavePath: "C:/downloads/tv",
117139
wantEnabled: true,
@@ -122,6 +144,7 @@ func TestShouldEnableAutoTMM(t *testing.T) {
122144
crossCategory: "",
123145
matchedAutoManaged: false,
124146
useCategoryFromIndexer: true,
147+
useCustomCategory: false,
125148
actualCategorySavePath: "",
126149
matchedSavePath: "",
127150
wantEnabled: false,
@@ -135,6 +158,7 @@ func TestShouldEnableAutoTMM(t *testing.T) {
135158
tt.crossCategory,
136159
tt.matchedAutoManaged,
137160
tt.useCategoryFromIndexer,
161+
tt.useCustomCategory,
138162
tt.actualCategorySavePath,
139163
tt.matchedSavePath,
140164
)

internal/services/crossseed/service.go

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2958,10 +2958,11 @@ func (s *Service) processCrossSeedCandidate(
29582958
options["contentLayout"] = "Original"
29592959
}
29602960

2961-
// Check if UseCategoryFromIndexer is enabled (affects TMM decision)
2962-
var useCategoryFromIndexer bool
2961+
// Check if UseCategoryFromIndexer or UseCustomCategory is enabled (affects TMM decision)
2962+
var useCategoryFromIndexer, useCustomCategory bool
29632963
if settings, err := s.GetAutomationSettings(ctx); err == nil && settings != nil {
29642964
useCategoryFromIndexer = settings.UseCategoryFromIndexer
2965+
useCustomCategory = settings.UseCustomCategory
29652966
}
29662967

29672968
// Determine save path strategy:
@@ -3015,7 +3016,7 @@ func (s *Service) processCrossSeedCandidate(
30153016
}
30163017

30173018
// Evaluate whether autoTMM should be enabled
3018-
tmmDecision := shouldEnableAutoTMM(crossCategory, matchedTorrent.AutoManaged, useCategoryFromIndexer, actualCategorySavePath, props.SavePath)
3019+
tmmDecision := shouldEnableAutoTMM(crossCategory, matchedTorrent.AutoManaged, useCategoryFromIndexer, useCustomCategory, actualCategorySavePath, props.SavePath)
30193020
if forceManualSavePath {
30203021
tmmDecision.Enabled = false
30213022
}
@@ -3025,6 +3026,7 @@ func (s *Service) processCrossSeedCandidate(
30253026
Str("crossCategory", tmmDecision.CrossCategory).
30263027
Bool("matchedAutoManaged", tmmDecision.MatchedAutoManaged).
30273028
Bool("useIndexerCategory", tmmDecision.UseIndexerCategory).
3029+
Bool("useCustomCategory", tmmDecision.UseCustomCategory).
30283030
Str("categorySavePath", tmmDecision.CategorySavePath).
30293031
Str("matchedSavePath", tmmDecision.MatchedSavePath).
30303032
Bool("pathsMatch", tmmDecision.PathsMatch).
@@ -7148,6 +7150,7 @@ type autoTMMDecision struct {
71487150
CrossCategory string
71497151
MatchedAutoManaged bool
71507152
UseIndexerCategory bool
7153+
UseCustomCategory bool
71517154
CategorySavePath string
71527155
MatchedSavePath string
71537156
PathsMatch bool
@@ -7157,29 +7160,31 @@ type autoTMMDecision struct {
71577160
// autoTMM can only be enabled when:
71587161
// - A cross-seed category was successfully created (crossCategory != "")
71597162
// - The matched torrent uses autoTMM
7160-
// - Not using indexer categories (which may have different paths)
7163+
// - Not using indexer categories or custom categories (which may have different paths)
71617164
//
71627165
// We trust qBittorrent's implicit path calculation when categories don't have explicit
71637166
// save paths configured. If the matched torrent is using autoTMM successfully, the
71647167
// cross-seed should inherit that setting.
71657168
//
71667169
// Returns the decision struct for logging and whether autoTMM should be enabled.
7167-
func shouldEnableAutoTMM(crossCategory string, matchedAutoManaged bool, useCategoryFromIndexer bool, actualCategorySavePath string, matchedSavePath string) autoTMMDecision {
7170+
func shouldEnableAutoTMM(crossCategory string, matchedAutoManaged bool, useCategoryFromIndexer bool, useCustomCategory bool, actualCategorySavePath string, matchedSavePath string) autoTMMDecision {
71687171
// Check if explicit category save path matches the matched torrent's path (informational).
71697172
pathsMatch := actualCategorySavePath != "" && matchedSavePath != "" &&
71707173
normalizePath(actualCategorySavePath) == normalizePath(matchedSavePath)
71717174

7172-
// Enable autoTMM if the matched torrent uses autoTMM and we're not using indexer categories.
7175+
// Enable autoTMM if the matched torrent uses autoTMM and we're not using indexer or custom categories.
71737176
// When a category has no explicit save path, qBittorrent uses an implicit path:
71747177
// <default_save_path>/<category_name>. Since the matched torrent is already using
71757178
// autoTMM successfully at its current path, the cross-seed should too.
7176-
enabled := crossCategory != "" && matchedAutoManaged && !useCategoryFromIndexer
7179+
// Custom categories disable autoTMM like indexer categories for safety.
7180+
enabled := crossCategory != "" && matchedAutoManaged && !useCategoryFromIndexer && !useCustomCategory
71777181

71787182
return autoTMMDecision{
71797183
Enabled: enabled,
71807184
CrossCategory: crossCategory,
71817185
MatchedAutoManaged: matchedAutoManaged,
71827186
UseIndexerCategory: useCategoryFromIndexer,
7187+
UseCustomCategory: useCustomCategory,
71837188
CategorySavePath: actualCategorySavePath,
71847189
MatchedSavePath: matchedSavePath,
71857190
PathsMatch: pathsMatch,
@@ -7317,6 +7322,11 @@ func (s *Service) determineCrossSeedCategory(ctx context.Context, req *CrossSeed
73177322
settings, _ = s.GetAutomationSettings(ctx)
73187323
}
73197324

7325+
// Custom category takes priority - use exact category without any suffix
7326+
if settings != nil && settings.UseCustomCategory && settings.CustomCategory != "" {
7327+
return settings.CustomCategory, settings.CustomCategory
7328+
}
7329+
73207330
// Helper to apply .cross suffix only if enabled in settings
73217331
applySuffix := func(cat string) string {
73227332
if settings != nil && !settings.UseCrossCategorySuffix {

web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"@radix-ui/react-label": "^2.1.8",
3333
"@radix-ui/react-popover": "^1.1.15",
3434
"@radix-ui/react-progress": "^1.1.8",
35+
"@radix-ui/react-radio-group": "^1.3.8",
3536
"@radix-ui/react-scroll-area": "^1.2.10",
3637
"@radix-ui/react-select": "^2.2.6",
3738
"@radix-ui/react-separator": "^1.1.8",

0 commit comments

Comments
 (0)