Skip to content

feat(cross-seed): add "by-tracker" directory preset with per-indexer category mapping#1783

Open
CjE5 wants to merge 2 commits into
autobrr:developfrom
CjE5:develop
Open

feat(cross-seed): add "by-tracker" directory preset with per-indexer category mapping#1783
CjE5 wants to merge 2 commits into
autobrr:developfrom
CjE5:develop

Conversation

@CjE5
Copy link
Copy Markdown
Contributor

@CjE5 CjE5 commented Apr 18, 2026

This reverts commit 54fa047.

Trying this again. See: #1722
@s0up4200 any chance you could share the context from the channel in regards to the issues encountered? I think it's locked and I can't access it. I'd like to fix whatever issues seen and add required tests.


When a qBittorrent instance's directory organisation is set to "by-tracker", cross-seeds are assigned a category based on the (instance, indexer) mapping table rather than the global category mode. If no mapping is configured for the indexer, the existing global logic (custom → indexer-name → affix → reuse) is used as a fallback.

AutoTMM is enabled automatically when the mapped category has a configured save path in qBittorrent, ensuring files land in the right directory without moving the hardlinks.

The per-tracker mapping UI is shown inline under the instance's "Directory organisation" setting when "By Tracker" is selected.

A proxy-path endpoint (GET /api/v2/cross-seed/indexer-categories) is registered alongside the existing qBittorrent proxy routes so external cross-seed clients can resolve per-tracker categories using only the proxy key — no user API key required.

The same per-tracker category resolution and AutoTMM logic applies to directory-scan injections, not only the automation/webhook path.

Test coverage added for the indexer-category store (12 tests covering CRUD, upsert, whitespace trimming, case-insensitive lookup, instance isolation, and cascade delete), the proxy handler (4 tests), the autoTMM decision (3 new cases), and the announce-domain-to-indexer matching logic (20 cases).

#1631

Summary by CodeRabbit

  • New Features

    • Per-instance indexer→category mappings: DB migration, store, API endpoints (GET/PUT/DELETE), proxy listing, client methods, and a UI editor to manage tracker-specific category overrides.
    • "By-tracker" placement mode: tracker-aware category resolution, mapped save-path handling, and integrated placement logic for link modes.
  • Bug Fixes

    • Improved tracker→indexer domain matching (aliases, normalization) and more consistent AutoTMM, save-path selection, rollback, and path-mapping behavior.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 18, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds per-instance cross-seed indexer→category mappings: DB migrations, a new store and model, announce-domain↔indexer matching and aliasing, service and dirscan integration for tracker-category mode, new API/proxy endpoints plus frontend UI, and tests across backend and frontend.

Changes

