Skip to content

Commit 9eaf9e6

Browse files
committed
feat(torrents): warn about cross-seeded torrents in delete dialogs
1 parent 0af7504 commit 9eaf9e6

4 files changed

Lines changed: 360 additions & 3 deletions

File tree

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
/*
2+
* Copyright (c) 2025, s0up and the autobrr contributors.
3+
* SPDX-License-Identifier: GPL-2.0-or-later
4+
*/
5+
6+
import { useState } from "react"
7+
import { AlertTriangle, ChevronDown, ChevronRight, GitBranch, Info, Loader2 } from "lucide-react"
8+
9+
import { cn } from "@/lib/utils"
10+
import type { CrossSeedTorrent } from "@/lib/cross-seed-utils"
11+
import { getLinuxIsoName, useIncognitoMode } from "@/lib/incognito"
12+
13+
interface CrossSeedWarningProps {
14+
affectedTorrents: CrossSeedTorrent[]
15+
isLoading: boolean
16+
hasWarning: boolean
17+
deleteFiles: boolean
18+
className?: string
19+
}
20+
21+
/** Extract tracker domain from URL, e.g. "https://tracker.example.com:443/announce" -> "tracker.example.com" */
22+
function getTrackerDomain(trackerUrl: string | undefined): string | null {
23+
if (!trackerUrl) return null
24+
try {
25+
const url = new URL(trackerUrl)
26+
return url.hostname
27+
} catch {
28+
// If not a valid URL, try to extract domain-like pattern
29+
const match = trackerUrl.match(/(?:https?:\/\/)?([^:/]+)/)
30+
return match?.[1] || null
31+
}
32+
}
33+
34+
export function CrossSeedWarning({
35+
affectedTorrents,
36+
isLoading,
37+
hasWarning,
38+
deleteFiles,
39+
className,
40+
}: CrossSeedWarningProps) {
41+
const [isExpanded, setIsExpanded] = useState(false)
42+
const [incognitoMode] = useIncognitoMode()
43+
44+
// Loading state - subtle inline indicator
45+
if (isLoading) {
46+
return (
47+
<div className={cn("flex items-center gap-2 py-2 text-xs text-muted-foreground", className)}>
48+
<Loader2 className="h-3 w-3 animate-spin" />
49+
<span>Checking for cross-seeded torrents...</span>
50+
</div>
51+
)
52+
}
53+
54+
// No cross-seeds found
55+
if (!hasWarning) {
56+
return null
57+
}
58+
59+
// Group by instance
60+
const byInstance = affectedTorrents.reduce<Record<string, CrossSeedTorrent[]>>(
61+
(acc, torrent) => {
62+
const key = torrent.instanceName || `Instance ${torrent.instanceId}`
63+
if (!acc[key]) {
64+
acc[key] = []
65+
}
66+
acc[key].push(torrent)
67+
return acc
68+
},
69+
{}
70+
)
71+
72+
const instanceCount = Object.keys(byInstance).length
73+
const isDestructive = deleteFiles
74+
75+
// Collect unique trackers for summary
76+
const uniqueTrackers = new Set<string>()
77+
affectedTorrents.forEach((t) => {
78+
const domain = getTrackerDomain(t.tracker)
79+
if (domain) uniqueTrackers.add(domain)
80+
})
81+
82+
return (
83+
<div
84+
className={cn(
85+
"rounded-lg border py-3 px-4 overflow-hidden",
86+
isDestructive
87+
? "border-destructive/40 bg-destructive/5"
88+
: "border-blue-500/30 bg-blue-500/5",
89+
className
90+
)}
91+
>
92+
{/* Header */}
93+
<div className="flex items-start gap-3">
94+
{isDestructive ? (
95+
<AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0 text-destructive" />
96+
) : (
97+
<Info className="mt-0.5 h-4 w-4 flex-shrink-0 text-blue-500" />
98+
)}
99+
<div className="flex-1 min-w-0">
100+
<p className={cn(
101+
"text-sm font-medium",
102+
isDestructive ? "text-destructive" : "text-blue-600 dark:text-blue-400"
103+
)}>
104+
{isDestructive
105+
? "This will break cross-seeded torrents"
106+
: "Cross-seeded torrents detected (safe to remove)"}
107+
</p>
108+
<p className="mt-0.5 text-xs text-muted-foreground">
109+
{affectedTorrents.length} {affectedTorrents.length === 1 ? "torrent shares" : "torrents share"} {isDestructive ? "" : "these "}files
110+
{instanceCount > 1 && ` across ${instanceCount} instances`}
111+
{uniqueTrackers.size > 0 && (
112+
<span className="ml-1">
113+
on {uniqueTrackers.size === 1
114+
? Array.from(uniqueTrackers)[0]
115+
: `${uniqueTrackers.size} trackers`}
116+
</span>
117+
)}
118+
{!isDestructive && " — data will be preserved"}
119+
</p>
120+
</div>
121+
</div>
122+
123+
{/* Expandable torrent list */}
124+
<div className="mt-3">
125+
<button
126+
type="button"
127+
onClick={() => setIsExpanded(!isExpanded)}
128+
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
129+
>
130+
{isExpanded ? (
131+
<ChevronDown className="h-3 w-3" />
132+
) : (
133+
<ChevronRight className="h-3 w-3" />
134+
)}
135+
<GitBranch className="h-3 w-3" />
136+
<span>{isExpanded ? "Hide" : "Show"} affected torrents</span>
137+
</button>
138+
139+
{isExpanded && (
140+
<div className="mt-2 space-y-2 overflow-hidden">
141+
{Object.entries(byInstance).map(([instanceName, torrents]) => (
142+
<div key={instanceName} className="min-w-0">
143+
{instanceCount > 1 && (
144+
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wide mb-1">
145+
{instanceName}
146+
</p>
147+
)}
148+
<div className="space-y-1 min-w-0">
149+
{torrents.slice(0, 8).map((torrent) => {
150+
const trackerDomain = getTrackerDomain(torrent.tracker)
151+
return (
152+
<div
153+
key={`${torrent.hash}-${torrent.instanceId}`}
154+
className="flex items-center gap-2 py-0.5 text-xs min-w-0"
155+
>
156+
<span className="truncate min-w-0 flex-1">
157+
{incognitoMode
158+
? getLinuxIsoName(torrent.hash)
159+
: torrent.name}
160+
</span>
161+
{trackerDomain && (
162+
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
163+
{trackerDomain}
164+
</span>
165+
)}
166+
</div>
167+
)
168+
})}
169+
{torrents.length > 8 && (
170+
<p className="text-[10px] text-muted-foreground pt-0.5">
171+
+ {torrents.length - 8} more
172+
</p>
173+
)}
174+
</div>
175+
</div>
176+
))}
177+
</div>
178+
)}
179+
</div>
180+
</div>
181+
)
182+
}

