Skip to content

fix(dirscan): queue webhook scans and tighten age filtering#1603

Merged
s0up4200 merged 7 commits intodevelopfrom
fix/dirscan-webhook-queue
Mar 17, 2026
Merged

fix(dirscan): queue webhook scans and tighten age filtering#1603
s0up4200 merged 7 commits intodevelopfrom
fix/dirscan-webhook-queue

Conversation

@s0up4200
Copy link
Copy Markdown
Collaborator

@s0up4200 s0up4200 commented Mar 16, 2026

Webhook-triggered directory scans no longer drop later Sonarr or Radarr imports while a scan is already running. qui now keeps one follow-up queued run per directory and merges later webhook paths into it so imported items are not silently missed.

Dir scan age filtering now uses video and audio mtimes only and applies at TV work-item granularity instead of keeping an entire show alive because one file under it is newer. The UI now reports eligible items as the primary count and moves raw discovered and skipped totals into details.

Closes #1602

Summary by CodeRabbit

  • New Features

    • Added "Default Category / Tags" option in Dir Scan settings.
    • Webhook scans now queue and merge follow-up runs, updating queued scan roots instead of rejecting duplicates; queued runs can be canceled.
  • Documentation

    • Renamed age-filter to "Only process items changed within the last (days)"; clarified per-type rules (video/audio vs TV, season/episode) and examples.
    • Expanded webhook response semantics and guidance for repeated webhooks.
  • UI/UX Improvements

    • Replaced raw file counts with an "Eligible" badge showing discovered/eligible/skipped breakdown and updated labels/tooltips.
  • Tests

    • Added tests covering queue/merge behavior and per-type age-selection logic.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Mar 16, 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

Refactors dirscan to select work per-root, adds webhook queuing/merge/update logic and queued-run cancellation, extends content detection to include audio, updates UI/docs wording for the age-filter and eligible file counts, and introduces work-selection primitives used throughout the scan pipeline.

Changes

Cohort / File(s) Summary
Documentation
documentation/docs/features/cross-seed/dir-scan.md
Renamed age-filter label and rewritten explanatory text to per-type "Only process items changed within the last (days)"; added Default Category / Tags row; clarified webhook response semantics and merge/update behavior.
Models & Store
internal/models/dirscan.go, internal/models/dirscan_run_status_test.go
Changed GetActiveRun ordering to prefer running/searching/injecting over queued, added GetQueuedRun, UpdateRunScanRoot, and CancelQueuedRuns; added tests for run selection and queued-run tie-breaking.
Work selection & content detection
internal/services/dirscan/work_selection.go, internal/services/dirscan/content_detection.go
Added scanWorkSelection/rootWorkSelection and selectEligibleRootWork logic (per-root cutoff, staleness, counts); added audio detection and content filtering (audio + video) and helpers for tracked-file resolution and stale determination.
Service & webhook queue
internal/services/dirscan/service.go, internal/services/dirscan/webhook_queue.go, internal/services/dirscan/webhook_queue_test.go
Serialized webhook handling with mutex; StartWebhookScan merges scan roots with active/queued runs or creates runs; added merge/normalize/isPathWithin helpers; CancelScan cancels queued runs; tests exercise merge and queued-run lifecycle.
Scan pipeline / flow
internal/services/dirscan/service.go, internal/services/dirscan/work_selection.go
Refactored scan pipeline to build fileID index, run work-selection after scanning, drive search/inject and finalize phases from scanWorkSelection; updated many internal signatures to accept workSelection and explicit file counts.
Tracked files & progress
internal/services/dirscan/progress.go
Introduced logger threading, lookupTrackedFile, logTrackedFileDecision, and shouldLogTrackedFileDecision; refactored tracked-file upsert logic and index synchronization with conditional debug logging.
Age filter tests
internal/services/dirscan/age_filter_test.go
Replaced applyMaxSearcheeAgeFilter tests with selectEligibleRootWork-focused tests covering TV episodes, season packs, subtitle bumps, and audio scenarios.
Webhook queue tests
internal/services/dirscan/webhook_queue_test.go
New tests for mergeWebhookScanRoots and StartWebhookScan queued/merge behavior including lifecycle and cancellation.
UI
web/src/components/cross-seed/DirScanTab.tsx
Added RunFilesBadge showing discovered/eligible/skipped counts, renamed headers to “Eligible”, updated age-filter UI text and tooltips to reflect audio/video mtime semantics.
Docs / examples
documentation/docs/features/cross-seed/dir-scan.md
Expanded Simple mode examples (curl and end-to-end), clarified webhook 202/409 semantics and follow-up merging behavior.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant Service as DirScan Service
    participant Store as DirScanStore
    participant DB as Database

    Client->>Service: StartWebhookScan(directoryPath, scanRoot)
    activate Service
    Service->>Service: Acquire webhook mutex
    Service->>Store: GetActiveRun(directoryID)
    activate Store
    Store->>DB: Query runs ordered by prioritized status + timestamps (LIMIT 1)
    DB-->>Store: activeRun or nil
    Store-->>Service: activeRun
    deactivate Store

    alt Active run exists
        Service->>Service: mergedRoot = mergeWebhookScanRoots(dir, activeRun.scan_root, scanRoot)
        Service->>Store: UpdateRunScanRoot(activeRun.id, mergedRoot)
    else No active run
        Service->>Store: GetQueuedRun(directoryID)
        activate Store
        Store->>DB: Query newest queued run
        DB-->>Store: queuedRun or nil
        Store-->>Service: queuedRun
        deactivate Store

        alt Queued run exists
            Service->>Service: mergedRoot = mergeWebhookScanRoots(dir, queuedRun.scan_root, scanRoot)
            Service->>Store: UpdateRunScanRoot(queuedRun.id, mergedRoot)
        else No queued run
            Service->>Store: CreateRun(directoryID, scanRoot)
        end
    end

    Service->>Service: Release webhook mutex
    deactivate Service