Cohort / File(s) Summary
Database Migrations
internal/database/migrations/071_add_tracker_category_settings.sql, internal/database/postgres_migrations/072_add_tracker_category_settings.sql
Add cross_seed_indexer_categories table with composite PK (instance_id,indexer_id), FKs, category, and updated_at.
Models & Tests
internal/models/crossseed_indexer_categories.go, internal/models/crossseed_indexer_categories_test.go
New CrossSeedIndexerCategoryStore with List/GetByIndexerName/Set/Delete, ErrIndexerCategoryForeignKey, constructor panic-on-nil DB, and comprehensive unit tests.
API Handlers & Server
internal/api/handlers/crossseed.go, internal/api/server.go, cmd/qui/main.go, internal/api/server_test.go
Wire new store into server deps and handlers; add /api/cross-seed/indexer-categories CRUD routes, instance validation, and error mappings.
Proxy & Proxy Tests
internal/proxy/handler.go, internal/proxy/handler_test.go, internal/proxy/crossseed_indexer_categories_test.go
Add indexerCategoryStore dependency, GET /proxy/{api-key}/api/v2/cross-seed/indexer-categories, handler to list mappings for instance from API key, and tests.
Cross-Seed Service & Matching
internal/services/crossseed/service.go, internal/services/crossseed/tracker_category.go, internal/services/crossseed/domain_aliases.go, tests...
Introduce announce-domain↔indexer matching, alias map, ResolveTrackerCategory, update determineCrossSeedCategory signature/flow, change AutoTMM decisioning, and add related unit tests.
Dirscan Integration & Inject
internal/services/dirscan/service.go, internal/services/dirscan/inject.go, internal/services/dirscan/service_tracker_category_test.go, internal/services/dirscan/*_test.go
Add indexerCategoryStore to dirscan service, TrackerCategorySavePath on InjectRequest, path-mapping helpers, preserveRoot rollback flag, and tracker-category-aware materialization and resolution; tests added.
Link/Hardlink/Reflink Tests & Logic
internal/services/crossseed/*_test.go, internal/services/dirscan/*_test.go
Multiple new and updated tests covering tracker-category mode, AutoTMM decisions, category persistence path selection, and resolver behavior.
Misc Test Callsite Updates
internal/api/handlers/dirscan_webhook_test.go, internal/api/server_test.go, internal/proxy/handler_test.go, internal/services/dirscan/*_test.go
Updated NewService/NewHandler callsites in tests to pass additional constructor parameter (commonly nil).
Frontend & OpenAPI
internal/web/swagger/openapi.yaml, web/src/lib/api.ts, web/src/types/index.ts, web/src/pages/CrossSeedPage.tsx
OpenAPI: add schemas/endpoints for indexer-category CRUD and tighten instanceId validation. Frontend: add CrossSeedIndexerCategory type, API client methods, per-instance tracker-category mappings editor, and category-mode normalization helper.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Client
    participant API as HTTP API
    participant Handler as CrossSeedHandler
    participant Store as CrossSeedIndexerCategoryStore
    participant DB as Database

    Client->>API: GET /api/cross-seed/indexer-categories/{instanceId}
    API->>Handler: GetInstanceIndexerCategories(w,r)
    Handler->>Handler: parse & validate instanceId
    Handler->>Store: List(ctx, instanceId)
    Store->>DB: SELECT ... JOIN ...
    DB-->>Store: rows
    Store-->>Handler: []CrossSeedIndexerCategory
    Handler-->>Client: 200 + JSON
Loading
sequenceDiagram
    participant Dirscan as Dirscan Service
    participant Crossseed as CrossSeed Service
    participant Store as CrossSeedIndexerCategoryStore
    participant Qbit as qBittorrent
    participant Disk as Host FS

    Dirscan->>Crossseed: ResolveTrackerCategory(ctx, instanceId, indexerName, announceDomain)
    Crossseed->>Store: List/GetByIndexerName(ctx, instanceId, indexerName)
    Store-->>Crossseed: category (or empty)
    alt category found
        Crossseed->>Qbit: GetCategories()
        Qbit-->>Crossseed: categories map (contains SavePath)
        Crossseed-->>Dirscan: (category, savePath)
        Dirscan->>Disk: mapTrackerCategorySavePathToHost(savePath)
        Disk-->>Dirscan: host path
        Dirscan->>Dirscan: Inject with TrackerCategorySavePath (autoTMM=true)
    else not found
        Crossseed-->>Dirscan: ("", "")
        Dirscan->>Dirscan: fallback injection path
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested labels

database:migrations, cross-seed

Suggested reviewers

  • s0up4200

Poem

🐰 I hopped through migrations, stores, and keys,
I matched tracker domains with delicate ease.
Per-instance maps and UI to impart,
Backend and frontend stitched heart to heart.
Hooray — little seeds find their rightful start 🌱

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 24.73% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly summarizes the main change: adding a 'by-tracker' directory preset feature with per-indexer category mapping for cross-seed functionality.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
internal/services/crossseed/service.go (2)

11214-11223: ⚠️ Potential issue | 🟠 Major

Disable AutoTMM when category creation fails.

placement.EnableAutoTMM is computed before ensureCrossCategory. If category creation fails, crossCategory is cleared, but the later add options can still send autoTMM=true with no category and no explicit savepath, so qBittorrent can place the torrent in its default managed location instead of plan.RootDir.

🐛 Proposed fix
-	if placement.EnableAutoTMM {
+	if placement.EnableAutoTMM && crossCategory != "" {
 		options["autoTMM"] = "true"
 	} else {
 		options["autoTMM"] = "false"
 		options["savepath"] = plan.RootDir
 	}

Apply the same guard in both processHardlinkMode and processReflinkMode.

Also applies to: 11328-11333, 11841-11849, 11944-11949

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/services/crossseed/service.go` around lines 11214 - 11223, The code
computes placement.EnableAutoTMM before calling s.ensureCrossCategory and clears
crossCategory on failure, but never turns off AutoTMM, allowing qBittorrent to
auto-manage placement; update the logic in processHardlinkMode and
processReflinkMode (and the analogous locations noted) so that if
ensureCrossCategory returns an error (categoryCreationFailed true) you set
placement.EnableAutoTMM = false (or otherwise disable AutoTMM on the
placement/options object you later pass to AddTorrent/AddTorrentFromFile),
ensuring no auto-TMM is sent when crossCategory was cleared; locate uses of
ensureCrossCategory, placement.EnableAutoTMM, and the add-option construction in
those functions and apply the same guard in all listed blocks.

11750-11804: ⚠️ Potential issue | 🟠 Major

Use the tracker category save path as the reflink base when available.

Hardlink mode already validates and uses categorySavePath directly, but reflink mode still requires instance.HardlinkBaseDir and checks reflink support on that directory before resolveLinkModeCategoryPlacement later chooses the category path as destDir. A by-tracker category with a valid configured save path can therefore be rejected because HardlinkBaseDir is empty/wrong, or the wrong filesystem can be validated before cloning into categorySavePath.

🐛 Proposed fix
-	// Validate base directory is configured (reuses hardlink base dir)
-	if instance.HardlinkBaseDir == "" {
-		log.Warn().
-			Int("instanceID", candidate.InstanceID).
-			Msg("[CROSSSEED] Reflink mode enabled but base directory is empty")
-		return handleError("Reflink mode enabled but base directory is not configured")
-	}
-
 	// Verify instance has local filesystem access (required for reflinks)
 	if !instance.HasLocalFilesystemAccess {
 		log.Warn().
@@
-	selectedBaseDir, err := FindMatchingBaseDir(instance.HardlinkBaseDir, existingFilePath)
-	if err != nil {
-		log.Warn().
-			Err(err).
-			Str("configuredDirs", instance.HardlinkBaseDir).
-			Str("existingPath", existingFilePath).
-			Msg("[CROSSSEED] Reflink mode: no suitable base directory found")
-		return handleError(fmt.Sprintf("No suitable base directory: %v", err))
-	}
+	var selectedBaseDir string
+	if categorySavePath != "" {
+		if err := os.MkdirAll(categorySavePath, 0o755); err != nil {
+			return handleError(fmt.Sprintf("Failed to create category directory %q: %v", categorySavePath, err))
+		}
+		sameFS, err := fsutil.SameFilesystem(existingFilePath, categorySavePath)
+		if err != nil {
+			return handleError(fmt.Sprintf("Failed to check filesystem for category path %q: %v", categorySavePath, err))
+		}
+		if !sameFS {
+			return handleError(fmt.Sprintf("Category save path %q is not on the same filesystem as source files — reflinks require compatible source/destination filesystems", categorySavePath))
+		}
+		selectedBaseDir = categorySavePath
+	} else {
+		if instance.HardlinkBaseDir == "" {
+			log.Warn().
+				Int("instanceID", candidate.InstanceID).
+				Msg("[CROSSSEED] Reflink mode enabled but base directory is empty")
+			return handleError("Reflink mode enabled but base directory is not configured")
+		}
+
+		selectedBaseDir, err = FindMatchingBaseDir(instance.HardlinkBaseDir, existingFilePath)
+		if err != nil {
+			log.Warn().
+				Err(err).
+				Str("configuredDirs", instance.HardlinkBaseDir).
+				Str("existingPath", existingFilePath).
+				Msg("[CROSSSEED] Reflink mode: no suitable base directory found")
+			return handleError(fmt.Sprintf("No suitable base directory: %v", err))
+		}
+	}
 
 	// Check reflink support
 	supported, reason := reflinktree.SupportsReflink(selectedBaseDir)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/services/crossseed/service.go` around lines 11750 - 11804, The
reflink validation should prefer the tracker category's save path when present:
when building existingFilePath and selecting the base for reflink checks, if a
non-empty categorySavePath (the tracker category save path used later by
resolveLinkModeCategoryPlacement as destDir) is available use that as the base
to call reflinktree.SupportsReflink and FindMatchingBaseDir; otherwise fall back
to instance.HardlinkBaseDir as before. Update the logic around
matchedTorrent.ContentPath/props.SavePath and the selectedBaseDir selection
(where FindMatchingBaseDir is called and reflinktree.SupportsReflink is checked)
to try categorySavePath first, then instance.HardlinkBaseDir, and keep existing
warnings/handleError calls when neither yields a valid base or reflinks
unsupported.
🧹 Nitpick comments (5)
internal/database/migrations/071_add_tracker_category_settings.sql (1)

15-15: Nit: DEFAULT '' on category is unreachable.

CrossSeedIndexerCategoryStore.Set trims and rejects blank categories before inserting, and it's the only writer on this table. The default value can never be stored, so DEFAULT '' is dead; consider dropping it (and the companion default on the Postgres migration) to avoid confusion about whether blank mappings are valid.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/database/migrations/071_add_tracker_category_settings.sql` at line
15, The migration currently defines the column "category TEXT NOT NULL DEFAULT
''", but CrossSeedIndexerCategoryStore.Set trims and rejects blank categories so
the empty-string default is never used; remove the DEFAULT '' from the column
definition in this migration (and remove the matching DEFAULT '' in the Postgres
migration counterpart) so the schema doesn't imply blank categories are
valid—keep NOT NULL but drop the default to avoid confusion with
CrossSeedIndexerCategoryStore.Set behavior.
internal/proxy/crossseed_indexer_categories_test.go (1)

81-163: Consider table-driving these handler cases.

The status/error/success tests repeat the same request, recorder, handler call, and decode flow. A small table with per-case setup would better match the repo test style and make adding more proxy endpoint cases cheaper.

♻️ Possible shape
+func TestHandleCrossSeedIndexerCategories(t *testing.T) {
+	tests := []struct {
+		name           string
+		setup          func(t *testing.T) (*Handler, int)
+		wantStatus     int
+		wantErr        bool
+		wantMappings   int
+	}{
+		// missing instance, nil store, empty slice, populated mapping...
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			h, instanceID := tc.setup(t)
+			req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/v2/cross-seed/indexer-categories", nil)
+			req = req.WithContext(context.WithValue(req.Context(), InstanceIDContextKey, instanceID))
+
+			rec := httptest.NewRecorder()
+			h.handleCrossSeedIndexerCategories(rec, req)
+
+			require.Equal(t, tc.wantStatus, rec.Code)
+			// decode error or mappings based on tc.wantErr
+		})
+	}
+}

As per coding guidelines, **/*_test.go: “Use table-driven test cases and reuse integration fixtures from internal/qbittorrent/”.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/proxy/crossseed_indexer_categories_test.go` around lines 81 - 163,
The tests for handleCrossSeedIndexerCategories are repetitive—refactor
TestHandleCrossSeedIndexerCategories_* into a single table-driven test that
iterates cases (e.g., "missing instance id", "nil store", "empty slice",
"returns mappings"); for each case define a name, optional store setup (use
setupIndexerCategoryProxyDB, models.NewCrossSeedIndexerCategoryStore,
insertProxyTestInstance, insertProxyTestIndexer, store.Set where needed), the
context InstanceID value, expected HTTP status and a post-check function for the
decoded body; inside the loop create the request, attach context, create
httptest.NewRecorder(), call h.handleCrossSeedIndexerCategories, assert status
and Content-Type, then decode and run the per-case assertions—this consolidates
repeated request/recorder/handler call/decode logic while keeping existing
helpers and the handleCrossSeedIndexerCategories target unchanged.
internal/models/crossseed_indexer_categories_test.go (1)

160-214: Consider consolidating the repeated lookup cases into table-driven tests.

These cases share the same setup/assertion shape and would be easier to extend as []struct{name, input, want string} with t.Run. As per coding guidelines, **/*_test.go: “Use table-driven test cases and reuse integration fixtures from internal/qbittorrent/”.

Also applies to: 235-265

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/models/crossseed_indexer_categories_test.go` around lines 160 - 214,
Consolidate the repeated GetByIndexerName test cases into table-driven subtests:
replace the multiple functions
TestCrossSeedIndexerCategoryStore_GetByIndexerName,
TestCrossSeedIndexerCategoryStore_GetByIndexerName_CaseInsensitive,
TestCrossSeedIndexerCategoryStore_GetByIndexerName_NotFound, and
TestCrossSeedIndexerCategoryStore_GetByIndexerName_EmptyName with a single
TestCrossSeedIndexerCategoryStore_GetByIndexerName that defines a []struct{name
string; instanceID int; setup bool; input string; want string} (or similar) and
iterates with t.Run; reuse the common setup logic (db :=
setupIndexerCategoryTestDB, store := models.NewCrossSeedIndexerCategoryStore,
ctx := context.Background()) and call store.Set only for rows that need
pre-inserted data (reference insertTestInstance, insertTestTorznabIndexer, and
store.Set), then assert results for each case (expecting empty string or
"Aither") to cover case-insensitive, not-found and empty-name scenarios.
internal/services/dirscan/service.go (1)