web/src/components/torrents/TorrentManagementBar.tsx

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,10 @@ import {
2929
DropdownMenuTrigger
3030
} from "@/components/ui/dropdown-menu"
3131
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
32+
import { useCrossSeedWarning } from "@/hooks/useCrossSeedWarning"
3233
import { useInstanceCapabilities } from "@/hooks/useInstanceCapabilities"
3334
import { useInstanceMetadata } from "@/hooks/useInstanceMetadata"
35+
import { useInstances } from "@/hooks/useInstances"
3436
import { TORRENT_ACTIONS, useTorrentActions } from "@/hooks/useTorrentActions"
3537
import { getCommonCategory, getCommonSavePath, getCommonTags, getTotalSize } from "@/lib/torrent-utils"
3638
import { formatBytes } from "@/lib/utils"
@@ -55,6 +57,7 @@ import {
5557
Trash2
5658
} from "lucide-react"
5759
import { memo, useCallback, useMemo } from "react"
60+
import { CrossSeedWarning } from "./CrossSeedWarning"
5861
import { DeleteFilesPreference } from "./DeleteFilesPreference"
5962
import {
6063
AddTagsDialog,
@@ -111,6 +114,10 @@ export const TorrentManagementBar = memo(function TorrentManagementBar({
111114
const allowSubcategories =
112115
supportsSubcategories && (preferences?.use_subcategories ?? false)
113116

117+
// Get instance name for cross-seed warning
118+
const { instances } = useInstances()
119+
const instance = useMemo(() => instances?.find(i => i.id === instanceId), [instances, instanceId])
120+
114121
// Use the shared torrent actions hook
115122
const {
116123
showDeleteDialog,
@@ -163,6 +170,14 @@ export const TorrentManagementBar = memo(function TorrentManagementBar({
163170
},
164171
})
165172

173+
// Cross-seed warning for delete dialog
174+
const crossSeedWarning = useCrossSeedWarning({
175+
instanceId,
176+
instanceName: instance?.name ?? "",
177+
torrents: selectedTorrents,
178+
enabled: showDeleteDialog,
179+
})
180+
166181
// Wrapper functions to adapt hook handlers to component needs
167182
const actionHashes = useMemo(() => (isAllSelected ? [] : selectedHashes), [isAllSelected, selectedHashes])
168183
const actionOptions = useMemo(() => ({
@@ -602,7 +617,7 @@ export const TorrentManagementBar = memo(function TorrentManagementBar({
602617
</div>
603618

604619
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
605-
<AlertDialogContent>
620+
<AlertDialogContent className="max-w-xl">
606621
<AlertDialogHeader>
607622
<AlertDialogTitle>Delete {totalSelectionCount || selectedHashes.length} torrent(s)?</AlertDialogTitle>
608623
<AlertDialogDescription>
@@ -621,6 +636,12 @@ export const TorrentManagementBar = memo(function TorrentManagementBar({
621636
isLocked={isDeleteFilesLocked}
622637
onToggleLock={toggleDeleteFilesLock}
623638
/>
639+
<CrossSeedWarning
640+
affectedTorrents={crossSeedWarning.affectedTorrents}
641+
isLoading={crossSeedWarning.isLoading}
642+
hasWarning={crossSeedWarning.hasWarning}
643+
deleteFiles={deleteFiles}
644+
/>
624645
<AlertDialogFooter>
625646
<AlertDialogCancel>Cancel</AlertDialogCancel>
626647
<AlertDialogAction

web/src/components/torrents/TorrentTableOptimized.tsx

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
* SPDX-License-Identifier: GPL-2.0-or-later
44
*/
55

6+
import { useCrossSeedWarning } from "@/hooks/useCrossSeedWarning"
67
import { useDateTimeFormatters } from "@/hooks/useDateTimeFormatters"
78
import { useDebounce } from "@/hooks/useDebounce"
89
import { useKeyboardNavigation } from "@/hooks/useKeyboardNavigation"
@@ -43,7 +44,7 @@ import { useVirtualizer } from "@tanstack/react-virtual"
4344
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"
4445
import { InstancePreferencesDialog } from "../instances/preferences/InstancePreferencesDialog"
4546
import { TorrentContextMenu } from "./TorrentContextMenu"
46-
import { TORRENT_SORT_OPTIONS, type TorrentSortOptionValue, getDefaultSortOrder } from "./torrentSortOptions"
47+
import { TORRENT_SORT_OPTIONS, getDefaultSortOrder, type TorrentSortOptionValue } from "./torrentSortOptions"
4748

4849
import {
4950
AlertDialog,
@@ -125,6 +126,7 @@ import {
125126
} from "lucide-react"
126127
import { createPortal } from "react-dom"
127128
import { AddTorrentDialog, type AddTorrentDropPayload } from "./AddTorrentDialog"
129+
import { CrossSeedWarning } from "./CrossSeedWarning"
128130
import { DeleteFilesPreference } from "./DeleteFilesPreference"
129131
import { DraggableTableHeader } from "./DraggableTableHeader"
130132
import { SelectAllHotkey } from "./SelectAllHotkey"
@@ -830,6 +832,14 @@ export const TorrentTableOptimized = memo(function TorrentTableOptimized({
830832
},
831833
})
832834

835+
// Cross-seed warning for delete dialog
836+
const crossSeedWarning = useCrossSeedWarning({
837+
instanceId,
838+
instanceName: instance?.name ?? "",
839+
torrents: contextTorrents,
840+
enabled: showDeleteDialog,
841+
})
842+
833843
// Fetch metadata using shared hook
834844
const { data: metadata, isLoading: isMetadataLoading } = useInstanceMetadata(instanceId)
835845
const availableTags = metadata?.tags || []
@@ -2913,7 +2923,7 @@ export const TorrentTableOptimized = memo(function TorrentTableOptimized({
29132923
</div>
29142924

29152925
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
2916-
<AlertDialogContent>
2926+
<AlertDialogContent className="!max-w-2xl">
29172927
<AlertDialogHeader>
29182928
<AlertDialogTitle>Delete {isAllSelected ? effectiveSelectionCount : contextHashes.length} torrent(s)?</AlertDialogTitle>
29192929
<AlertDialogDescription>
@@ -2932,6 +2942,12 @@ export const TorrentTableOptimized = memo(function TorrentTableOptimized({
29322942
isLocked={isDeleteFilesLocked}
29332943
onToggleLock={toggleDeleteFilesLock}
29342944
/>
2945+
<CrossSeedWarning
2946+
affectedTorrents={crossSeedWarning.affectedTorrents}
2947+
isLoading={crossSeedWarning.isLoading}
2948+
hasWarning={crossSeedWarning.hasWarning}
2949+
deleteFiles={deleteFiles}
2950+
/>
29352951
<AlertDialogFooter>
29362952
<AlertDialogCancel>Cancel</AlertDialogCancel>
29372953
<AlertDialogAction

0 commit comments

Comments
 (0)