Loading
sequenceDiagram
    participant Scanner as Scan Phase
    participant WorkSel as Work Selection
    participant Searcher as Search/Inject Phase
    participant Finalizer as Finalize Run
    Scanner->>Scanner: Build fileID index & scan directory tree
    Scanner-->>WorkSel: ScanResult
    activate WorkSel
    WorkSel->>WorkSel: Compute cutoff (maxAgeDays + now)
    loop For each root in ScanResult
        WorkSel->>WorkSel: Build work items for root
        loop For each work item
            WorkSel->>WorkSel: workItemIsStale(item, cutoff) (check video/audio mtimes)
            alt stale
                WorkSel->>WorkSel: mark skipped
            else fresh
                WorkSel->>WorkSel: mark eligible
            end
        end
    end
    WorkSel-->>Searcher: scanWorkSelection (eligible items + counts)
    deactivate WorkSel
    activate Searcher
    Searcher->>Searcher: Process eligible work items (search & inject)
    Searcher-->>Finalizer: matchesFound, torrentsAdded, filesFound, filesSkipped
    deactivate Searcher
    activate Finalizer
    Finalizer->>Finalizer: finalizeRun(filesFound, filesSkipped, matchesFound, torrentsAdded)
    Finalizer->>DB: Update run record
    deactivate Finalizer
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested labels

bugfix, dirscan, backend, web, tests, documentation, cross-seed

Poem

🐰 I sniffed the roots and hopped with care,

I merged the paths when seasons shared air.
Audio and video both earned a say,
Fresh stays in, stale hops away.
Badges blink bright — the rabbit's hooray!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 4.55% 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 'fix(dirscan): queue webhook scans and tighten age filtering' accurately summarizes the two main changes: webhook scan queueing and improved age filtering logic.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/dirscan-webhook-queue
📝 Coding Plan
  • Generate coding plan for human review comments

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: 4

Caution

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

⚠️ Outside diff range comments (1)
web/src/components/cross-seed/DirScanTab.tsx (1)

462-472: ⚠️ Potential issue | 🟠 Major

Don’t hide the new counts when everything was skipped.

hasStats ignores run.filesSkipped, so a run with 0 eligible, N skipped, 0 matches, and 0 added shows no file stats in the directory card. That hides the exact age-filter outcome this change is trying to surface.

💡 Suggested patch
-  const hasStats = run.filesFound > 0 || run.matchesFound > 0 || run.torrentsAdded > 0
+  const hasStats =
+    run.filesFound > 0 ||
+    run.filesSkipped > 0 ||
+    run.matchesFound > 0 ||
+    run.torrentsAdded > 0
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/components/cross-seed/DirScanTab.tsx` around lines 462 - 472,
hasStats currently only checks run.filesFound, run.matchesFound, and
run.torrentsAdded so runs with only run.filesSkipped > 0 are treated as having
no stats; update the condition in DirScanTab (the hasStats declaration) to
include run.filesSkipped and also update the rendered stats line (the JSX block
that uses RunFilesBadge and the trailing text) to surface the skipped count
(e.g., add ", {run.filesSkipped} skipped" or ensure RunFilesBadge reflects
skipped) so directory cards show the age-filter skipped count.
🧹 Nitpick comments (2)
internal/services/dirscan/service.go (1)

771-779: Nil-safe sorting, but consider defensive validation.

The sort pushes nil roots to the end, and processRootSearchee safely handles nil searchees. However, having nil roots in the selection likely indicates an upstream bug.

Consider adding a debug log or assertion if a nil root is encountered during iteration at line 800 to help identify such issues during development.

🤖 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 771 - 779, The sort
currently pushes nil entries in workSelection.roots to the end; add a defensive
check when iterating over workSelection.roots (the loop that calls
processRootSearchee) to detect nil root entries and either log a debug/error via
the existing logger or panic/assert in debug builds so upstream creation bugs
are caught early; specifically, inspect each element’s root before calling
processRootSearchee and emit a descriptive debug log mentioning the index and
the surrounding context (or trigger an assertion) when root == nil to aid
developers tracing the source of nil roots.
internal/services/dirscan/age_filter_test.go (1)

70-86: Consider adding a comment explaining the expected item count.

Line 84 asserts Len(t, items, 3) but doesn't explain why 3 items are expected. Adding a brief comment like // root + 2 episodes would improve test readability for future maintainers.

📝 Suggested documentation
 	items := buildSearcheeWorkItems(root, NewParser(nil))
+	// Expect 3 items: the root searchee + 2 individual episode work items
 	require.Len(t, items, 3)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/services/dirscan/age_filter_test.go` around lines 70 - 86, Add a