1786-1825: Extract the by-tracker resolution branch from tryMatchAndInject.

This branch is correct, but it adds another responsibility to an already long match/download/inject function. A small helper returning (category, trackerCategorySavePath string, failed *searcheeMatch) would keep the injection path easier to scan. As per coding guidelines, Go code is checked with golangci-lint v2 using “funlen function length threshold at 80 lines” and “gocognit cognitive complexity threshold at 15”.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/services/dirscan/service.go` around lines 1786 - 1825, Extract the
"by-tracker" resolution branch out of tryMatchAndInject into a small helper
function (e.g., resolveByTrackerCategory) that returns (category string,
trackerCategorySavePath string, failed *searcheeMatch); move the logic that
checks s.indexerCategoryStore, loads the instance via s.instanceStore.Get, calls
resolveDirscanTrackerCategory, and builds the failMatch searcheeMatch into that
helper, preserving the same parameters used here (ctx, dir.TargetInstanceID,
result.Indexer, announceDomain, instance, s.indexerCategoryStore, s.syncManager,
l) and the same failure/fatal handling; update tryMatchAndInject to call the new
helper, assign returned category and trackerCategorySavePath if non-empty, and
if failed != nil return it, ensuring behavior is identical but reducing
tryMatchAndInject's length and cognitive complexity.
internal/services/dirscan/inject.go (1)

670-762: Consider extracting tracker-category destination resolution from materializeLinkTree.

materializeLinkTree now spans roughly 90+ lines and mixes base-dir mapping, destination selection, plan diagnostics, and link creation. Extracting the tracker/non-tracker destination selection into a helper would keep this under the repo’s lint threshold and make the new branch easier to test. As per coding guidelines, “Use golangci-lint v2 with strict configuration targeting AI-generated code patterns, with funlen function length threshold at 80 lines”.

Also applies to: 801-810

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/services/dirscan/inject.go` around lines 670 - 762, The
materializeLinkTree function mixes tracker-category mapping, base-dir selection,
and destDir creation making it too long; extract the tracker vs non-tracker
destination resolution into a helper (e.g., resolveLinkDestination or
selectLinkDestination) that accepts (ctx, instance, req,
incomingFiles/existingFiles) and returns (selectedBaseDir, destDir, error),
encapsulating the mapTrackerCategorySavePathToHost call, the
crossseed.FindMatchingBaseDir call, the os.MkdirAll calls, and the
buildLinkDestDir invocation; update materializeLinkTree to call this helper and
use its returned selectedBaseDir and destDir before calling
hardlinktree.BuildPlan so the main function is shortened and easier to test.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/services/crossseed/auto_tmm_test.go`:
- Around line 170-181: The test case named "tracker category mode with
mismatched paths - auto tmm disabled" is asserting AutoTMM is disabled even
though the mapped tracker category has a configured save path; update the
expectation to match by-tracker AutoTMM semantics by setting wantEnabled to true
(AutoTMM enabled) while keeping wantPathsMatch false, and ensure the test still
uses isTrackerCategoryMode, actualCategorySavePath, and matchedSavePath to
reflect the mismatched paths scenario.

In `@internal/services/crossseed/service.go`:
- Around line 10111-10118: The ResolveTrackerCategory call is treating a
store/context error (resolveErr) as “no mapping” and falling back to other
category rules; instead surface resolveErr to the caller so the inject can
fail/retry. Change the branch in the block around ResolveTrackerCategory (the
variables cat, found, resolveErr and s.indexerCategoryStore) to return or
propagate resolveErr to processCrossSeedCandidate rather than logging and
continuing, and update processCrossSeedCandidate’s caller chain to handle/return
that error appropriately so mapping-store failures are not treated as “no
mapping.”

In `@internal/services/crossseed/tracker_category.go`:
- Around line 93-99: The current containment fallback in tracker_category.go
(checking strings.Contains between normalizedIndexer and domainNorm)
reintroduces false positives (e.g., "notbeyondhd" containing "beyondhd");
replace this loose substring check with a boundary-aware match: only treat a
match as true if normalizedIndexer appears in domainNorm with non-alphanumeric
boundaries (or at the string edges) — e.g., use a regex or explicit checks for
separators/edges around the match — and remove the plain strings.Contains
fallback; for domains that truly need non-textual mapping, add explicit entries
in internal/services/crossseed/domain_aliases.go instead of relying on substring
containment.

---

Outside diff comments:
In `@internal/services/crossseed/service.go`:
- Around line 11214-11223: The code computes placement.EnableAutoTMM before
calling s.ensureCrossCategory and clears crossCategory on failure, but never
turns off AutoTMM, allowing qBittorrent to auto-manage placement; update the
logic in processHardlinkMode and processReflinkMode (and the analogous locations
noted) so that if ensureCrossCategory returns an error (categoryCreationFailed
true) you set placement.EnableAutoTMM = false (or otherwise disable AutoTMM on
the placement/options object you later pass to AddTorrent/AddTorrentFromFile),
ensuring no auto-TMM is sent when crossCategory was cleared; locate uses of
ensureCrossCategory, placement.EnableAutoTMM, and the add-option construction in
those functions and apply the same guard in all listed blocks.
- Around line 11750-11804: The reflink validation should prefer the tracker
category's save path when present: when building existingFilePath and selecting
the base for reflink checks, if a non-empty categorySavePath (the tracker
category save path used later by resolveLinkModeCategoryPlacement as destDir) is
available use that as the base to call reflinktree.SupportsReflink and
FindMatchingBaseDir; otherwise fall back to instance.HardlinkBaseDir as before.
Update the logic around matchedTorrent.ContentPath/props.SavePath and the
selectedBaseDir selection (where FindMatchingBaseDir is called and
reflinktree.SupportsReflink is checked) to try categorySavePath first, then
instance.HardlinkBaseDir, and keep existing warnings/handleError calls when
neither yields a valid base or reflinks unsupported.

---

Nitpick comments:
In `@internal/database/migrations/071_add_tracker_category_settings.sql`:
- Line 15: The migration currently defines the column "category TEXT NOT NULL
DEFAULT ''", but CrossSeedIndexerCategoryStore.Set trims and rejects blank
categories so the empty-string default is never used; remove the DEFAULT '' from
the column definition in this migration (and remove the matching DEFAULT '' in
the Postgres migration counterpart) so the schema doesn't imply blank categories
are valid—keep NOT NULL but drop the default to avoid confusion with
CrossSeedIndexerCategoryStore.Set behavior.

In `@internal/models/crossseed_indexer_categories_test.go`:
- Around line 160-214: Consolidate the repeated GetByIndexerName test cases into
table-driven subtests: replace the multiple functions
TestCrossSeedIndexerCategoryStore_GetByIndexerName,
TestCrossSeedIndexerCategoryStore_GetByIndexerName_CaseInsensitive,
TestCrossSeedIndexerCategoryStore_GetByIndexerName_NotFound, and
TestCrossSeedIndexerCategoryStore_GetByIndexerName_EmptyName with a single
TestCrossSeedIndexerCategoryStore_GetByIndexerName that defines a []struct{name
string; instanceID int; setup bool; input string; want string} (or similar) and
iterates with t.Run; reuse the common setup logic (db :=
setupIndexerCategoryTestDB, store := models.NewCrossSeedIndexerCategoryStore,
ctx := context.Background()) and call store.Set only for rows that need
pre-inserted data (reference insertTestInstance, insertTestTorznabIndexer, and
store.Set), then assert results for each case (expecting empty string or
"Aither") to cover case-insensitive, not-found and empty-name scenarios.

In `@internal/proxy/crossseed_indexer_categories_test.go`:
- Around line 81-163: The tests for handleCrossSeedIndexerCategories are
repetitive—refactor TestHandleCrossSeedIndexerCategories_* into a single
table-driven test that iterates cases (e.g., "missing instance id", "nil store",
"empty slice", "returns mappings"); for each case define a name, optional store
setup (use setupIndexerCategoryProxyDB, models.NewCrossSeedIndexerCategoryStore,
insertProxyTestInstance, insertProxyTestIndexer, store.Set where needed), the
context InstanceID value, expected HTTP status and a post-check function for the
decoded body; inside the loop create the request, attach context, create
httptest.NewRecorder(), call h.handleCrossSeedIndexerCategories, assert status
and Content-Type, then decode and run the per-case assertions—this consolidates
repeated request/recorder/handler call/decode logic while keeping existing
helpers and the handleCrossSeedIndexerCategories target unchanged.

In `@internal/services/dirscan/inject.go`:
- Around line 670-762: The materializeLinkTree function mixes tracker-category
mapping, base-dir selection, and destDir creation making it too long; extract
the tracker vs non-tracker destination resolution into a helper (e.g.,
resolveLinkDestination or selectLinkDestination) that accepts (ctx, instance,
req, incomingFiles/existingFiles) and returns (selectedBaseDir, destDir, error),
encapsulating the mapTrackerCategorySavePathToHost call, the
crossseed.FindMatchingBaseDir call, the os.MkdirAll calls, and the
buildLinkDestDir invocation; update materializeLinkTree to call this helper and
use its returned selectedBaseDir and destDir before calling
hardlinktree.BuildPlan so the main function is shortened and easier to test.

In `@internal/services/dirscan/service.go`:
- Around line 1786-1825: Extract the "by-tracker" resolution branch out of
tryMatchAndInject into a small helper function (e.g., resolveByTrackerCategory)
that returns (category string, trackerCategorySavePath string, failed
*searcheeMatch); move the logic that checks s.indexerCategoryStore, loads the
instance via s.instanceStore.Get, calls resolveDirscanTrackerCategory, and
builds the failMatch searcheeMatch into that helper, preserving the same
parameters used here (ctx, dir.TargetInstanceID, result.Indexer, announceDomain,
instance, s.indexerCategoryStore, s.syncManager, l) and the same failure/fatal
handling; update tryMatchAndInject to call the new helper, assign returned
category and trackerCategorySavePath if non-empty, and if failed != nil return
it, ensuring behavior is identical but reducing tryMatchAndInject's length and
cognitive complexity.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: b7633239-19f9-4929-aa75-995079e1fc83

📥 Commits

Reviewing files that changed from the base of the PR and between 65c7046 and a0ca10a.

📒 Files selected for processing (34)
  • cmd/qui/main.go
  • internal/api/handlers/crossseed.go
  • internal/api/handlers/dirscan_webhook_test.go
  • internal/api/server.go
  • internal/api/server_test.go
  • internal/database/migrations/071_add_tracker_category_settings.sql
  • internal/database/postgres_migrations/072_add_tracker_category_settings.sql
  • internal/models/crossseed_indexer_categories.go
  • internal/models/crossseed_indexer_categories_test.go
  • internal/proxy/crossseed_indexer_categories_test.go
  • internal/proxy/handler.go
  • internal/proxy/handler_test.go
  • internal/services/crossseed/auto_tmm_test.go
  • internal/services/crossseed/category_lookup_failure_test.go
  • internal/services/crossseed/category_persist_path_test.go
  • internal/services/crossseed/crossseed_test.go
  • internal/services/crossseed/domain_aliases.go
  • internal/services/crossseed/domain_aliases_test.go
  • internal/services/crossseed/domain_matching_test.go
  • internal/services/crossseed/hardlink_mode_test.go
  • internal/services/crossseed/link_mode_category_placement_test.go
  • internal/services/crossseed/service.go
  • internal/services/crossseed/tracker_category.go
  • internal/services/crossseed/tracker_category_test.go
  • internal/services/dirscan/cancel_scan_test.go
  • internal/services/dirscan/inject.go
  • internal/services/dirscan/inject_test.go
  • internal/services/dirscan/service.go
  • internal/services/dirscan/service_tracker_category_test.go
  • internal/services/dirscan/webhook_queue_test.go
  • internal/web/swagger/openapi.yaml
  • web/src/lib/api.ts
  • web/src/pages/CrossSeedPage.tsx
  • web/src/types/index.ts

Comment on lines +170 to +181
// AutoTMM must NOT be enabled when paths differ: qBittorrent would move the
// existing files to the category directory instead of cross-seeding in place.
name: "tracker category mode with mismatched paths - auto tmm disabled",
crossCategory: "Aither",
matchedAutoManaged: false,
useCategoryFromIndexer: false,
useCustomCategory: false,
isTrackerCategoryMode: true,
actualCategorySavePath: "/downloads/aither",
matchedSavePath: "/downloads/tv",
wantEnabled: false,
wantPathsMatch: false,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Recheck this expectation against by-tracker AutoTMM semantics.

This case disables AutoTMM even though the mapped category has a configured save path. That appears to contradict the PR objective: by-tracker mappings should enable AutoTMM when qBittorrent has a save path for the mapped category, even when that path differs from the matched torrent’s current save path.

If the category path is configured, the expected decision should likely keep AutoTMM enabled while PathsMatch remains false.

Proposed test expectation adjustment
-			name:                   "tracker category mode with mismatched paths - auto tmm disabled",
+			name:                   "tracker category mode with configured category path - auto tmm enabled",
 			crossCategory:          "Aither",
 			matchedAutoManaged:     false,
 			useCategoryFromIndexer: false,
 			useCustomCategory:      false,
 			isTrackerCategoryMode:  true,
 			actualCategorySavePath: "/downloads/aither",
 			matchedSavePath:        "/downloads/tv",
-			wantEnabled:            false,
+			wantEnabled:            true,
 			wantPathsMatch:         false,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// AutoTMM must NOT be enabled when paths differ: qBittorrent would move the
// existing files to the category directory instead of cross-seeding in place.
name: "tracker category mode with mismatched paths - auto tmm disabled",
crossCategory: "Aither",
matchedAutoManaged: false,
useCategoryFromIndexer: false,
useCustomCategory: false,
isTrackerCategoryMode: true,
actualCategorySavePath: "/downloads/aither",
matchedSavePath: "/downloads/tv",
wantEnabled: false,
wantPathsMatch: false,
name: "tracker category mode with configured category path - auto tmm enabled",
crossCategory: "Aither",
matchedAutoManaged: false,
useCategoryFromIndexer: false,
useCustomCategory: false,
isTrackerCategoryMode: true,
actualCategorySavePath: "/downloads/aither",
matchedSavePath: "/downloads/tv",
wantEnabled: true,
wantPathsMatch: false,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/services/crossseed/auto_tmm_test.go` around lines 170 - 181, The
test case named "tracker category mode with mismatched paths - auto tmm
disabled" is asserting AutoTMM is disabled even though the mapped tracker
category has a configured save path; update the expectation to match by-tracker
AutoTMM semantics by setting wantEnabled to true (AutoTMM enabled) while keeping
wantPathsMatch false, and ensure the test still uses isTrackerCategoryMode,
actualCategorySavePath, and matchedSavePath to reflect the mismatched paths
scenario.

