Skip to content

Commit b83aebe

Browse files
authored
fix(web): align CrossSeedDialog indexers with search flows (#619)
1 parent 2438fc6 commit b83aebe

2 files changed

Lines changed: 100 additions & 66 deletions

File tree

web/src/components/torrents/CrossSeedDialog.tsx

Lines changed: 54 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -30,15 +30,14 @@ import {
3030
} from "@/components/ui/dropdown-menu"
3131
import { Input } from "@/components/ui/input"
3232
import { Switch } from "@/components/ui/switch"
33-
import { formatBytes } from "@/lib/utils"
33+
import { formatBytes, formatRelativeTime } from "@/lib/utils"
3434
import type {
3535
CrossSeedApplyResponse,
3636
CrossSeedTorrentSearchResponse,
3737
Torrent
3838
} from "@/types"
3939
import { ChevronDown, ChevronRight, Loader2, RefreshCw, SlidersHorizontal } from "lucide-react"
4040
import { memo, useCallback, useMemo, useState } from "react"
41-
import { formatRelativeTime } from "@/lib/utils"
4241

4342
type CrossSeedSearchResult = CrossSeedTorrentSearchResponse["results"][number]
4443
type CrossSeedIndexerOption = {
@@ -138,6 +137,22 @@ const CrossSeedDialogComponent = ({
138137
.sort((a, b) => a.name.localeCompare(b.name))
139138
}, [indexerNameMap, sourceTorrent?.excludedIndexers])
140139

140+
const capabilityFilteredIndexerEntries = useMemo(() => {
141+
const available = sourceTorrent?.availableIndexers
142+
if (!available || available.length === 0) {
143+
return []
144+
}
145+
const supported = new Set(available)
146+
return Object.keys(indexerNameMap)
147+
.map(id => Number(id))
148+
.filter(id => !Number.isNaN(id) && !supported.has(id))
149+
.map(id => ({
150+
id,
151+
name: indexerNameMap[id] ?? `Indexer ${id}`,
152+
}))
153+
.sort((a, b) => a.name.localeCompare(b.name))
154+
}, [indexerNameMap, sourceTorrent?.availableIndexers])
155+
141156
const [excludedOpen, setExcludedOpen] = useState(false)
142157
const [applyResultOpen, setApplyResultOpen] = useState(true)
143158

@@ -209,13 +224,25 @@ const CrossSeedDialogComponent = ({
209224
onScopeSearch={onScopeSearch}
210225
isSearching={isLoading}
211226
/>
212-
) : sourceTorrent && (
227+
) : (
213228
<div className="space-y-1.5 text-sm text-muted-foreground">
214-
<p className="font-medium">No compatible indexers found</p>
229+
<p className="font-medium">No Torznab indexers available</p>
215230
<p className="text-xs">
216-
None of your enabled indexers support the required capabilities ({sourceTorrent.requiredCaps?.join(", ")})
217-
or categories ({sourceTorrent.searchCategories?.join(", ")}) for this {sourceTorrent.contentType} content.
231+
Add or enable Torznab indexers in Settings to search for cross-seeds.
232+
</p>
233+
</div>
234+
)}
235+
{(capabilityFilteredIndexerEntries.length > 0 && indexerOptions.length > 0) && (
236+
<div className="mt-2 rounded-md border border-dashed border-border/60 bg-muted/30 p-2 text-xs text-muted-foreground">
237+
<p className="font-medium text-[11px] text-foreground">Capability note</p>
238+
<p>
239+
These indexers lack the required capabilities/categories for this torrent and will be skipped by the server:
218240
</p>
241+
<ul className="mt-1.5 ml-4 space-y-0.5">
242+
{capabilityFilteredIndexerEntries.map(entry => (
243+
<li key={entry.id} className="break-words">{entry.name}</li>
244+
))}
245+
</ul>
219246
</div>
220247
)}
221248
</div>
@@ -308,7 +335,7 @@ const CrossSeedDialogComponent = ({
308335
<>
309336
{results.length === 0 ? (
310337
<div className="rounded-md border border-dashed p-4 text-center text-sm text-muted-foreground">
311-
No matches found across the enabled indexers.
338+
No matches found in your search.
312339
</div>
313340
) : (
314341
<>
@@ -506,6 +533,7 @@ const IndexerCheckboxItem = memo(({
506533
key={option.id}
507534
checked={isChecked}
508535
onCheckedChange={handleChange}
536+
onSelect={(event) => event.preventDefault()} // keep menu open for multi-select
509537
>
510538
{option.name}
511539
</DropdownMenuCheckboxItem>
@@ -536,17 +564,15 @@ const CrossSeedScopeSelector = memo(({
536564
// Calculate the actual count of indexers that will be used for search
537565
const searchIndexerCount = useMemo(() => {
538566
if (indexerMode === "all") {
539-
return Math.max(0, total - excludedCount)
567+
return total
540568
}
541-
// For custom mode, exclude any selected indexers that are also excluded
542-
const availableSelected = selectedIndexerIds.filter(id => !excludedIndexerIds.includes(id))
543-
return availableSelected.length
544-
}, [indexerMode, total, excludedCount, selectedIndexerIds, excludedIndexerIds])
569+
return selectedCount
570+
}, [indexerMode, total, selectedCount])
545571

546572
const statusText = useMemo(() => {
547573
const suffix = total === 1 ? "indexer" : "indexers"
548574
if (indexerMode === "all") {
549-
return `${total} compatible ${suffix}`
575+
return `${total} enabled ${suffix}`
550576
}
551577
if (selectedCount === 0) {
552578
return "None selected"
@@ -613,7 +639,7 @@ const CrossSeedScopeSelector = memo(({
613639
disabled={isSearching}
614640
className="h-7 flex-1 sm:flex-initial text-xs"
615641
>
616-
All Compatible
642+
All indexers
617643
</Button>
618644
<Button
619645
size="sm"
@@ -646,8 +672,20 @@ const CrossSeedScopeSelector = memo(({
646672
<DropdownMenuSeparator />
647673
{indexerItems}
648674
<DropdownMenuSeparator />
649-
<DropdownMenuItem onClick={onSelectAllIndexers} className="text-xs">Select all</DropdownMenuItem>
650-
<DropdownMenuItem onClick={onClearIndexerSelection} className="text-xs">Clear selection</DropdownMenuItem>
675+
<DropdownMenuItem
676+
onSelect={(event) => event.preventDefault()}
677+
onClick={onSelectAllIndexers}
678+
className="text-xs"
679+
>
680+
Select all
681+
</DropdownMenuItem>
682+
<DropdownMenuItem
683+
onSelect={(event) => event.preventDefault()}
684+
onClick={onClearIndexerSelection}
685+
className="text-xs"
686+
>
687+
Clear selection
688+
</DropdownMenuItem>
651689
</DropdownMenuContent>
652690
</DropdownMenu>
653691
)}

web/src/hooks/useCrossSeedSearch.tsx

Lines changed: 46 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,6 @@ export function useCrossSeedSearch(instanceId: number) {
5555
const [crossSeedIndexerSelection, setCrossSeedIndexerSelection] = useState<number[]>([])
5656
const [crossSeedHasSearched, setCrossSeedHasSearched] = useState(false)
5757
const [crossSeedRefreshCooldownUntil, setCrossSeedRefreshCooldownUntil] = useState(0)
58-
const [lastSourceTorrent, setLastSourceTorrent] = useState<CrossSeedTorrentSearchResponse["sourceTorrent"] | null>(null)
5958

6059
const crossSeedPollingRef = useRef<ReturnType<typeof setInterval> | null>(null)
6160
const crossSeedPollingInFlightRef = useRef(false)
@@ -66,53 +65,35 @@ export function useCrossSeedSearch(instanceId: number) {
6665
staleTime: 5 * 60 * 1000,
6766
})
6867

69-
useEffect(() => {
70-
if (crossSeedSearchResponse?.sourceTorrent) {
71-
setLastSourceTorrent(crossSeedSearchResponse.sourceTorrent)
72-
}
73-
}, [crossSeedSearchResponse?.sourceTorrent])
74-
7568
const crossSeedIndexerOptions = useMemo(() => {
76-
const sourceTorrent = crossSeedSearchResponse?.sourceTorrent ?? lastSourceTorrent
77-
if (!sourceTorrent) {
78-
return sortedEnabledIndexers.map(indexer => ({ id: indexer.id, name: indexer.name }))
69+
const allowedIds = crossSeedSearchResponse?.sourceTorrent?.availableIndexers
70+
const filteredIds = crossSeedSearchResponse?.sourceTorrent?.filteredIndexers
71+
const excludedIds = crossSeedSearchResponse?.sourceTorrent?.excludedIndexers
72+
73+
let candidateIds: Set<number> | null = null
74+
if (allowedIds && allowedIds.length > 0) {
75+
candidateIds = new Set(allowedIds)
76+
} else if (filteredIds && filteredIds.length > 0) {
77+
candidateIds = new Set(filteredIds)
7978
}
8079

81-
if (sourceTorrent.availableIndexers && sourceTorrent.filteredIndexers) {
82-
const targetIndexerIds = crossSeedHasSearched ? sourceTorrent.filteredIndexers : sourceTorrent.availableIndexers
83-
const filteredIds = new Set(targetIndexerIds)
84-
return sortedEnabledIndexers
85-
.filter(indexer => filteredIds.has(indexer.id))
86-
.map(indexer => ({ id: indexer.id, name: indexer.name }))
87-
}
88-
89-
if (sourceTorrent.filteredIndexers && sourceTorrent.filteredIndexers.length > 0) {
90-
const filteredIds = new Set(sourceTorrent.filteredIndexers)
91-
return sortedEnabledIndexers
92-
.filter(indexer => filteredIds.has(indexer.id))
93-
.map(indexer => ({ id: indexer.id, name: indexer.name }))
94-
}
95-
96-
const requiredCaps = sourceTorrent.requiredCaps ?? []
97-
const requiredCategories = sourceTorrent.searchCategories ?? []
98-
99-
const filteredIndexers = sortedEnabledIndexers.filter(indexer => {
100-
if (requiredCaps.length === 0 && requiredCategories.length === 0) {
101-
return true
102-
}
103-
104-
const indexerCaps = indexer.capabilities ?? []
105-
const hasRequiredCaps = requiredCaps.length === 0 || requiredCaps.every(cap => indexerCaps.includes(cap))
106-
107-
const indexerCategoryIds = (indexer.categories ?? []).map(cat => cat.category_id)
108-
const hasRequiredCategories = requiredCategories.length === 0 ||
109-
requiredCategories.some(catId => indexerCategoryIds.includes(catId))
110-
111-
return hasRequiredCaps && hasRequiredCategories
112-
})
113-
114-
return filteredIndexers.map(indexer => ({ id: indexer.id, name: indexer.name }))
115-
}, [sortedEnabledIndexers, crossSeedSearchResponse, lastSourceTorrent, crossSeedHasSearched])
80+
const excludedIdSet = excludedIds
81+
? new Set(
82+
Object.keys(excludedIds)
83+
.map(id => Number(id))
84+
.filter(id => !Number.isNaN(id))
85+
)
86+
: null
87+
88+
return sortedEnabledIndexers
89+
.filter(indexer => (!candidateIds || candidateIds.has(indexer.id)) && (!excludedIdSet || !excludedIdSet.has(indexer.id)))
90+
.map(indexer => ({ id: indexer.id, name: indexer.name }))
91+
}, [
92+
crossSeedSearchResponse?.sourceTorrent?.availableIndexers,
93+
crossSeedSearchResponse?.sourceTorrent?.filteredIndexers,
94+
crossSeedSearchResponse?.sourceTorrent?.excludedIndexers,
95+
sortedEnabledIndexers,
96+
])
11697

11798
const crossSeedIndexerNameMap = useMemo(() => {
11899
const map: Record<number, string> = {}
@@ -122,6 +103,14 @@ export function useCrossSeedSearch(instanceId: number) {
122103
return map
123104
}, [sortedEnabledIndexers])
124105

106+
const excludedIndexerIds = useMemo(
107+
() =>
108+
Object.keys(crossSeedSearchResponse?.sourceTorrent?.excludedIndexers ?? {})
109+
.map(id => Number(id))
110+
.filter(id => !Number.isNaN(id)),
111+
[crossSeedSearchResponse?.sourceTorrent?.excludedIndexers]
112+
)
113+
125114
const getCrossSeedResultKey = useCallback(
126115
(result: CrossSeedTorrentSearchResponse["results"][number], index: number) =>
127116
result.guid || result.downloadUrl || `${result.indexer}-${index}`,
@@ -142,7 +131,6 @@ export function useCrossSeedSearch(instanceId: number) {
142131
setCrossSeedApplyResult(null)
143132
setCrossSeedIndexerMode("all")
144133
setCrossSeedIndexerSelection([])
145-
setLastSourceTorrent(null)
146134
setCrossSeedHasSearched(false)
147135
}, [])
148136

@@ -176,11 +164,17 @@ export function useCrossSeedSearch(instanceId: number) {
176164
return
177165
}
178166

179-
let resolvedIndexerIds: number[] | undefined
167+
let resolvedIndexerIds: number[] | null | undefined
180168
if (indexerOverride !== undefined) {
181-
resolvedIndexerIds = indexerOverride ?? undefined
182-
} else if (crossSeedIndexerMode === "custom" && crossSeedIndexerSelection.length > 0) {
169+
resolvedIndexerIds = indexerOverride
170+
} else if (crossSeedIndexerMode === "custom") {
183171
resolvedIndexerIds = crossSeedIndexerSelection
172+
} else {
173+
resolvedIndexerIds = sortedEnabledIndexers.map(indexer => indexer.id)
174+
}
175+
176+
if (Array.isArray(resolvedIndexerIds) && excludedIndexerIds.length > 0) {
177+
resolvedIndexerIds = resolvedIndexerIds.filter(id => !excludedIndexerIds.includes(id))
184178
}
185179

186180
setCrossSeedSearchLoading(true)
@@ -192,7 +186,7 @@ export function useCrossSeedSearch(instanceId: number) {
192186
void api
193187
.searchCrossSeedTorrent(instanceId, torrent.hash, {
194188
findIndividualEpisodes: crossSeedSettings?.findIndividualEpisodes ?? false,
195-
indexerIds: resolvedIndexerIds && resolvedIndexerIds.length > 0 ? resolvedIndexerIds : undefined,
189+
indexerIds: Array.isArray(resolvedIndexerIds) && resolvedIndexerIds.length > 0 ? resolvedIndexerIds : undefined,
196190
cacheMode: options?.bypassCache ? "bypass" : undefined,
197191
})
198192
.then(response => {
@@ -235,6 +229,8 @@ export function useCrossSeedSearch(instanceId: number) {
235229
crossSeedIndexerSelection,
236230
crossSeedSettings?.findIndividualEpisodes,
237231
getCrossSeedResultKey,
232+
excludedIndexerIds,
233+
sortedEnabledIndexers,
238234
instanceId,
239235
]
240236
)

0 commit comments

Comments
 (0)