brief inline comment in TestWorkItemIsStale_KeepsFreshSeasonPack explaining why
items length equals 3 (e.g., "// root + 2 episodes") so future readers
understand the expectation produced by buildSearcheeWorkItems when called with
NewParser(nil); place the comment immediately above or beside the require.Len(t,
items, 3) assertion to clarify the source of the three work items.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@documentation/docs/features/cross-seed/dir-scan.md`:
- Around line 174-175: Update the documentation's "response-code table" and any
references to concurrent webhook behavior that still claim a 409 conflict;
change the contract text to reflect that follow-up webhook requests are now
queued and merged rather than rejected, replacing mentions of "409" with the new
behavior (queued/merged) and update any example responses, operator guidance,
and the paragraphs covering the webhook handling (the section previously
claiming 409s) so they describe the queuing/merging semantics and any new
headers or retry guidance operators should use.

In `@internal/models/dirscan.go`:
- Around line 793-805: The ORDER BY in GetActiveRun() and GetQueuedRun() is
non-deterministic when started_at ties; make selection deterministic by adding a
stable tiebreaker (e.g., append ", id DESC" or the primary key column such as
run_id DESC) after started_at DESC in the ORDER BY clause so scanRun() will
always receive a consistent row for concurrent inserts.

In `@internal/services/dirscan/content_detection.go`:
- Around line 16-19: Add the .aob extension to the audioExtensions map so
AUDIO_TS payloads are recognized as audio by filterContentFiles and therefore
subject to the age cutoff enforced by workItemIsStale; update the map literal
named audioExtensions (used by filterContentFiles) to include ".aob": {} so
directories containing .aob files no longer bypass stale-item filtering.

In `@internal/services/dirscan/webhook_queue_test.go`:
- Around line 18-20: The test compares a raw dir string to the cleaned path
returned by mergeWebhookScanRoots, causing OS-dependent failures; update the
assertion to normalize the expected value by running the test's dir through
filepath.Clean (and ensure the test imports path/filepath) so you compare
filepath.Clean(dir) to mergeWebhookScanRoots(dir, "", dir).

---

Outside diff comments:
In `@web/src/components/cross-seed/DirScanTab.tsx`:
- Around line 462-472: hasStats currently only checks run.filesFound,
run.matchesFound, and run.torrentsAdded so runs with only run.filesSkipped > 0
are treated as having no stats; update the condition in DirScanTab (the hasStats
declaration) to include run.filesSkipped and also update the rendered stats line
(the JSX block that uses RunFilesBadge and the trailing text) to surface the
skipped count (e.g., add ", {run.filesSkipped} skipped" or ensure RunFilesBadge
reflects skipped) so directory cards show the age-filter skipped count.

---

Nitpick comments:
In `@internal/services/dirscan/age_filter_test.go`:
- Around line 70-86: Add a brief inline comment in
TestWorkItemIsStale_KeepsFreshSeasonPack explaining why items length equals 3
(e.g., "// root + 2 episodes") so future readers understand the expectation
produced by buildSearcheeWorkItems when called with NewParser(nil); place the
comment immediately above or beside the require.Len(t, items, 3) assertion to
clarify the source of the three work items.

In `@internal/services/dirscan/service.go`:
- Around line 771-779: The sort currently pushes nil entries in
workSelection.roots to the end; add a defensive check when iterating over
workSelection.roots (the loop that calls processRootSearchee) to detect nil root
entries and either log a debug/error via the existing logger or panic/assert in
debug builds so upstream creation bugs are caught early; specifically, inspect
each element’s root before calling processRootSearchee and emit a descriptive
debug log mentioning the index and the surrounding context (or trigger an
assertion) when root == nil to aid developers tracing the source of nil roots.
🪄 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: 4eef5c74-9fd7-43bf-8b55-d0367f6ad665

📥 Commits

Reviewing files that changed from the base of the PR and between 99cf695 and e10b2d0.

📒 Files selected for processing (10)
  • documentation/docs/features/cross-seed/dir-scan.md
  • internal/models/dirscan.go
  • internal/models/dirscan_run_status_test.go
  • internal/services/dirscan/age_filter_test.go
  • internal/services/dirscan/content_detection.go
  • internal/services/dirscan/service.go
  • internal/services/dirscan/webhook_queue.go
  • internal/services/dirscan/webhook_queue_test.go
  • internal/services/dirscan/work_selection.go
  • web/src/components/cross-seed/DirScanTab.tsx

Comment thread documentation/docs/features/cross-seed/dir-scan.md Outdated
Comment thread internal/models/dirscan.go
Comment thread internal/services/dirscan/content_detection.go
Comment thread internal/services/dirscan/webhook_queue_test.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: 1

🧹 Nitpick comments (3)
internal/models/dirscan.go (1)

902-913: Consider returning affected row count for observability.

The caller may want to know how many queued runs were canceled (for logging or metrics). Currently, the count is discarded.

💡 Optional: return affected count
-func (s *DirScanStore) CancelQueuedRuns(ctx context.Context, directoryID int) error {
-	_, err := s.db.ExecContext(ctx, `
+func (s *DirScanStore) CancelQueuedRuns(ctx context.Context, directoryID int) (int64, error) {
+	res, err := s.db.ExecContext(ctx, `
 		UPDATE dir_scan_runs
 		SET status = ?, completed_at = CURRENT_TIMESTAMP
 		WHERE directory_id = ? AND status = 'queued'
 	`, DirScanRunStatusCanceled, directoryID)
 	if err != nil {
-		return fmt.Errorf("cancel queued runs: %w", err)
+		return 0, fmt.Errorf("cancel queued runs: %w", err)
 	}
-	return nil
+	rows, err := res.RowsAffected()
+	if err != nil {
+		return 0, fmt.Errorf("rows affected: %w", err)
+	}
+	return rows, nil
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/models/dirscan.go` around lines 902 - 913, The CancelQueuedRuns
function currently discards the ExecContext result; update
DirScanStore.CancelQueuedRuns to return the number of affected rows (e.g.,
change signature to return (int64, error) or (int, error)), capture the
sql.Result from s.db.ExecContext, call RowsAffected() on it, and return that
count along with any error; keep the same SQL and the DirScanRunStatusCanceled
constant usage and propagate errors as before (wrap fmt.Errorf("cancel queued
runs: %w", err) when ExecContext or RowsAffected() fails).
web/src/components/cross-seed/DirScanTab.tsx (1)

469-476: Minor: "skipped" count appears twice when tooltip is visible.

RunFilesBadge already shows "X discovered, Y skipped" in its tooltip. Line 473 then appends , Y skipped again to the inline text. This creates redundancy when hovering:

5 eligible (hover) → "10 discovered, 5 skipped"
plus inline: , 5 skipped, 2 matches, 1 added)

Consider removing the inline skipped text since the tooltip covers it, or keep both if the intent is to show skipped without requiring hover.

💡 Optional: remove duplicate skipped display
         <span className="inline-flex items-center gap-1">
           <span className="text-muted-foreground">(</span>
           <RunFilesBadge run={run} />
           <span className="text-muted-foreground">
-            {run.filesSkipped > 0 ? `, ${run.filesSkipped} skipped` : ""}
-            , {run.matchesFound} matches, {run.torrentsAdded} added)
+            , {run.matchesFound} matches, {run.torrentsAdded} added)
           </span>
         </span>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/components/cross-seed/DirScanTab.tsx` around lines 469 - 476, The
inline text is duplicating the skipped count already shown by RunFilesBadge's
tooltip; update DirScanTab.tsx so the inline span that currently uses
run.filesSkipped no longer appends ", {run.filesSkipped} skipped" — keep
RunFilesBadge as-is and change the surrounding text (the span containing ",
{run.filesSkipped} skipped, {run.matchesFound} matches, {run.torrentsAdded}
added") to only include the matches and torrentsAdded (e.g., ",
{run.matchesFound} matches, {run.torrentsAdded} added") so skipped is shown only
in the RunFilesBadge tooltip.
internal/services/dirscan/age_filter_test.go (1)

15-92: Consider converting the three TestSelectEligibleRootWork_* cases into a table-driven test.

The setup/act flow is duplicated and only fixtures/expectations vary. A table will reduce drift and make adding new file-type/age scenarios easier.

♻️ Refactor sketch
+func TestSelectEligibleRootWork(t *testing.T) {
+	now := time.Date(2026, time.March, 16, 13, 0, 0, 0, time.UTC)
+	tests := []struct {
+		name           string
+		scanResult      *ScanResult
+		wantDiscovered int
+		wantEligible   int
+		wantSkipped    int
+		wantRoots      int
+	}{
+		// existing TV / subtitle / AOB scenarios...
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			selection := selectEligibleRootWork(tc.scanResult, nil, NewParser(nil), 3, now)
+			require.Equal(t, tc.wantDiscovered, selection.discoveredFiles)
+			require.Equal(t, tc.wantEligible, selection.eligibleFiles)
+			require.Equal(t, tc.wantSkipped, selection.skippedFiles)
+			require.Len(t, selection.roots, tc.wantRoots)
+		})
+	}
+}