Comment thread internal/services/crossseed/service.go
Comment thread internal/services/crossseed/tracker_category.go Outdated
@s0up4200
Copy link
Copy Markdown
Collaborator

CleanShot 2026-04-18 at 19 50 12@2x

But part from that (hopefully without sounding too critical), I'm not really sure anymore if the feature brings any real usefulness?

What does it bring that isnt already solved by these?

CleanShot 2026-04-18 at 19 53 30@2x CleanShot 2026-04-18 at 19 53 37@2x

@CjE5
Copy link
Copy Markdown
Contributor Author

CjE5 commented Apr 18, 2026

Hey @s0up4200, totally fair question - happy to clarify.

The goal of this feature is to have all cross-seeds for a given tracker land in a single, consistent directory regardless of which cross-seed app injected them, with a user-defined qBittorrent category assigned and AutoTMM enabled - so files can be easily moved later as needed without breaking anything.

Affix vs. mapping: Category affix transforms whatever category cross-seed would have resolved into a derived name (e.g. BeyondHD → BeyondHD.cs). The intent is isolation - marking cross-seeds as distinct. My goal is the opposite: I want cross-seeds for a given tracker to land in the same category as regular downloads for that tracker (BeyondHD → hd-movies), no duplication. Affix has no way to express that.

AutoTMM + hardlinks: When a category has a configured save path in qBittorrent, this feature looks up that path, pre-places the hardlinks directly under it, and then enables AutoTMM on the added torrent. Without pre-placement, enabling AutoTMM would cause qBittorrent to move the files to the category directory - breaking the hardlinks. No existing mechanism handles this sequence.

