diff --git a/internal/api/handlers/torrents.go b/internal/api/handlers/torrents.go index 9421b38f2..765e7dd70 100644 --- a/internal/api/handlers/torrents.go +++ b/internal/api/handlers/torrents.go @@ -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"` diff --git a/internal/api/server.go b/internal/api/server.go index 6e620dad5..baeca6405 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -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) diff --git a/internal/qbittorrent/sync_manager.go b/internal/qbittorrent/sync_manager.go index c10ca3cb8..268495846 100644 --- a/internal/qbittorrent/sync_manager.go +++ b/internal/qbittorrent/sync_manager.go @@ -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 diff --git a/internal/web/swagger/openapi.yaml b/internal/web/swagger/openapi.yaml index 84d6f9d10..1ebd19450 100644 --- a/internal/web/swagger/openapi.yaml +++ b/internal/web/swagger/openapi.yaml @@ -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: @@ -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: diff --git a/web/src/components/torrents/TorrentDetailsPanel.tsx b/web/src/components/torrents/TorrentDetailsPanel.tsx index 905bdabbe..4aeeb0221 100644 --- a/web/src/components/torrents/TorrentDetailsPanel.tsx +++ b/web/src/components/torrents/TorrentDetailsPanel.tsx @@ -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" @@ -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" @@ -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[]) => { @@ -701,6 +718,14 @@ export const TorrentDetailsPanel = memo(function TorrentDetailsPanel({ instanceI > Peers + {hasWebseeds && ( + + HTTP Sources + + )} + + {isHorizontal ? ( + + ) : ( + +
+ {activeTab === "webseeds" && loadingWebseeds && !webseedsData ? ( +
+ +
+ ) : webseedsData && webseedsData.length > 0 ? ( +
+
+

HTTP Sources

+

{webseedsData.length} source{webseedsData.length !== 1 ? "s" : ""}

+
+
+ {webseedsData.map((webseed, index) => ( + + +
+

+ {incognitoMode ? "***masked***" : renderTextWithLinks(webseed.url)} +

+
+
+ + { + if (!incognitoMode) { + copyTextToClipboard(webseed.url) + toast.success("URL copied to clipboard") + } + }} + disabled={incognitoMode} + > + + Copy URL + + +
+ ))} +
+
+ ) : ( +
+ No HTTP sources +
+ )} +
+
+ )} +
+ {isHorizontal ? ( () + +export const WebSeedsTable = memo(function WebSeedsTable({ + webseeds, + loading, + incognitoMode, +}: WebSeedsTableProps) { + const [searchQuery, setSearchQuery] = useState("") + + const columns = useMemo(() => [ + columnHelper.accessor("url", { + header: "URL", + cell: (info) => { + const url = info.getValue() + if (incognitoMode) { + try { + const parsed = new URL(url) + return ( + + {parsed.protocol}//***masked***{parsed.pathname.slice(0, 20)}... + + ) + } catch { + return ***masked*** + } + } + return ( + + {renderTextWithLinks(url)} + + ) + }, + }), + ], [incognitoMode]) + + const filteredData = useMemo(() => { + const data = webseeds || [] + if (!searchQuery) return data + const query = searchQuery.toLowerCase() + return data.filter((ws) => ws.url.toLowerCase().includes(query)) + }, [webseeds, searchQuery]) + + const table = useReactTable({ + data: filteredData, + columns, + getCoreRowModel: getCoreRowModel(), + }) + + const handleCopyUrl = (webseed: WebSeed) => { + if (incognitoMode) return + copyTextToClipboard(webseed.url) + toast.success("URL copied to clipboard") + } + + if (loading && !webseeds) { + return ( +
+ +
+ ) + } + + if (!webseeds || webseeds.length === 0) { + return ( +
+ No HTTP sources +
+ ) + } + + return ( +
+ {/* Toolbar */} +
+
+ + setSearchQuery(e.target.value)} + className="h-6 w-40 pl-7 pr-7 text-xs" + /> + {searchQuery && ( + + )} +
+ + {searchQuery + ? `${filteredData.length} of ${webseeds.length}` + : `${webseeds.length} HTTP source${webseeds.length !== 1 ? "s" : ""}`} + +
+ + +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + + + {row.getVisibleCells().map((cell) => ( + + ))} + + + + handleCopyUrl(row.original)} + disabled={incognitoMode} + > + + Copy URL + + + + ))} + +
+ {flexRender(header.column.columnDef.header, header.getContext())} +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
+
+
+ ) +}) diff --git a/web/src/components/torrents/details/index.ts b/web/src/components/torrents/details/index.ts index 706bc870a..27c0407a1 100644 --- a/web/src/components/torrents/details/index.ts +++ b/web/src/components/torrents/details/index.ts @@ -9,3 +9,4 @@ export { PeersTable } from "./PeersTable" export { StatRow } from "./StatRow" export { TorrentFileTable } from "./TorrentFileTable" export { TrackersTable } from "./TrackersTable" +export { WebSeedsTable } from "./WebSeedsTable" diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 2b73a8243..315e879ae 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -47,6 +47,7 @@ import type { RestoreResult, SearchHistoryResponse, SortedPeersResponse, + WebSeed, TorrentCreationParams, TorrentCreationTask, TorrentCreationTaskResponse, @@ -1150,6 +1151,10 @@ class ApiClient { return this.request(`/instances/${instanceId}/torrents/${hash}/peers`) } + async getTorrentWebSeeds(instanceId: number, hash: string): Promise { + return this.request(`/instances/${instanceId}/torrents/${hash}/webseeds`) + } + async addPeersToTorrents(instanceId: number, hashes: string[], peers: string[]): Promise { return this.request(`/instances/${instanceId}/torrents/add-peers`, { method: "POST", diff --git a/web/src/types/index.ts b/web/src/types/index.ts index cb7058980..3a86bc76c 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -496,6 +496,10 @@ export interface SortedPeersResponse extends TorrentPeersResponse { sorted_peers?: SortedPeer[] } +export interface WebSeed { + url: string +} + export type BackupRunKind = "manual" | "hourly" | "daily" | "weekly" | "monthly" | "import" export type BackupRunStatus = "pending" | "running" | "success" | "failed" | "canceled"