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..ce944390d 100644 --- a/src/lang/en/home.json +++ b/src/lang/en/home.json @@ -92,6 +92,36 @@ "cancel_select": "Cancel Select", "offline_download": "Offline download", "offline_download-tips": "One URL per line", + "offline_download_torrent": "BT Offline Download", + "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.", + "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/toolbar/OfflineDownloadEnhanced.tsx b/src/pages/home/toolbar/OfflineDownloadEnhanced.tsx new file mode 100644 index 000000000..20d71331e --- /dev/null +++ b/src/pages/home/toolbar/OfflineDownloadEnhanced.tsx @@ -0,0 +1,747 @@ +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) + }) + + // 重置状态 + 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 */} + + +