As per coding guidelines: "Prefer table-driven test cases in Go backend tests".

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

In `@internal/services/dirscan/age_filter_test.go` around lines 15 - 92, Combine
the three TestSelectEligibleRootWork_* tests into a single table-driven test
that iterates over cases describing the ScanResult fixture, the now/time values,
and expected selection fields; for each case construct the ScanResult (using
Searchee and ScannedFile entries), call selectEligibleRootWork(...,
NewParser(nil), 3, now) and assert selection.discoveredFiles,
selection.eligibleFiles, selection.skippedFiles, and selection.roots (and items
contents where applicable). Use the existing test names as case keys (e.g.,
"TVKeepsOnlyFreshEpisodeItems", "IgnoresFreshSubtitleBumps",
"TreatsAOBAsAudioContent") to keep intent clear, and remove duplicated setup/act
code by reusing the same loop body for assertions.
🤖 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/age_filter_test.go`:
- Around line 107-110: The test is brittle because it asserts freshness using
items[0]; instead locate the root work item explicitly (e.g., by matching a
Path, Name, or an IsRoot/Root flag on the work item returned by
buildSearcheeWorkItems) and run the stale check against that found item using
workItemIsStale; update the assertion to require the found item exists
(require.NotNil/require.True for the predicate that finds it) and then
require.False(t, workItemIsStale(rootItem, now.AddDate(0,0,-3))) to ensure the
root-specific behavior is tested regardless of slice ordering.

---

Nitpick comments:
In `@internal/models/dirscan.go`:
- Around line 902-913: The CancelQueuedRuns function currently discards the
ExecContext result; update DirScanStore.CancelQueuedRuns to return the number of
affected rows (e.g., change signature to return (int64, error) or (int, error)),
capture the sql.Result from s.db.ExecContext, call RowsAffected() on it, and
return that count along with any error; keep the same SQL and the
DirScanRunStatusCanceled constant usage and propagate errors as before (wrap
fmt.Errorf("cancel queued runs: %w", err) when ExecContext or RowsAffected()
fails).

In `@internal/services/dirscan/age_filter_test.go`:
- Around line 15-92: Combine the three TestSelectEligibleRootWork_* tests into a
single table-driven test that iterates over cases describing the ScanResult
fixture, the now/time values, and expected selection fields; for each case
construct the ScanResult (using Searchee and ScannedFile entries), call
selectEligibleRootWork(..., NewParser(nil), 3, now) and assert
selection.discoveredFiles, selection.eligibleFiles, selection.skippedFiles, and
selection.roots (and items contents where applicable). Use the existing test
names as case keys (e.g., "TVKeepsOnlyFreshEpisodeItems",
"IgnoresFreshSubtitleBumps", "TreatsAOBAsAudioContent") to keep intent clear,
and remove duplicated setup/act code by reusing the same loop body for
assertions.

In `@web/src/components/cross-seed/DirScanTab.tsx`:
- Around line 469-476: The inline text is duplicating the skipped count already
shown by RunFilesBadge's tooltip; update DirScanTab.tsx so the inline span that
currently uses run.filesSkipped no longer appends ", {run.filesSkipped} skipped"
— keep RunFilesBadge as-is and change the surrounding text (the span containing
", {run.filesSkipped} skipped, {run.matchesFound} matches, {run.torrentsAdded}
added") to only include the matches and torrentsAdded (e.g., ",
{run.matchesFound} matches, {run.torrentsAdded} added") so skipped is shown only
in the RunFilesBadge tooltip.
🪄 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: fafc636f-8faf-47ba-aec8-41d00e36dcb7

📥 Commits

Reviewing files that changed from the base of the PR and between c350f54 and 2084493.

📒 Files selected for processing (6)
  • documentation/docs/features/cross-seed/dir-scan.md
  • internal/models/dirscan.go
  • internal/models/dirscan_run_status_test.go
  • internal/services/dirscan/age_filter_test.go
  • internal/services/dirscan/content_detection.go
  • web/src/components/cross-seed/DirScanTab.tsx
🚧 Files skipped from review as they are similar to previous changes (2)
  • internal/models/dirscan_run_status_test.go
  • internal/services/dirscan/content_detection.go

Comment thread internal/services/dirscan/age_filter_test.go Outdated
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 (1)
internal/services/dirscan/age_filter_test.go (1)

15-92: Consider consolidating these test scenarios into a table-driven test.

The three test functions (TVKeepsOnlyFreshEpisodeItems, IgnoresFreshSubtitleBumps, TreatsAOBAsAudioContent) share identical setup and assertion patterns—only the input data and expected counts differ. This repetition is a good candidate for table-driven refactoring, which would make future scenario additions simpler and clearer.

Per coding guidelines: Prefer table-driven test cases in Go backend tests.

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

In `@internal/services/dirscan/age_filter_test.go` around lines 15 - 92,
Consolidate the three tests
TestSelectEligibleRootWork_TVKeepsOnlyFreshEpisodeItems,
TestSelectEligibleRootWork_IgnoresFreshSubtitleBumps, and
TestSelectEligibleRootWork_TreatsAOBAsAudioContent into a single table-driven
test: create a slice of cases each with a name, input ScanResult (Searchee and
ScannedFile list with appropriate ModTime/Size), expected
discoveredFiles/eligibleFiles/skippedFiles/roots length and any specific item
name expectations; loop over cases using t.Run(case.name, func(t *testing.T) {
selection := selectEligibleRootWork(case.scanResult, nil, NewParser(nil), 3,
now); assert the expected fields (selection.discoveredFiles,
selection.eligibleFiles, selection.skippedFiles, len(selection.roots),
len(selection.roots[0].items) and item searchee.Name when applicable) }),
preserving the same now/old/fresh time setup per case and keeping use of
ScanResult, Searchee, ScannedFile, selectEligibleRootWork and NewParser to
locate the code.
🤖 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/dirscan/age_filter_test.go`:
- Around line 15-92: Consolidate the three tests
TestSelectEligibleRootWork_TVKeepsOnlyFreshEpisodeItems,
TestSelectEligibleRootWork_IgnoresFreshSubtitleBumps, and
TestSelectEligibleRootWork_TreatsAOBAsAudioContent into a single table-driven
test: create a slice of cases each with a name, input ScanResult (Searchee and
ScannedFile list with appropriate ModTime/Size), expected
discoveredFiles/eligibleFiles/skippedFiles/roots length and any specific item
name expectations; loop over cases using t.Run(case.name, func(t *testing.T) {
selection := selectEligibleRootWork(case.scanResult, nil, NewParser(nil), 3,
now); assert the expected fields (selection.discoveredFiles,
selection.eligibleFiles, selection.skippedFiles, len(selection.roots),
len(selection.roots[0].items) and item searchee.Name when applicable) }),
preserving the same now/old/fresh time setup per case and keeping use of
ScanResult, Searchee, ScannedFile, selectEligibleRootWork and NewParser to
locate the code.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 33abfeb1-8ddd-479e-a060-8df66a5d73a7