Centralization: I run more than one cross-seed app. This feature makes qui the authority on tracker categories and save paths - the mapping lives in qui's database and is exposed via a proxy-authenticated endpoint. Any cross-seed type of app can query it to resolve the correct category without each needing its own configuration.

qui's by-tracker is orthogonal: That setting controls where hardlinks land on the host filesystem - tracker-named subfolders for multi-file torrents, auto-generated isolation folders (TorrentName--<hash>) for single-file torrents. These paths are not customizable and result in inconsistencies across apps that don't share the same hardlink base directory layout. And, ultimately, it says nothing about which qBittorrent category is assigned or whether AutoTMM is enabled - the two features operate at different layers.

I'd also love to understand your perspective better - is there a specific combination of existing features you think would achieve the same result? If there's an approach I'm missing I'm genuinely open to it, but from what I can tell none of the existing mechanisms get you consistent single-directory placement per tracker across multiple apps with AutoTMM wired up correctly.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
internal/services/crossseed/hardlink_mode_test.go (1)

950-994: Keep the regression test from touching absolute host paths.

If this guard regresses, the test may attempt MkdirAll("/qbt/movies"); in privileged CI that can mutate the host filesystem instead of failing safely. Use a temp-file blocker so the category-path branch deterministically fails without touching /.

