diff --git a/.gitignore b/.gitignore index 35b0b2ee2..2a319fcd7 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ dist !/src/lang/en/ .DS_Store .idea -.test \ No newline at end of file +.test +.codebuddy diff --git a/src/lang/en/home.json b/src/lang/en/home.json index c8a2243e2..5f2a08ba2 100644 --- a/src/lang/en/home.json +++ b/src/lang/en/home.json @@ -44,7 +44,8 @@ "doc": "DOC Previewer", "asciinema": "Asciinema Player", "video360": "Video360 Player", - "archive": "Archive Previewer" + "archive": "Archive Previewer", + "torrent": "Torrent Previewer" } }, "layouts": { @@ -92,6 +93,39 @@ "cancel_select": "Cancel Select", "offline_download": "Offline download", "offline_download-tips": "One URL per line", + "offline_download_torrent": "BT Fetch", + "offline_download_enhanced": { + "tab_link": "Link Download", + "tab_bt": "BT Download", + "link_placeholder": "Enter download links, one per line\nSupports: HTTP/HTTPS, magnet:?, ed2k://", + "link_tips": "Supports HTTP/HTTPS URLs, magnet links, and ed2k links. One link per line.", + "drop_torrent": "Drop .torrent file here", + "click_to_select": "Or click to select a .torrent file", + "parsing": "Parsing torrent file...", + "torrent_too_large": "Torrent file is too large (max 10MB)", + "parse_failed": "Failed to parse torrent file", + "files_count": "files", + "select_all": "Select All", + "save_path": "Save Path", + "download_tool": "Download Tool", + "delete_policy": "Delete Policy", + "start_download": "Start Download", + "rapid_upload_and_download": "Try Rapid Upload", + "rapid_upload_success": "Rapid upload succeeded!", + "cas_supported": "CAS Rapid Upload", + "cas_hint": "This torrent contains CAS info, will try rapid upload to Cloud189 first.", + "cas_rapid_upload_mode": "CAS info detected. Will directly use Cloud189 rapid upload (no download tool needed).", + "cas_rapid_upload_failed": "CAS rapid upload failed. Please check if the save path is a Cloud189 storage, or try downloading manually.", + "cas_failed_fallback_hint": "CAS rapid upload failed. You can now select a download tool and start a normal offline download.", + "no_cas_hint": "No CAS info found. Files will be downloaded first, then uploaded.", + "reselect": "Reselect", + "simplehttp_not_supported": "SimpleHttp does not support BT/magnet downloads. Please select aria2 or another tool.", + "ed2k_tool_hint": "ed2k links detected. aria2/qBittorrent do not support ed2k protocol. The system will automatically try Thunder tools, or you can manually select Thunder/ThunderX/ThunderBrowser.", + "generate_cas_file": "Generate CAS File", + "cas_file_generated": "CAS file generated successfully", + "cas_file_generate_failed": "Failed to generate CAS file" + "file_selection_hint": "Note: The torrent file list is for reference only. Partial file selection is not yet supported — all files in the torrent will be downloaded." + }, "delete_policy": { "delete_on_upload_succeed": "Delete on upload succeed", "delete_on_upload_failed": "Delete on upload failed", diff --git a/src/pages/home/folder/context-menu.tsx b/src/pages/home/folder/context-menu.tsx index c5e0c2fe3..af466c33a 100644 --- a/src/pages/home/folder/context-menu.tsx +++ b/src/pages/home/folder/context-menu.tsx @@ -4,7 +4,7 @@ import "solid-contextmenu/dist/style.css" import { HStack, Icon, Text, useColorMode, Image } from "@hope-ui/solid" import { operations } from "../toolbar/operations" import { For, Show } from "solid-js" -import { bus, convertURL, notify } from "~/utils" +import { bus, convertURL, notify, torrentParse } from "~/utils" import { ObjType, UserMethods } from "~/types" import { getSettingBool, @@ -18,6 +18,7 @@ import { import { players } from "../previews/video_box" import { BsPlayCircleFill } from "solid-icons/bs" import { isArchive } from "~/store/archive" +import axios from "axios" const ItemContent = (props: { name: string }) => { const t = useT() @@ -88,6 +89,51 @@ export const ContextMenu = () => { > + { diff --git a/src/pages/home/previews/index.ts b/src/pages/home/previews/index.ts index b40d08fd5..8fef26f15 100644 --- a/src/pages/home/previews/index.ts +++ b/src/pages/home/previews/index.ts @@ -165,6 +165,12 @@ const previews: Preview[] = [ component: lazy(() => import("./video360")), prior: true, }, + { + key: "torrent", + exts: ["torrent"], + component: lazy(() => import("./torrent")), + prior: true, + }, { key: "archive", exts: (name: string) => { diff --git a/src/pages/home/previews/torrent.tsx b/src/pages/home/previews/torrent.tsx new file mode 100644 index 000000000..8bbaf5c90 --- /dev/null +++ b/src/pages/home/previews/torrent.tsx @@ -0,0 +1,240 @@ +import { + VStack, + HStack, + Text, + Button, + Badge, + Heading, + Box, +} from "@hope-ui/solid" +import { createSignal, onMount, Show } from "solid-js" +import { objStore } from "~/store" +import { TorrentInfo, CASInfo, TorrentFile } from "~/types" +import { useLink, useRouter, useT } from "~/hooks" +import { bus } from "~/utils" +import { TorrentFileList } from "../toolbar/TorrentFileList" +import axios from "axios" +import bencode from "bencode" +import crypto from "crypto-js" + +function formatFileSize(bytes: number): string { + if (bytes === 0) return "0 B" + const k = 1024 + const sizes = ["B", "KB", "MB", "GB", "TB"] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i] +} + +function utf8Decode(data: Uint8Array | undefined): string { + if (!data) return "" + return crypto.enc.Utf8.stringify(crypto.lib.WordArray.create(data)) +} + +function arrayBufferToBase64(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer) + let binary = "" + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]) + } + return btoa(binary) +} + +// 本地解析 torrent 文件,避免调用后端API +// 与后端 ParseTorrent 行为对齐:返回 TorrentInfo 结构 + 检测 x-cas 扩展 +function parseLocalTorrent(buffer: Uint8Array): TorrentInfo { + const data = bencode.decode(buffer as any) + const info = data.info + if (!info) { + throw new Error("Invalid torrent: missing info dict") + } + + // 计算 info_hash(SHA1 of bencoded info dict) + const infoEncoded = bencode.encode(info) as unknown as Uint8Array + const infoHash = crypto + .SHA1(crypto.lib.WordArray.create(infoEncoded)) + .toString() + + // 提取名称 + const name = utf8Decode(info.name) + + // 提取分片信息 + const pieceLength: number = info["piece length"] || 0 + const pieces: Uint8Array = info.pieces || new Uint8Array(0) + const pieceCount = Math.floor(pieces.byteLength / 20) + + // 提取文件列表 + const files: TorrentFile[] = [] + let totalSize = 0 + if (Array.isArray(info.files) && info.files.length > 0) { + // 多文件模式 + for (const f of info.files) { + const pathParts: string[] = (f.path || []).map((p: Uint8Array) => + utf8Decode(p), + ) + const size: number = f.length || 0 + files.push({ path: pathParts.join("/"), size }) + totalSize += size + } + } else { + // 单文件模式 + const size: number = info.length || 0 + files.push({ path: name, size }) + totalSize = size + } + + // 检测 CAS 扩展(key: "x-cas") + let hasCas = false + let cas: CASInfo | undefined = undefined + const casDict = data["x-cas"] + if (casDict && typeof casDict === "object") { + const fileMd5 = utf8Decode(casDict["file_md5"]) + const sliceMd5 = utf8Decode(casDict["slice_md5"]) + if (fileMd5 && sliceMd5) { + hasCas = true + cas = { + file_md5: fileMd5, + slice_md5: sliceMd5, + slice_size: casDict["slice_size"] || 0, + cloud: utf8Decode(casDict["cloud"]), + } + } + } + + return { + name, + total_size: totalSize, + piece_length: pieceLength, + piece_count: pieceCount, + info_hash: infoHash, + files, + has_cas: hasCas, + cas, + } +} + +const TorrentPreview = () => { + const t = useT() + const { proxyLink, rawLink } = useLink() + const { isShare } = useRouter() + + const [loading, setLoading] = createSignal(true) + const [error, setError] = createSignal("") + const [torrentInfo, setTorrentInfo] = createSignal(null) + const [torrentData, setTorrentData] = createSignal("") // Base64 编码 + const [selectedFiles, setSelectedFiles] = createSignal([]) + + onMount(async () => { + try { + // 优先使用 proxy 链接(带签名,最稳定),失败时回退到 raw 链接 + let resp + try { + const link = proxyLink(objStore.obj, true) + resp = await axios.get(link, { responseType: "arraybuffer" }) + } catch (e) { + // 代理链接失败时尝试 raw 链接 + const link = rawLink(objStore.obj, true) + resp = await axios.get(link, { responseType: "arraybuffer" }) + } + + const buffer = resp.data as ArrayBuffer + const bytes = new Uint8Array(buffer) + + // 本地 bencode 解析(避免调用后端 API 出现 403) + const info = parseLocalTorrent(bytes) + setTorrentInfo(info) + setTorrentData(arrayBufferToBase64(buffer)) + setSelectedFiles(info.files.map((_, i) => i)) + } catch (err) { + console.error("Failed to parse torrent file:", err) + setError( + `${t("home.toolbar.offline_download_enhanced.parse_failed")}: ${err}`, + ) + } finally { + setLoading(false) + } + }) + + // 触发离线下载对话框(完全复用 OfflineDownloadEnhanced) + const handleOfflineDownload = () => { + const info = torrentInfo() + const data = torrentData() + if (!info || !data) return + bus.emit("torrent_parsed", { + torrentData: data, + info: info, + }) + } + + return ( + + + {t("home.toolbar.offline_download_enhanced.parsing")} + + + + {error()} + + + + {/* 种子信息头部 */} + + + + + {torrentInfo()!.name} + + + + {formatFileSize(torrentInfo()!.total_size)} + + + {torrentInfo()!.files.length}{" "} + {t("home.toolbar.offline_download_enhanced.files_count")} + + + Info Hash: {torrentInfo()!.info_hash} + + + + + + {t("home.toolbar.offline_download_enhanced.cas_supported")} + + + + + {/* 文件列表 */} + + + {/* 离线下载按钮 */} + + + + + + + + + ) +} + +export default TorrentPreview diff --git a/src/pages/home/toolbar/OfflineDownloadEnhanced.tsx b/src/pages/home/toolbar/OfflineDownloadEnhanced.tsx new file mode 100644 index 000000000..a9c01e01a --- /dev/null +++ b/src/pages/home/toolbar/OfflineDownloadEnhanced.tsx @@ -0,0 +1,791 @@ +import { + Modal, + ModalOverlay, + ModalContent, + ModalHeader, + ModalBody, + ModalFooter, + Button, + Textarea, + Box, + HStack, + VStack, + Text, + Badge, + Heading, + createDisclosure, + notificationService, +} from "@hope-ui/solid" +import { SelectWrapper, FolderChooseInput } from "~/components" +import { useFetch, useRouter, useT } from "~/hooks" +import { + offlineDownload, + torrentParse, + torrentRapidUpload, + bus, + handleRespWithNotifySuccess, + handleResp, + r, +} from "~/utils" +import { + createSignal, + onCleanup, + onMount, + Show, + createMemo, + createEffect, +} from "solid-js" +import { PResp, TorrentInfo } from "~/types" +import bencode from "bencode" +import crypto from "crypto-js" +import { TorrentFileList } from "./TorrentFileList" + +const deletePolicies = [ + "upload_download_stream", + "delete_on_upload_succeed", + "delete_on_upload_failed", + "delete_never", + "delete_always", +] as const + +type DeletePolicy = (typeof deletePolicies)[number] + +// Tab 类型 +type TabType = "link" | "bt" + +function utf8Decode(data: Uint8Array): string { + return crypto.enc.Utf8.stringify(crypto.lib.WordArray.create(data)) +} + +function toMagnetUrl(torrentBuffer: Uint8Array) { + const data = bencode.decode(torrentBuffer as any) + const infoEncode = bencode.encode(data.info) as unknown as Uint8Array + const infoHash = crypto + .SHA1(crypto.lib.WordArray.create(infoEncode)) + .toString() + let params = {} as any + if (Number.isInteger(data?.info?.length)) { + params.xl = data.info.length + } + if (data.info.name) { + params.dn = utf8Decode(data.info.name) + } + if (data.announce) { + params.tr = utf8Decode(data.announce) + } + const paramStr = new URLSearchParams(params).toString() + return `magnet:?xt=urn:btih:${infoHash}${paramStr ? "&" + paramStr : ""}` +} + +function arrayBufferToBase64(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer) + let binary = "" + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]) + } + return btoa(binary) +} + +function formatFileSize(bytes: number): string { + if (bytes === 0) return "0 B" + const k = 1024 + const sizes = ["B", "KB", "MB", "GB", "TB"] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i] +} + +export const OfflineDownloadEnhanced = () => { + const t = useT() + const { pathname } = useRouter() + + // 下载工具列表 + const [tools, setTools] = createSignal([] as string[]) + const [toolsLoading, reqTool] = useFetch((): PResp => { + return r.get("/public/offline_download_tools") + }) + const [tool, setTool] = createSignal("") + const [deletePolicy, setDeletePolicy] = createSignal( + "upload_download_stream", + ) + + // 对话框状态 + const { isOpen, onOpen, onClose } = createDisclosure() + const [activeTab, setActiveTab] = createSignal("link") + + // 链接下载状态 + const [linkValue, setLinkValue] = createSignal("") + const [linkLoading, submitLink] = useFetch(offlineDownload) + + // BT 下载状态 + const [torrentInfo, setTorrentInfo] = createSignal(null) + const [torrentData, setTorrentData] = createSignal("") // Base64 编码 + const [selectedFiles, setSelectedFiles] = createSignal([]) + const [btLoading, setBtLoading] = createSignal(false) + const [parsing, setParsing] = createSignal(false) + + // 保存路径 + const [savePath, setSavePath] = createSignal("") + + // 秒传状态 + const [rapidUploading, setRapidUploading] = createSignal(false) + const [rapidUploadResult, setRapidUploadResult] = createSignal("") + // 秒传失败后允许回退到普通离线下载 + const [casRapidUploadFailed, setCasRapidUploadFailed] = + createSignal(false) + + // 检测输入中是否包含 ed2k 链接 + const hasEd2kLinks = createMemo(() => { + return linkValue() + .split("\n") + .some((line) => line.trim().toLowerCase().startsWith("ed2k://")) + }) + + // 当有 CAS 信息且秒传尚未失败时,默认使用天翼云秒传(不需要 aria2) + const shouldUseCasRapidUpload = createMemo(() => { + return ( + activeTab() === "bt" && + !!torrentInfo()?.has_cas && + !casRapidUploadFailed() + ) + }) + + // 检测输入中是否包含磁力链 + const hasMagnetLinks = createMemo(() => { + return linkValue() + .split("\n") + .some((line) => line.trim().toLowerCase().startsWith("magnet:?")) + }) + + // 是否应该禁用 SimpleHttp(BT种子/磁力链/ed2k 场景不支持) + const shouldDisableSimpleHttp = createMemo(() => { + return activeTab() === "bt" || hasEd2kLinks() || hasMagnetLinks() + }) + + // 可用的工具列表(根据场景过滤) + const availableTools = createMemo(() => { + if (shouldDisableSimpleHttp()) { + return tools().filter((t) => t !== "SimpleHttp") + } + return tools() + }) + + // 当 SimpleHttp 被禁用时,自动切换到第一个可用工具 + createEffect(() => { + if (shouldDisableSimpleHttp() && tool() === "SimpleHttp") { + const available = availableTools() + if (available.length > 0) { + setTool(available[0]) + } + } + }) + + onMount(async () => { + const resp = await reqTool() + handleResp(resp, (data) => { + setTools(data) + setTool(data[0]) + }) + }) + + // 监听 bus 事件 + const handler = (name: string) => { + if (name === "offline_download") { + setSavePath(pathname()) + onOpen() + } + } + bus.on("tool", handler) + onCleanup(() => { + bus.off("tool", handler) + }) + + // 监听从右键菜单触发的 torrent 解析事件 + const torrentHandler = (data: { torrentData: string; info: TorrentInfo }) => { + setTorrentData(data.torrentData) + setTorrentInfo(data.info) + setSelectedFiles(data.info.files.map((_, i) => i)) + setActiveTab("bt") + setSavePath(pathname()) + onOpen() + } + bus.on("torrent_parsed", torrentHandler) + onCleanup(() => { + bus.off("torrent_parsed", torrentHandler) + }) + + // 生成 CAS 文件并下载(纯前端) + const handleGenerateCASFile = () => { + const info = torrentInfo() + if (!info?.has_cas || !info.cas) return + try { + const casJson = JSON.stringify({ + md5: info.cas.file_md5, + name: info.name, + size: info.total_size, + sliceMd5: info.cas.slice_md5, + }) + const casContent = btoa(casJson) + const blob = new Blob([casContent], { type: "text/plain;charset=utf-8" }) + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = `${info.name}.cas` + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) + notificationService.show({ + status: "success", + title: t("home.toolbar.offline_download_enhanced.cas_file_generated"), + }) + } catch (err) { + notificationService.show({ + status: "danger", + title: t( + "home.toolbar.offline_download_enhanced.cas_file_generate_failed", + ), + description: String(err), + }) + } + } + + // 重置状态 + const resetState = () => { + setLinkValue("") + setTorrentInfo(null) + setTorrentData("") + setSelectedFiles([]) + setRapidUploadResult("") + setCasRapidUploadFailed(false) + } + + const handleClose = () => { + resetState() + onClose() + } + + // 处理 torrent 文件拖拽/选择 + const handleTorrentFile = async (file: File) => { + if (file.size > 10 * 1024 * 1024) { + notificationService.show({ + status: "danger", + title: t("home.toolbar.offline_download_enhanced.torrent_too_large"), + }) + return + } + + setParsing(true) + try { + const buffer = await file.arrayBuffer() + const base64Data = arrayBufferToBase64(buffer) + + const resp = await torrentParse(base64Data) + handleResp(resp, (data) => { + setTorrentInfo(data) + setTorrentData(base64Data) + setSelectedFiles(data.files.map((_, i) => i)) + }) + } catch (err) { + notificationService.show({ + status: "danger", + title: t("home.toolbar.offline_download_enhanced.parse_failed"), + description: String(err), + }) + } finally { + setParsing(false) + } + } + + // 拖拽处理 + const handleDrop = (e: DragEvent) => { + e.preventDefault() + e.stopPropagation() + if (!e.dataTransfer?.files.length) return + + if (activeTab() === "bt") { + // BT Tab: 解析第一个 torrent 文件 + for (const file of e.dataTransfer.files) { + if (file.name.toLowerCase().endsWith(".torrent")) { + handleTorrentFile(file) + return + } + } + } else { + // Link Tab: 将 torrent 文件转换为磁力链追加到输入框 + const processFiles = async () => { + const values: string[] = [] + for (const file of e.dataTransfer!.files) { + if (file.name.toLowerCase().endsWith(".torrent")) { + try { + const buffer = await file.arrayBuffer() + values.push(toMagnetUrl(new Uint8Array(buffer))) + } catch (err) { + console.error("Failed to convert torrent:", err) + } + } + } + if (values.length) { + setLinkValue((prev) => + prev ? prev + "\n" + values.join("\n") : values.join("\n"), + ) + } + } + processFiles() + } + } + + // 文件选择处理 + const handleFileSelect = () => { + const input = document.createElement("input") + input.type = "file" + input.accept = ".torrent" + input.onchange = (e) => { + const file = (e.target as HTMLInputElement).files?.[0] + if (file) { + handleTorrentFile(file) + } + } + input.click() + } + + // 提交链接下载 + const handleLinkSubmit = async () => { + if (!linkValue().trim()) return + const urls = linkValue() + .split("\n") + .filter((u) => u.trim()) + const resp = await submitLink(savePath(), urls, tool(), deletePolicy()) + handleRespWithNotifySuccess(resp, () => { + handleClose() + }) + } + + // 提交 BT 下载 + const handleBtSubmit = async () => { + const info = torrentInfo() + if (!info || !torrentData()) return + + setBtLoading(true) + try { + // 有 CAS 信息且秒传尚未失败时,默认直接走天翼云秒传 + if (info.has_cas && !casRapidUploadFailed()) { + setRapidUploading(true) + try { + const resp = await torrentRapidUpload(torrentData(), savePath()) + if (resp.code === 200) { + setRapidUploadResult("success") + notificationService.show({ + status: "success", + title: t( + "home.toolbar.offline_download_enhanced.rapid_upload_success", + ), + description: resp.data.file_name, + }) + handleClose() + return + } else { + // 秒传失败,标记允许回退到普通离线下载 + setCasRapidUploadFailed(true) + notificationService.show({ + status: "warning", + title: t( + "home.toolbar.offline_download_enhanced.cas_rapid_upload_failed", + ), + description: resp.message, + }) + } + } catch (err) { + // 秒传异常,标记允许回退到普通离线下载 + setCasRapidUploadFailed(true) + notificationService.show({ + status: "danger", + title: t( + "home.toolbar.offline_download_enhanced.cas_rapid_upload_failed", + ), + description: String(err), + }) + } finally { + setRapidUploading(false) + } + // 秒传失败后返回,让用户选择是否继续普通离线下载 + return + } + + // 无 CAS 信息或秒传失败后,走正常离线下载流程 + // SimpleHttp 不支持磁力链/BT 下载 + if (tool() === "SimpleHttp") { + notificationService.show({ + status: "warning", + title: t( + "home.toolbar.offline_download_enhanced.simplehttp_not_supported", + ), + }) + return + } + + // 正常离线下载:将 torrent 转为磁力链提交 + const buffer = Uint8Array.from(atob(torrentData()), (c) => + c.charCodeAt(0), + ) + const magnetUrl = toMagnetUrl(buffer) + const resp = await offlineDownload( + savePath(), + [magnetUrl], + tool(), + deletePolicy(), + ) + handleRespWithNotifySuccess(resp, () => { + handleClose() + }) + } finally { + setBtLoading(false) + } + } + + return ( + + + { + e.preventDefault() + e.stopPropagation() + }} + onDrop={handleDrop} + > + {t("home.toolbar.offline_download")} + + {/* Tab 切换 */} + + + + + + {/* 链接下载 Tab */} + + +