📥 Commits

Reviewing files that changed from the base of the PR and between 2084493 and 62b4741.

📒 Files selected for processing (1)
  • internal/services/dirscan/age_filter_test.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.

🧹 Nitpick comments (2)
internal/services/dirscan/progress.go (1)

191-197: Consider extracting match-type string literals to constants.

The "file_id" and "path" strings are used in multiple places. Constants would provide compile-time typo detection and a single source of truth.

♻️ Optional: Extract to package-level constants
+const (
+	matchedByFileID = "file_id"
+	matchedByPath   = "path"
+)
+
 func lookupTrackedFile(scanned *ScannedFile, idx *trackedFilesIndex) (*models.DirScanFile, string) {
 	if scanned == nil || idx == nil {
 		return nil, ""
 	}

 	if !scanned.FileID.IsZero() {
 		if existing := idx.byFileID[string(scanned.FileID.Bytes())]; existing != nil {
-			return existing, "file_id"
+			return existing, matchedByFileID
 		}
 	}
 	if existing := idx.byPath[scanned.Path]; existing != nil {
-		return existing, "path"
+		return existing, matchedByPath
 	}

 	return nil, ""
 }

Then update line 248 similarly:

-	if alreadySeeding || matchedBy == "file_id" {
+	if alreadySeeding || matchedBy == matchedByFileID {

Also applies to: 248-248

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

In `@internal/services/dirscan/progress.go` around lines 191 - 197, Extract the
match-type string literals into package-level constants (e.g. const
matchTypeFileID = "file_id" and const matchTypePath = "path") and replace direct
uses in the progress logic: change the returns in the checks using idx.byFileID
(and scanned.FileID.Bytes()) and idx.byPath (and scanned.Path) to return the
corresponding constants; also update the other occurrence referenced near the
later return (the one at the similar check around line 248) to use the same
constants so all match-type usages share a single source of truth.
internal/services/dirscan/work_selection.go (1)

254-289: Document the intentional asymmetry in staleness logic.

The function applies different staleness criteria depending on item type:

  • TV groups with >1 content file (lines 264-274): Stale if any file is old — a strict check that marks season packs with even one stale episode.
  • Non-TV or single-file items (lines 276-288): Stale only if the newest file is old — a lenient check.

This asymmetry appears intentional per the PR objective (per-item granularity for TV), but a brief comment would help future maintainers understand the design choice.

📝 Suggested comment
 func workItemIsStale(item searcheeWorkItem, cutoff time.Time) bool {
 	if item.searchee == nil || cutoff.IsZero() {
 		return false
 	}

 	contentFiles := filterContentFiles(item.searchee.Files)
 	if len(contentFiles) == 0 {
 		return false
 	}

+	// TV groups with multiple content files are marked stale if ANY file is
+	// older than the cutoff (strict). Non-TV items are stale only if the
+	// NEWEST content file is older (lenient).
 	if item.tvGroup != nil && len(contentFiles) > 1 {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/services/dirscan/work_selection.go` around lines 254 - 289, Add a
short comment inside workItemIsStale explaining the intentional asymmetry: for
TV groups with more than one content file (checking item.tvGroup and
len(contentFiles) > 1) we mark the work item stale if any file is older than
cutoff (per-episode staleness), whereas for non-TV or single-file items we
compute the newest file ModTime and only mark stale if that newest time is
before cutoff; place this comment near the tvGroup branch (around the loop over
contentFiles) and mention the rationale (per-item granularity for TV group
packs) so future maintainers understand the design choice.
🤖 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/dirscan/progress.go`:
- Around line 191-197: Extract the match-type string literals into package-level
constants (e.g. const matchTypeFileID = "file_id" and const matchTypePath =
"path") and replace direct uses in the progress logic: change the returns in the
checks using idx.byFileID (and scanned.FileID.Bytes()) and idx.byPath (and
scanned.Path) to return the corresponding constants; also update the other
occurrence referenced near the later return (the one at the similar check around
line 248) to use the same constants so all match-type usages share a single
source of truth.

In `@internal/services/dirscan/work_selection.go`:
- Around line 254-289: Add a short comment inside workItemIsStale explaining the
intentional asymmetry: for TV groups with more than one content file (checking
item.tvGroup and len(contentFiles) > 1) we mark the work item stale if any file
is older than cutoff (per-episode staleness), whereas for non-TV or single-file
items we compute the newest file ModTime and only mark stale if that newest time
is before cutoff; place this comment near the tvGroup branch (around the loop
over contentFiles) and mention the rationale (per-item granularity for TV group
packs) so future maintainers understand the design choice.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: d20b3960-dc90-4394-8097-fd98a1a43e9c

📥 Commits

Reviewing files that changed from the base of the PR and between 62b4741 and 67e13d7.

📒 Files selected for processing (4)
  • internal/services/dirscan/age_filter_test.go
  • internal/services/dirscan/progress.go
  • internal/services/dirscan/service.go
  • internal/services/dirscan/work_selection.go
✅ Files skipped from review due to trivial changes (1)
  • internal/services/dirscan/service.go

@s0up4200 s0up4200 merged commit 56995f1 into develop Mar 17, 2026
15 checks passed
@s0up4200 s0up4200 deleted the fix/dirscan-webhook-queue branch March 17, 2026 13:47
alexlebens pushed a commit to alexlebens/infrastructure that referenced this pull request Mar 20, 2026
This PR contains the following updates:

| Package | Update | Change |
|---|---|---|
| [ghcr.io/autobrr/qui](https://github.com/autobrr/qui) | minor | `v1.14.1` → `v1.15.0` |

---

> ⚠️ **Warning**
>
> Some dependencies could not be looked up. Check the [Dependency Dashboard](issues/2) for more information.

---

### Release Notes

<details>
<summary>autobrr/qui (ghcr.io/autobrr/qui)</summary>

### [`v1.15.0`](https://github.com/autobrr/qui/releases/tag/v1.15.0)

[Compare Source](autobrr/qui@v1.14.1...v1.15.0)

#### Changelog

##### Breaking change

CORS is disabled by default; enable by setting QUI\_\_CORS\_ALLOWED\_ORIGINS with explicit origins (http(s)://host\[:port]). See <https://getqui.com/docs/advanced/sso-proxy-cors>

##### New Features

- [`93786a2`](autobrr/qui@93786a2): feat(automations): add configurable processing priority/sorting ([#&#8203;1235](autobrr/qui#1235)) ([@&#8203;Oscariremma](https://github.com/Oscariremma))
- [`45eaf1f`](autobrr/qui@45eaf1f): feat(database): add postgres and sqlite migration CLI ([#&#8203;1530](autobrr/qui#1530)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`430f5d1`](autobrr/qui@430f5d1): feat(torrents): mediaInfo dialog ([#&#8203;1537](autobrr/qui#1537)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`8eb8903`](autobrr/qui@8eb8903): feat(web): Add persistence to unified instance filter in sidebar ([#&#8203;1560](autobrr/qui#1560)) ([@&#8203;drtaru](https://github.com/drtaru))
- [`7aadde7`](autobrr/qui@7aadde7): feat(web): add path autocomplete to set location dialog ([#&#8203;1432](autobrr/qui#1432)) ([@&#8203;nitrobass24](https://github.com/nitrobass24))
- [`077f32c`](autobrr/qui@077f32c): feat: add mediainfo api endpoint ([#&#8203;1545](autobrr/qui#1545)) ([@&#8203;Audionut](https://github.com/Audionut))
- [`99cf695`](autobrr/qui@99cf695): feat: endpoint to trigger directory scans from external tools ([#&#8203;1559](autobrr/qui#1559)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`8956f9b`](autobrr/qui@8956f9b): feat: unify bulk tag editor ([#&#8203;1571](autobrr/qui#1571)) ([@&#8203;s0up4200](https://github.com/s0up4200))

##### Bug Fixes

- [`552d617`](autobrr/qui@552d617): fix(api): align add torrent OpenAPI field ([#&#8203;1617](autobrr/qui#1617)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`424f7a0`](autobrr/qui@424f7a0): fix(api): restrict CORS to explicit allowlist ([#&#8203;1551](autobrr/qui#1551)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`38991d8`](autobrr/qui@38991d8): fix(auth): allow loopback health probes ([#&#8203;1621](autobrr/qui#1621)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`4ae88c9`](autobrr/qui@4ae88c9): fix(automations): align include-cross-seeds category apply ([#&#8203;1517](autobrr/qui#1517)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`6a127a8`](autobrr/qui@6a127a8): fix(automations): scope skipWithin to only deleted action ([#&#8203;1538](autobrr/qui#1538)) ([@&#8203;jabloink](https://github.com/jabloink))
- [`c776189`](autobrr/qui@c776189): fix(crossseed): avoid completion timeout misses on non-Gazelle torrents ([#&#8203;1536](autobrr/qui#1536)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`b1338a7`](autobrr/qui@b1338a7): fix(crossseed): handle missing webhook collection tags ([#&#8203;1610](autobrr/qui#1610)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`eacbb68`](autobrr/qui@eacbb68): fix(crossseed): normalize hdr aliases ([#&#8203;1572](autobrr/qui#1572)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`537ad46`](autobrr/qui@537ad46): fix(crossseed): queue completion searches and retry rate-limit waits ([#&#8203;1523](autobrr/qui#1523)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`4fc550f`](autobrr/qui@4fc550f): fix(crossseed): use autobrr indexer ids for webhooks ([#&#8203;1614](autobrr/qui#1614)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`08029ad`](autobrr/qui@08029ad): fix(crossseed): valid partial matches being rejected ([#&#8203;1291](autobrr/qui#1291)) ([@&#8203;rybertm](https://github.com/rybertm))
- [`77eedd9`](autobrr/qui@77eedd9): fix(database): avoid postgres temp-table statement caching ([#&#8203;1581](autobrr/qui#1581)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`25daa17`](autobrr/qui@25daa17): fix(dirscan): honor canceled queued webhook runs ([#&#8203;1612](autobrr/qui#1612)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`56995f1`](autobrr/qui@56995f1): fix(dirscan): queue webhook scans and tighten age filtering ([#&#8203;1603](autobrr/qui#1603)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`444d07b`](autobrr/qui@444d07b): fix(dirscan): select concrete hardlink base dir ([#&#8203;1606](autobrr/qui#1606)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`c35bea0`](autobrr/qui@c35bea0): fix(instances): improve settings dialog scrolling ([#&#8203;1569](autobrr/qui#1569)) ([@&#8203;nuxencs](https://github.com/nuxencs))
- [`dc501a0`](autobrr/qui@dc501a0): fix(proxy): reauth qbit passthrough requests ([#&#8203;1582](autobrr/qui#1582)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`7950d1d`](autobrr/qui@7950d1d): fix(proxy): search endpoint handling ([#&#8203;1524](autobrr/qui#1524)) ([@&#8203;Audionut](https://github.com/Audionut))
- [`1076eea`](autobrr/qui@1076eea): fix(qbit): prune empty managed dirs after delete\_with\_files ([#&#8203;1604](autobrr/qui#1604)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`5a3114b`](autobrr/qui@5a3114b): fix(qbittorrent): stop reboot torrent\_completed spam ([#&#8203;1515](autobrr/qui#1515)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`1d02e6c`](autobrr/qui@1d02e6c): fix(settings): contain settings tab scrolling ([#&#8203;1567](autobrr/qui#1567)) ([@&#8203;nuxencs](https://github.com/nuxencs))
- [`f5d69f3`](autobrr/qui@f5d69f3): fix(settings): smoother gradient ([#&#8203;1570](autobrr/qui#1570)) ([@&#8203;nuxencs](https://github.com/nuxencs))
- [`1c0c3bc`](autobrr/qui@1c0c3bc): fix(torrents): copy MediaInfo summary without brackets ([#&#8203;1540](autobrr/qui#1540)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`3ec913a`](autobrr/qui@3ec913a): fix(web): auto-append slash on path autocomplete selection ([#&#8203;1431](autobrr/qui#1431)) ([@&#8203;nitrobass24](https://github.com/nitrobass24))
- [`aa2f3da`](autobrr/qui@aa2f3da): fix(web): check field.state.value type in AddTorrentDialog ([#&#8203;1613](autobrr/qui#1613)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`1abfc5e`](autobrr/qui@1abfc5e): fix(web): handle SSO proxy redirect to /index.html ([#&#8203;1600](autobrr/qui#1600)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`1991f90`](autobrr/qui@1991f90): fix(web): warn before enabling reannounce ([#&#8203;1583](autobrr/qui#1583)) ([@&#8203;s0up4200](https://github.com/s0up4200))

##### Other Changes

- [`4069492`](autobrr/qui@4069492): chore(deps): bump the github group with 3 updates ([#&#8203;1535](autobrr/qui#1535)) ([@&#8203;dependabot](https://github.com/dependabot)\[bot])
- [`a02e9e8`](autobrr/qui@a02e9e8): chore(deps): bump the github group with 7 updates ([#&#8203;1558](autobrr/qui#1558)) ([@&#8203;dependabot](https://github.com/dependabot)\[bot])
- [`8713667`](autobrr/qui@8713667): chore(deps): bump the golang group with 15 updates ([#&#8203;1543](autobrr/qui#1543)) ([@&#8203;dependabot](https://github.com/dependabot)\[bot])
- [`420607e`](autobrr/qui@420607e): chore(go,ci): adopt go fix, bump to 1.26, and speed up PR checks ([#&#8203;1480](autobrr/qui#1480)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`0d0df45`](autobrr/qui@0d0df45): docs: add password reset section to CLI commands ([#&#8203;1598](autobrr/qui#1598)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`9ef56a2`](autobrr/qui@9ef56a2): refactor(makefile): windows support ([#&#8203;1546](autobrr/qui#1546)) ([@&#8203;Audionut](https://github.com/Audionut))
- [`7899cc8`](autobrr/qui@7899cc8): refactor(reflinking): add windows ReFS filesystem support ([#&#8203;1576](autobrr/qui#1576)) ([@&#8203;Audionut](https://github.com/Audionut))
- [`51d34ab`](autobrr/qui@51d34ab): refactor(releases): share hdr normalization helpers ([#&#8203;1586](autobrr/qui#1586)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`c7f4e3d`](autobrr/qui@c7f4e3d): refactor(web): tighten unified scope navigation ([#&#8203;1618](autobrr/qui#1618)) ([@&#8203;s0up4200](https://github.com/s0up4200))
- [`4b05177`](autobrr/qui@4b05177): test(handlers): cover tag baseline field requests ([@&#8203;s0up4200](https://github.com/s0up4200))

**Full Changelog**: <autobrr/qui@v1.14.1...v1.15.0>

#### Docker images

- `docker pull ghcr.io/autobrr/qui:v1.15.0`
- `docker pull ghcr.io/autobrr/qui:latest`

#### What to do next?

- Join our [Discord server](https://discord.autobrr.com/qui)

Thank you for using qui!

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My41OS4yIiwidXBkYXRlZEluVmVyIjoiNDMuNTkuMiIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiaW1hZ2UiXX0=-->

Reviewed-on: https://gitea.alexlebens.dev/alexlebens/infrastructure/pulls/4884
Co-authored-by: Renovate Bot <renovate-bot@alexlebens.net>
Co-committed-by: Renovate Bot <renovate-bot@alexlebens.net>
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.

1 participant