🧪 Proposed test-hardening diff
 func TestProcessHardlinkMode_IgnoresCategorySavePathWhenNotTrackerCategoryMode(t *testing.T) {
 	mockInstances := &mockInstanceStore{
 		instances: map[int]*models.Instance{
@@
 
 	s := &Service{instanceStore: mockInstances}
 
+	categoryPathBlocker := filepath.Join(t.TempDir(), "category-save-path-blocker")
+	require.NoError(t, os.WriteFile(categoryPathBlocker, []byte("not a directory"), 0o600))
+	categorySavePath := filepath.Join(categoryPathBlocker, "movies")
+
 	result := s.processHardlinkMode(
 		context.Background(),
 		CrossSeedCandidate{InstanceID: 1, InstanceName: "qbt1"},
@@
 		&qbt.TorrentProperties{SavePath: "/downloads"},
 		"movies",
 		"movies.cross",
-		"/qbt/movies",  // categorySavePath from matched torrent's existing category
+		categorySavePath, // categorySavePath from matched torrent's existing category
 		false,          // isTrackerCategoryMode = false: no mapping configured
 		true,           // crossCategoryExistsInQbit = true: category already in qBittorrent
 	)

As per coding guidelines, when adding Go tests that create files with os.WriteFile, use 0o600 or tighter permissions unless broader mode bits are explicitly needed.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/services/crossseed/hardlink_mode_test.go` around lines 950 - 994,
The test
TestProcessHardlinkMode_IgnoresCategorySavePathWhenNotTrackerCategoryMode can
accidentally call os.MkdirAll on an absolute host path ("/qbt/movies") if the
guard around isTrackerCategoryMode regresses; modify the test to create a
temporary blocker file/path (e.g., via os.CreateTemp or os.WriteFile) at a safe
temp location and point categorySavePath to that temp path so the category-path
branch deterministically fails without touching root, and ensure any temp files
are created with tight permissions (0o600) and cleaned up; verify the assertion
still checks that the error came from FindMatchingBaseDir and not the category
directory branch, and reference processHardlinkMode, FindMatchingBaseDir,
MkdirAll, categorySavePath and isTrackerCategoryMode when locating the code to
change.
internal/services/crossseed/link_mode_category_placement_test.go (1)

26-53: Add coverage for an existing mapped category with no save path.

resolveLinkModeCategoryPlacement has a distinct branch for isTrackerCategoryMode=true, crossCategoryExistsInQbit=true, and empty categorySavePath; this should assert AutoTMM stays disabled and placement falls back to preset-derived destination.

🧪 Proposed additional table case
 	{
 		name:                     "existing tracker category uses configured save path directly",
 		candidateFiles:           filesRootless,
 		categorySavePath:         "/qbt/Aither",
 		crossCategoryExistsInQbt: true,
 		wantSavePath:             "/qbt/Aither",
 		wantDestDir:              "/qbt/Aither",
 		wantAutoTMM:              true,
 	},
+	{
+		name:                     "existing tracker category without configured save path disables AutoTMM",
+		candidateFiles:           filesWithCommonRoot,
+		categorySavePath:         "",
+		crossCategoryExistsInQbt: true,
+		wantSavePath:             "",
+		wantDestDir:              "/reflinks/Aither",
+		wantAutoTMM:              false,
+	},
 	{
 		name:                     "new tracker category derives preset save path",
 		candidateFiles:           filesWithCommonRoot,
 		categorySavePath:         "",
 		crossCategoryExistsInQbt: false,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/services/crossseed/link_mode_category_placement_test.go` around
lines 26 - 53, Add a table-driven test case to
internal/services/crossseed/link_mode_category_placement_test.go that covers the
branch in resolveLinkModeCategoryPlacement where isTrackerCategoryMode==true,
crossCategoryExistsInQbt==true and categorySavePath==""; set candidateFiles to
an appropriate fixture (e.g. filesWithCommonRoot), leave categorySavePath empty,
set crossCategoryExistsInQbt to true, and assert that the returned save
path/destination falls back to the preset-derived path (same as the “new tracker
category derives preset save path” expectation) and that AutoTMM is false (i.e.
wantAutoTMM=false); update the test table (tests := []struct{...}) with a
descriptive name like "existing mapped category with no save path falls back to
preset and disables AutoTMM" so resolveLinkModeCategoryPlacement's behavior is
verified.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@internal/services/crossseed/hardlink_mode_test.go`:
- Around line 950-994: The test
TestProcessHardlinkMode_IgnoresCategorySavePathWhenNotTrackerCategoryMode can
accidentally call os.MkdirAll on an absolute host path ("/qbt/movies") if the
guard around isTrackerCategoryMode regresses; modify the test to create a
temporary blocker file/path (e.g., via os.CreateTemp or os.WriteFile) at a safe
temp location and point categorySavePath to that temp path so the category-path
branch deterministically fails without touching root, and ensure any temp files
are created with tight permissions (0o600) and cleaned up; verify the assertion
still checks that the error came from FindMatchingBaseDir and not the category
directory branch, and reference processHardlinkMode, FindMatchingBaseDir,
MkdirAll, categorySavePath and isTrackerCategoryMode when locating the code to
change.

In `@internal/services/crossseed/link_mode_category_placement_test.go`:
- Around line 26-53: Add a table-driven test case to
internal/services/crossseed/link_mode_category_placement_test.go that covers the
branch in resolveLinkModeCategoryPlacement where isTrackerCategoryMode==true,
crossCategoryExistsInQbt==true and categorySavePath==""; set candidateFiles to
an appropriate fixture (e.g. filesWithCommonRoot), leave categorySavePath empty,
set crossCategoryExistsInQbt to true, and assert that the returned save
path/destination falls back to the preset-derived path (same as the “new tracker
category derives preset save path” expectation) and that AutoTMM is false (i.e.
wantAutoTMM=false); update the test table (tests := []struct{...}) with a
descriptive name like "existing mapped category with no save path falls back to
preset and disables AutoTMM" so resolveLinkModeCategoryPlacement's behavior is
verified.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 5ecdb4ac-8092-4593-802f-f881ce32f8da

📥 Commits

Reviewing files that changed from the base of the PR and between bc8d4cb and 97f84fb.

📒 Files selected for processing (3)
  • internal/services/crossseed/hardlink_mode_test.go
  • internal/services/crossseed/link_mode_category_placement_test.go
  • internal/services/crossseed/service.go
✅ Files skipped from review due to trivial changes (1)
  • internal/services/crossseed/service.go

@CjE5 CjE5 force-pushed the develop branch 2 times, most recently from 8f64f1b to ffa75b0 Compare April 21, 2026 02:14
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
internal/services/crossseed/tracker_category.go (1)

93-99: ⚠️ Potential issue | 🟠 Major

Remove the loose substring fallback before it maps unrelated trackers.

The minimum-length guard still lets unrelated domains match by containment, e.g. notbeyondhd contains beyondhd. Since this match controls category/AutoTMM placement, keep only boundary/label-aware matches and explicit aliases for non-textual tracker domains.

🐛 Proposed fix
-	// Containment on fully separator-stripped strings: apply a minimum length
-	// guard on both sides to prevent short common tokens from matching broadly.
-	if len(normalizedIndexer) >= 6 && strings.Contains(domainNorm, normalizedIndexer) {
-		return true
-	}
-	if len(domainNorm) >= 6 && strings.Contains(normalizedIndexer, domainNorm) {
-		return true
-	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/services/crossseed/tracker_category.go` around lines 93 - 99, The
loose substring containment fallback using normalizedIndexer and domainNorm is
causing false positives (e.g., "notbeyondhd" matching "beyondhd"); remove the
two containment checks (the if blocks checking len(normalizedIndexer) >= 6 &&
strings.Contains(domainNorm, normalizedIndexer) and len(domainNorm) >= 6 &&
strings.Contains(normalizedIndexer, domainNorm)) and rely only on the existing
boundary/label-aware matching logic and explicit alias lookups for non-textual
tracker domains; keep normalizedIndexer/domainNorm available for the stricter
checks but do not perform raw substring Contains, and update/extend relevant
tests to cover the removed fallback cases.
🧹 Nitpick comments (1)
internal/services/dirscan/service_tracker_category_test.go (1)

81-145: Fold these scenarios into a table-driven test.

Both tests exercise resolveDirscanTrackerCategory with different setup/expectations; a table makes it easier to add the successful mapping and “no mapped category” cases without duplicating boilerplate. As per coding guidelines, **/*_test.go: Use table-driven test cases and reuse integration fixtures from internal/qbittorrent/.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/services/dirscan/service_tracker_category_test.go` around lines 81 -
145, Combine the two tests into a single table-driven test that iterates over
cases (e.g., "fallback on lookup error" and "fatal on get categories error"),
each case specifying inputs like instanceID, trackerName, host, the
fakeDirscanCategoryGetter.err value, expected fatal flag, expected
category/savePath, and expected getter.calls; reuse the existing helpers
setupTrackerCategoryDB and fakeDirscanCategoryGetter and call
resolveDirscanTrackerCategory inside the loop, asserting outcomes per-case, and
keep model setup (models.Instance, store) shared/parameterized to remove
duplicated boilerplate while preserving the same assertions from
TestResolveDirscanTrackerCategory_FallsBackOnLookupError and
TestResolveDirscanTrackerCategory_FatalOnGetCategoriesError.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/services/dirscan/service.go`:
- Around line 1808-1816: resolveDirscanTrackerCategory may receive a non-nil
interface holding a typed nil s.syncManager, causing a panic when
resolveDirscanTrackerCategory later calls dirscanCategoryGetter.GetCategories;
to fix, ensure you do not assign a typed-nil to the dirscanCategoryGetter
parameter by creating a local variable (e.g., var sm dirscanCategoryGetter) and
only set sm = s.syncManager when s.syncManager != nil, then pass sm to
resolveDirscanTrackerCategory instead of passing s.syncManager directly;
reference resolveDirscanTrackerCategory, s.syncManager, dirscanCategoryGetter,
and GetCategories.

---

Duplicate comments:
In `@internal/services/crossseed/tracker_category.go`:
- Around line 93-99: The loose substring containment fallback using
normalizedIndexer and domainNorm is causing false positives (e.g., "notbeyondhd"
matching "beyondhd"); remove the two containment checks (the if blocks checking
len(normalizedIndexer) >= 6 && strings.Contains(domainNorm, normalizedIndexer)
and len(domainNorm) >= 6 && strings.Contains(normalizedIndexer, domainNorm)) and
rely only on the existing boundary/label-aware matching logic and explicit alias
lookups for non-textual tracker domains; keep normalizedIndexer/domainNorm
available for the stricter checks but do not perform raw substring Contains, and
update/extend relevant tests to cover the removed fallback cases.

---

Nitpick comments:
In `@internal/services/dirscan/service_tracker_category_test.go`:
- Around line 81-145: Combine the two tests into a single table-driven test that
iterates over cases (e.g., "fallback on lookup error" and "fatal on get
categories error"), each case specifying inputs like instanceID, trackerName,
host, the fakeDirscanCategoryGetter.err value, expected fatal flag, expected
category/savePath, and expected getter.calls; reuse the existing helpers
setupTrackerCategoryDB and fakeDirscanCategoryGetter and call
resolveDirscanTrackerCategory inside the loop, asserting outcomes per-case, and
keep model setup (models.Instance, store) shared/parameterized to remove
duplicated boilerplate while preserving the same assertions from
TestResolveDirscanTrackerCategory_FallsBackOnLookupError and
TestResolveDirscanTrackerCategory_FatalOnGetCategoriesError.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 8d7fda82-6fbc-431e-9e6e-5aac762160b8

📥 Commits

Reviewing files that changed from the base of the PR and between 97f84fb and 8f64f1b.

📒 Files selected for processing (34)
  • cmd/qui/main.go
  • internal/api/handlers/crossseed.go
  • internal/api/handlers/dirscan_webhook_test.go
  • internal/api/server.go
  • internal/api/server_test.go
  • internal/database/migrations/071_add_tracker_category_settings.sql
  • internal/database/postgres_migrations/072_add_tracker_category_settings.sql
  • internal/models/crossseed_indexer_categories.go
  • internal/models/crossseed_indexer_categories_test.go
  • internal/proxy/crossseed_indexer_categories_test.go
  • internal/proxy/handler.go
  • internal/proxy/handler_test.go
  • internal/services/crossseed/auto_tmm_test.go
  • internal/services/crossseed/category_lookup_failure_test.go
  • internal/services/crossseed/category_persist_path_test.go
  • internal/services/crossseed/crossseed_test.go
  • internal/services/crossseed/domain_aliases.go
  • internal/services/crossseed/domain_aliases_test.go
  • internal/services/crossseed/domain_matching_test.go
  • internal/services/crossseed/hardlink_mode_test.go
  • internal/services/crossseed/link_mode_category_placement_test.go
  • internal/services/crossseed/service.go
  • internal/services/crossseed/tracker_category.go
  • internal/services/crossseed/tracker_category_test.go
  • internal/services/dirscan/cancel_scan_test.go
  • internal/services/dirscan/inject.go
  • internal/services/dirscan/inject_test.go
  • internal/services/dirscan/service.go
  • internal/services/dirscan/service_tracker_category_test.go
  • internal/services/dirscan/webhook_queue_test.go
  • internal/web/swagger/openapi.yaml
  • web/src/lib/api.ts
  • web/src/pages/CrossSeedPage.tsx
  • web/src/types/index.ts
✅ Files skipped from review due to trivial changes (11)
  • internal/api/server_test.go
  • internal/services/crossseed/category_lookup_failure_test.go
  • internal/services/crossseed/domain_matching_test.go
  • internal/services/crossseed/tracker_category_test.go
  • internal/database/postgres_migrations/072_add_tracker_category_settings.sql
  • web/src/types/index.ts
  • internal/proxy/crossseed_indexer_categories_test.go
  • internal/models/crossseed_indexer_categories_test.go
  • internal/services/crossseed/link_mode_category_placement_test.go
  • internal/database/migrations/071_add_tracker_category_settings.sql
  • internal/services/crossseed/category_persist_path_test.go
🚧 Files skipped from review as they are similar to previous changes (13)
  • internal/services/dirscan/cancel_scan_test.go
  • web/src/lib/api.ts
  • internal/services/crossseed/hardlink_mode_test.go
  • internal/models/crossseed_indexer_categories.go
  • cmd/qui/main.go
  • internal/services/crossseed/auto_tmm_test.go
  • internal/services/crossseed/domain_aliases_test.go
  • internal/web/swagger/openapi.yaml
  • internal/services/crossseed/domain_aliases.go
  • internal/services/dirscan/inject_test.go
  • internal/services/dirscan/webhook_queue_test.go
  • internal/services/crossseed/crossseed_test.go
  • internal/services/crossseed/service.go

Comment thread internal/services/dirscan/service.go
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/services/crossseed/service.go (1)

11220-11339: ⚠️ Potential issue | 🟠 Major

Disable AutoTMM when category creation fails.

Both link-mode paths clear crossCategory after ensureCrossCategory fails, but placement.EnableAutoTMM can remain true. That can add the torrent with autoTMM=true and no category/savepath, so qBittorrent may place it outside the pre-created link tree.

Proposed fix
@@
 	if crossCategory != "" {
 		if err := s.ensureCrossCategory(ctx, candidate.InstanceID, crossCategory, effectiveCategorySavePath, false); err != nil {
 			log.Warn().Err(err).
 				Str("category", crossCategory).
 				Str("savePath", effectiveCategorySavePath).
 				Msg("[CROSSSEED] Hardlink mode: failed to ensure category exists, continuing without category")
 			crossCategory = ""
 			categoryCreationFailed = true
 		}
 	}
@@
-	if placement.EnableAutoTMM {
+	if placement.EnableAutoTMM && crossCategory != "" {
 		options["autoTMM"] = "true"
 	} else {
 		options["autoTMM"] = "false"
 		options["savepath"] = plan.RootDir
 	}
@@
 	if crossCategory != "" {
 		if err := s.ensureCrossCategory(ctx, candidate.InstanceID, crossCategory, effectiveCategorySavePath, false); err != nil {
 			log.Warn().Err(err).
 				Str("category", crossCategory).
 				Str("savePath", effectiveCategorySavePath).
 				Msg("[CROSSSEED] Reflink mode: failed to ensure category exists, continuing without category")
 			crossCategory = ""
 			categoryCreationFailed = true
 		}
 	}
@@
-	if placement.EnableAutoTMM {
+	if placement.EnableAutoTMM && crossCategory != "" {
 		options["autoTMM"] = "true"
 	} else {
 		options["autoTMM"] = "false"
 		options["savepath"] = plan.RootDir
 	}

Also applies to: 11847-11955

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/services/crossseed/service.go` around lines 11220 - 11339, The bug:
when ensureCrossCategory fails and crossCategory is cleared (see
ensureCrossCategory, crossCategory, categoryCreationFailed),
placement.EnableAutoTMM can still be true which would add the torrent with
autoTMM=true and no category/savepath; fix by disabling AutoTMM when category
creation fails—either set placement.EnableAutoTMM = false immediately after you
set categoryCreationFailed = true (where ensureCrossCategory error is handled)
or ensure later when building options you check categoryCreationFailed and force
options["autoTMM"]="false" and options["savepath"]=plan.RootDir so qBittorrent
uses the pre-created hardlink tree (refer to placement.EnableAutoTMM, options
map, and plan.RootDir).
♻️ Duplicate comments (1)
internal/services/crossseed/service.go (1)

10111-10118: ⚠️ Potential issue | 🟠 Major

Do not treat tracker-category lookup errors as no mapping.

This still falls back to global category rules when ResolveTrackerCategory returns a store/context error. The fallback is safe for “no mapping found,” but not when the mapping state is unknown; surface the error so the inject can fail/retry instead. As per coding guidelines, “Prefer explicit error handling over silent failures in Go code”.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/services/crossseed/service.go` around lines 10111 - 10118, The code
currently treats any non-nil resolveErr from ResolveTrackerCategory(ctx,
instance.ID, reqIndexerName, announceDomain, s.indexerCategoryStore) as a soft
failure and falls through to global rules; instead, if resolveErr != nil return
that error (or wrap and return it) from the enclosing function so the inject
operation fails/retries rather than silently using a fallback. Locate the
ResolveTrackerCategory call and replace the warning-only branch with explicit
error handling that returns resolveErr (or a wrapped error including context
such as instance.ID, reqIndexerName, announceDomain) rather than treating it as
"not found"; keep the existing found branch behavior unchanged.
🧹 Nitpick comments (2)
internal/services/crossseed/tracker_category.go (1)

137-147: Minor: debug-level log for store error may hide issues.

When store.List fails with a non-context error, the failure is logged at Debug only and then propagated. Callers (e.g., resolveDirscanTrackerCategory) do re-log at Warn, so this is fine in practice, but when this function is called from paths that treat the error as a soft fallback (e.g., return "", false, nil downstream), the DB failure could become invisible in default log setups. Consider log.Warn() here, or leave as-is and ensure every caller re-logs.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/services/crossseed/tracker_category.go` around lines 137 - 147, The
error from store.List inside ResolveTrackerCategory is currently logged at Debug
and can be missed; change the logging call to log.Warn() (keeping the same
.Err(err).Int("instanceID", instanceID).Msg("[CROSSSEED] ResolveTrackerCategory:
failed to list mappings") structure) so non-context DB failures are visible by
default, ensuring callers like resolveDirscanTrackerCategory still
handle/propagate the error as before.
internal/services/dirscan/service_tracker_category_test.go (1)

81-145: Consider consolidating into a table-driven test.

The two test functions share identical setup and call shape, differing only in (store source, getter error, expected fatal/calls). Per coding guidelines, prefer table-driven cases. Example consolidation:

♻️ Proposed refactor
func TestResolveDirscanTrackerCategory(t *testing.T) {
    t.Parallel()
    const instanceID = 42

    tests := []struct {
        name       string
        setupStore func(t *testing.T) *models.CrossSeedIndexerCategoryStore
        getter     *fakeDirscanCategoryGetter
        wantFatal  bool
        wantCalls  int
    }{
        {
            name: "falls back when store lookup errors",
            setupStore: func(t *testing.T) *models.CrossSeedIndexerCategoryStore {
                dbPath := filepath.Join(t.TempDir(), "indexer_categories.db")
                db, err := database.New(dbPath)
                require.NoError(t, err)
                store := models.NewCrossSeedIndexerCategoryStore(db)
                require.NoError(t, db.Close())
                return store
            },
            getter:    &fakeDirscanCategoryGetter{err: errors.New("should not be called")},
            wantFatal: false,
            wantCalls: 0,
        },
        {
            name: "fatal when GetCategories errors",
            setupStore: func(t *testing.T) *models.CrossSeedIndexerCategoryStore {
                _, store := setupTrackerCategoryDB(t, instanceID, "Aither", "aither-movies")
                return store
            },
            getter:    &fakeDirscanCategoryGetter{err: errors.New("qbittorrent unavailable")},
            wantFatal: true,
            wantCalls: 1,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            logger := zerolog.New(io.Discard)
            instance := &models.Instance{ID: instanceID, UseHardlinks: true, HardlinkDirPreset: "by-tracker"}
            store := tt.setupStore(t)

            category, savePath, fatal := resolveDirscanTrackerCategory(
                context.Background(), instanceID, "Aither", "aither.cc",
                instance, store, tt.getter, &logger,
            )

            require.Equal(t, tt.wantFatal, fatal)
            require.Empty(t, category)
            require.Empty(t, savePath)
            require.Equal(t, tt.wantCalls, tt.getter.calls)
        })
    }
}

As per coding guidelines: "Use table-driven test cases and reuse integration fixtures from internal/qbittorrent/".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/services/dirscan/service_tracker_category_test.go` around lines 81 -
145, The two tests TestResolveDirscanTrackerCategory_FallsBackOnLookupError and
TestResolveDirscanTrackerCategory_FatalOnGetCategoriesError are duplicated
patterns; refactor into a single table-driven test that iterates cases and calls
resolveDirscanTrackerCategory with different setupStore and getter values.
Create a tests slice with fields (name, setupStore func(*testing.T)
*models.CrossSeedIndexerCategoryStore, getter *fakeDirscanCategoryGetter,
wantFatal bool, wantCalls int), use setupTrackerCategoryDB and the temp-db
creation used in the original tests as the setupStore implementations, and
assert fatal/category/savePath/getter.calls per case; keep using
resolveDirscanTrackerCategory, fakeDirscanCategoryGetter and the original
instance construction and logger.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/services/crossseed/hardlink_mode_test.go`:
- Around line 978-983: Fix the gofmt violation by adjusting the spacing before
inline comments in the test case that contains the literals "movies",
"movies.cross", "/qbt/movies" and the trailing boolean comments (e.g., the
comment after "/qbt/movies" about categorySavePath and the comments for
isTrackerCategoryMode and crossCategoryExistsInQbit). Either collapse the double
spaces to a single space before each inline comment or run gofmt -w on the file
to apply canonical formatting so the line conforms to gofmt.

In `@internal/services/dirscan/inject.go`:
- Around line 601-613: The function mapTrackerCategorySavePathToHost (which uses
crossseed.FindMatchingBaseDir and applyInversePathMapping) can fail at runtime
when multiple HardlinkBaseDir entries span different filesystems but only a
single QbitPathPrefix is configured; add config-time validation when
saving/loading the HardlinkBaseDir + QbitPathPrefix pair to ensure the prefix
can inverse-map to at least one of the configured base dirs (or reject/save a
warning if base dirs live on multiple filesystems and the single QbitPathPrefix
cannot match all of them). Implement this by iterating the parsed
HardlinkBaseDir entries, calling applyInversePathMapping(qbitPathPrefix,
candidateBaseDir, qbitPathPrefix) (or equivalent test helper) to confirm at
least one successful mapping, and surface a clear validation error referencing
HardlinkBaseDir and QbitPathPrefix when no valid pairing exists; alternatively
add UI/API documentation notes next to the HardlinkBaseDir/QbitPathPrefix
configuration explaining the constraint.

---

Outside diff comments:
In `@internal/services/crossseed/service.go`:
- Around line 11220-11339: The bug: when ensureCrossCategory fails and
crossCategory is cleared (see ensureCrossCategory, crossCategory,
categoryCreationFailed), placement.EnableAutoTMM can still be true which would
add the torrent with autoTMM=true and no category/savepath; fix by disabling
AutoTMM when category creation fails—either set placement.EnableAutoTMM = false
immediately after you set categoryCreationFailed = true (where
ensureCrossCategory error is handled) or ensure later when building options you
check categoryCreationFailed and force options["autoTMM"]="false" and
options["savepath"]=plan.RootDir so qBittorrent uses the pre-created hardlink
tree (refer to placement.EnableAutoTMM, options map, and plan.RootDir).

---

Duplicate comments:
In `@internal/services/crossseed/service.go`:
- Around line 10111-10118: The code currently treats any non-nil resolveErr from
ResolveTrackerCategory(ctx, instance.ID, reqIndexerName, announceDomain,
s.indexerCategoryStore) as a soft failure and falls through to global rules;
instead, if resolveErr != nil return that error (or wrap and return it) from the
enclosing function so the inject operation fails/retries rather than silently
using a fallback. Locate the ResolveTrackerCategory call and replace the
warning-only branch with explicit error handling that returns resolveErr (or a
wrapped error including context such as instance.ID, reqIndexerName,
announceDomain) rather than treating it as "not found"; keep the existing found
branch behavior unchanged.

---

Nitpick comments:
In `@internal/services/crossseed/tracker_category.go`:
- Around line 137-147: The error from store.List inside ResolveTrackerCategory
is currently logged at Debug and can be missed; change the logging call to
log.Warn() (keeping the same .Err(err).Int("instanceID",
instanceID).Msg("[CROSSSEED] ResolveTrackerCategory: failed to list mappings")
structure) so non-context DB failures are visible by default, ensuring callers
like resolveDirscanTrackerCategory still handle/propagate the error as before.

In `@internal/services/dirscan/service_tracker_category_test.go`:
- Around line 81-145: The two tests
TestResolveDirscanTrackerCategory_FallsBackOnLookupError and
TestResolveDirscanTrackerCategory_FatalOnGetCategoriesError are duplicated
patterns; refactor into a single table-driven test that iterates cases and calls
resolveDirscanTrackerCategory with different setupStore and getter values.
Create a tests slice with fields (name, setupStore func(*testing.T)
*models.CrossSeedIndexerCategoryStore, getter *fakeDirscanCategoryGetter,
wantFatal bool, wantCalls int), use setupTrackerCategoryDB and the temp-db
creation used in the original tests as the setupStore implementations, and
assert fatal/category/savePath/getter.calls per case; keep using
resolveDirscanTrackerCategory, fakeDirscanCategoryGetter and the original
instance construction and logger.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 31deb310-f9a4-4042-9f59-8b4eb112ace0

📥 Commits

Reviewing files that changed from the base of the PR and between 8f64f1b and ffa75b0.

📒 Files selected for processing (34)
  • cmd/qui/main.go
  • internal/api/handlers/crossseed.go
  • internal/api/handlers/dirscan_webhook_test.go
  • internal/api/server.go
  • internal/api/server_test.go
  • internal/database/migrations/071_add_tracker_category_settings.sql
  • internal/database/postgres_migrations/072_add_tracker_category_settings.sql
  • internal/models/crossseed_indexer_categories.go
  • internal/models/crossseed_indexer_categories_test.go
  • internal/proxy/crossseed_indexer_categories_test.go
  • internal/proxy/handler.go
  • internal/proxy/handler_test.go
  • internal/services/crossseed/auto_tmm_test.go
  • internal/services/crossseed/category_lookup_failure_test.go
  • internal/services/crossseed/category_persist_path_test.go
  • internal/services/crossseed/crossseed_test.go
  • internal/services/crossseed/domain_aliases.go
  • internal/services/crossseed/domain_aliases_test.go
  • internal/services/crossseed/domain_matching_test.go
  • internal/services/crossseed/hardlink_mode_test.go
  • internal/services/crossseed/link_mode_category_placement_test.go
  • internal/services/crossseed/service.go
  • internal/services/crossseed/tracker_category.go
  • internal/services/crossseed/tracker_category_test.go
  • internal/services/dirscan/cancel_scan_test.go
  • internal/services/dirscan/inject.go
  • internal/services/dirscan/inject_test.go
  • internal/services/dirscan/service.go
  • internal/services/dirscan/service_tracker_category_test.go
  • internal/services/dirscan/webhook_queue_test.go
  • internal/web/swagger/openapi.yaml
  • web/src/lib/api.ts
  • web/src/pages/CrossSeedPage.tsx
  • web/src/types/index.ts
✅ Files skipped from review due to trivial changes (10)
  • internal/api/server_test.go
  • web/src/types/index.ts
  • internal/services/crossseed/category_persist_path_test.go
  • internal/services/crossseed/domain_aliases.go
  • internal/services/crossseed/category_lookup_failure_test.go
  • internal/database/migrations/071_add_tracker_category_settings.sql
  • internal/models/crossseed_indexer_categories_test.go
  • internal/services/crossseed/tracker_category_test.go
  • internal/services/crossseed/link_mode_category_placement_test.go
  • internal/proxy/crossseed_indexer_categories_test.go
🚧 Files skipped from review as they are similar to previous changes (12)
  • internal/services/dirscan/cancel_scan_test.go
  • internal/api/handlers/dirscan_webhook_test.go
  • cmd/qui/main.go
  • internal/services/dirscan/webhook_queue_test.go
  • internal/database/postgres_migrations/072_add_tracker_category_settings.sql
  • internal/services/crossseed/domain_matching_test.go
  • internal/services/crossseed/domain_aliases_test.go
  • internal/services/dirscan/inject_test.go
  • web/src/lib/api.ts
  • internal/services/crossseed/auto_tmm_test.go
  • internal/proxy/handler.go
  • internal/services/dirscan/service.go

Comment thread internal/services/crossseed/hardlink_mode_test.go
Comment thread internal/services/dirscan/inject.go
@CjE5 CjE5 force-pushed the develop branch 3 times, most recently from 9c8e97e to c15c0da Compare April 25, 2026 02:16
@CjE5 CjE5 force-pushed the develop branch 2 times, most recently from 127445f to 0014490 Compare May 5, 2026 02:59
@CjE5 CjE5 force-pushed the develop branch 2 times, most recently from f51cff0 to 681669c Compare May 23, 2026 18:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants