Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions internal/api/handlers/torrents.go
Original file line number Diff line number Diff line change
Expand Up @@ -1152,6 +1152,33 @@ func (h *TorrentsHandler) GetTorrentTrackers(w http.ResponseWriter, r *http.Requ
RespondJSON(w, http.StatusOK, trackers)
}

// GetTorrentWebSeeds returns the web seeds (HTTP sources) for a torrent
func (h *TorrentsHandler) GetTorrentWebSeeds(w http.ResponseWriter, r *http.Request) {
instanceID, err := strconv.Atoi(chi.URLParam(r, "instanceID"))
if err != nil {
RespondError(w, http.StatusBadRequest, "Invalid instance ID")
return
}

hash := chi.URLParam(r, "hash")
if hash == "" {
RespondError(w, http.StatusBadRequest, "Torrent hash is required")
return
}

webseeds, err := h.syncManager.GetTorrentWebSeeds(r.Context(), instanceID, hash)
if err != nil {
if respondIfInstanceDisabled(w, err, instanceID, "torrents:getWebSeeds") {
return
}
log.Error().Err(err).Int("instanceID", instanceID).Str("hash", hash).Msg("Failed to get torrent web seeds")
RespondError(w, http.StatusInternalServerError, "Failed to get torrent web seeds")
return
}

RespondJSON(w, http.StatusOK, webseeds)
}

// EditTrackerRequest represents a tracker edit request
type EditTrackerRequest struct {
OldURL string `json:"oldURL"`
Expand Down
1 change: 1 addition & 0 deletions internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,7 @@ func (s *Server) Handler() (*chi.Mux, error) {
r.Post("/trackers", torrentsHandler.AddTorrentTrackers)
r.Delete("/trackers", torrentsHandler.RemoveTorrentTrackers)
r.Get("/peers", torrentsHandler.GetTorrentPeers)
r.Get("/webseeds", torrentsHandler.GetTorrentWebSeeds)
r.Get("/files", torrentsHandler.GetTorrentFiles)
r.Put("/files", torrentsHandler.SetTorrentFilePriority)
r.Put("/rename", torrentsHandler.RenameTorrent)
Expand Down
15 changes: 15 additions & 0 deletions internal/qbittorrent/sync_manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -1325,6 +1325,21 @@ func (sm *SyncManager) GetTorrentTrackers(ctx context.Context, instanceID int, h
return trackers, nil
}

// GetTorrentWebSeeds returns the web seeds (HTTP sources) for a torrent
func (sm *SyncManager) GetTorrentWebSeeds(ctx context.Context, instanceID int, hash string) ([]qbt.WebSeed, error) {
client, err := sm.clientPool.GetClient(ctx, instanceID)
if err != nil {
return nil, fmt.Errorf("failed to get client: %w", err)
}

webseeds, err := client.GetTorrentsWebSeedsCtx(ctx, hash)
if err != nil {
return nil, fmt.Errorf("failed to get torrent web seeds: %w", err)
}

return webseeds, nil
}

// GetTorrentPeers gets peers for a specific torrent with incremental updates
func (sm *SyncManager) GetTorrentPeers(ctx context.Context, instanceID int, hash string) (*qbt.TorrentPeersResponse, error) {
// Get client
Expand Down
32 changes: 32 additions & 0 deletions internal/web/swagger/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1351,6 +1351,29 @@ paths:
'500':
description: Failed to remove trackers

/api/instances/{instanceID}/torrents/{hash}/webseeds:
get:
tags:
- Torrent Details
summary: Get torrent web seeds
description: Get list of web seeds (HTTP sources) for a torrent
parameters:
- $ref: '#/components/parameters/instanceID'
- $ref: '#/components/parameters/hash'
responses:
'200':
description: List of web seeds
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/WebSeed'
'400':
description: Invalid request
'500':
description: Failed to get web seeds

/api/instances/{instanceID}/torrents/{hash}/files:
get:
tags:
Expand Down Expand Up @@ -3598,6 +3621,15 @@ components:
msg:
type: string

WebSeed:
type: object
required:
- url
properties:
url:
type: string
description: HTTP source URL for the torrent

TorrentFile:
type: object
properties:
Expand Down
87 changes: 85 additions & 2 deletions web/src/components/torrents/TorrentDetailsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import "flag-icons/css/flag-icons.min.css"
import { Ban, Copy, Loader2, Trash2, UserPlus, X } from "lucide-react"
import { memo, useCallback, useEffect, useMemo, useState } from "react"
import { toast } from "sonner"
import { CrossSeedTable, GeneralTabHorizontal, PeersTable, TorrentFileTable, TrackersTable } from "./details"
import { CrossSeedTable, GeneralTabHorizontal, PeersTable, TorrentFileTable, TrackersTable, WebSeedsTable } from "./details"
import { RenameTorrentFileDialog, RenameTorrentFolderDialog } from "./TorrentDialogs"
import { TorrentFileTree } from "./TorrentFileTree"

Expand All @@ -47,7 +47,7 @@ interface TorrentDetailsPanelProps {
onClose?: () => void;
}

const TAB_VALUES = ["general", "trackers", "peers", "content", "crossseed"] as const
const TAB_VALUES = ["general", "trackers", "peers", "webseeds", "content", "crossseed"] as const
type TabValue = typeof TAB_VALUES[number]
const DEFAULT_TAB: TabValue = "general"
const TAB_STORAGE_KEY = "torrent-details-last-tab"
Expand Down Expand Up @@ -343,6 +343,23 @@ export const TorrentDetailsPanel = memo(function TorrentDetailsPanel({ instanceI
gcTime: 5 * 60 * 1000,
})

// Fetch web seeds (HTTP sources) - always fetch to determine if tab should be shown
const { data: webseedsData, isLoading: loadingWebseeds } = useQuery({
queryKey: ["torrent-webseeds", instanceId, torrent?.hash],
queryFn: () => api.getTorrentWebSeeds(instanceId, torrent!.hash),
enabled: !!torrent && isReady,
staleTime: 30000,
gcTime: 5 * 60 * 1000,
})
const hasWebseeds = (webseedsData?.length ?? 0) > 0

// Redirect away from webseeds tab if it becomes hidden (e.g., switching to a torrent without web seeds)
useEffect(() => {
if (activeTab === "webseeds" && !hasWebseeds && !loadingWebseeds) {
setActiveTab("general")
}
}, [activeTab, hasWebseeds, loadingWebseeds, setActiveTab])

// Add peers mutation
const addPeersMutation = useMutation({
mutationFn: async (peers: string[]) => {
Expand Down Expand Up @@ -701,6 +718,14 @@ export const TorrentDetailsPanel = memo(function TorrentDetailsPanel({ instanceI
>
Peers
</TabsTrigger>
{hasWebseeds && (
<TabsTrigger
value="webseeds"
className="relative text-xs rounded-none data-[state=active]:bg-transparent data-[state=active]:shadow-none hover:bg-accent/50 transition-all px-3 sm:px-4 cursor-pointer focus-visible:outline-none focus-visible:ring-0 after:absolute after:bottom-0 after:left-0 after:right-0 after:h-[2px] after:bg-primary after:scale-x-0 data-[state=active]:after:scale-x-100 after:transition-transform"
>
HTTP Sources
</TabsTrigger>
)}
<TabsTrigger
value="content"
className="relative text-xs rounded-none data-[state=active]:bg-transparent data-[state=active]:shadow-none hover:bg-accent/50 transition-all px-3 sm:px-4 cursor-pointer focus-visible:outline-none focus-visible:ring-0 after:absolute after:bottom-0 after:left-0 after:right-0 after:h-[2px] after:bg-primary after:scale-x-0 data-[state=active]:after:scale-x-100 after:transition-transform"
Expand Down Expand Up @@ -1359,6 +1384,64 @@ export const TorrentDetailsPanel = memo(function TorrentDetailsPanel({ instanceI
)}
</TabsContent>

<TabsContent value="webseeds" className="m-0 h-full">
{isHorizontal ? (
<WebSeedsTable
webseeds={webseedsData}
loading={loadingWebseeds}
incognitoMode={incognitoMode}
/>
) : (
<ScrollArea className="h-full">
<div className="p-4 sm:p-6">
{activeTab === "webseeds" && loadingWebseeds && !webseedsData ? (
<div className="flex items-center justify-center p-8">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : webseedsData && webseedsData.length > 0 ? (
<div className="space-y-3">
<div>
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">HTTP Sources</h3>
<p className="text-xs text-muted-foreground mt-1">{webseedsData.length} source{webseedsData.length !== 1 ? "s" : ""}</p>
</div>
<div className="space-y-2 mt-4">
{webseedsData.map((webseed, index) => (
<ContextMenu key={index}>
<ContextMenuTrigger asChild>
<div className="p-3 rounded-lg border bg-card hover:bg-muted/30 transition-colors cursor-default">
<p className="font-mono text-xs break-all">
{incognitoMode ? "***masked***" : renderTextWithLinks(webseed.url)}
</p>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onClick={() => {
if (!incognitoMode) {
copyTextToClipboard(webseed.url)
toast.success("URL copied to clipboard")
}
}}
disabled={incognitoMode}
>
<Copy className="h-3.5 w-3.5 mr-2" />
Copy URL
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
))}
</div>
</div>
) : (
<div className="flex items-center justify-center h-32 text-sm text-muted-foreground">
No HTTP sources
</div>
)}
</div>
</ScrollArea>
)}
</TabsContent>

<TabsContent value="content" className="m-0 h-full flex flex-col overflow-hidden">
{isHorizontal ? (
<TorrentFileTable
Expand Down
Loading
Loading