diff --git a/src/webui/www/private/addtorrent.html b/src/webui/www/private/addtorrent.html index 718fbfa4e8fd..682a79e3102c 100644 --- a/src/webui/www/private/addtorrent.html +++ b/src/webui/www/private/addtorrent.html @@ -25,6 +25,10 @@ "use strict"; window.addEventListener("DOMContentLoaded", (event) => { + const searchParams = new URLSearchParams(window.location.search); + + let submitted = false; + window.addEventListener("keydown", (event) => { switch (event.key) { case "Escape": @@ -34,7 +38,37 @@ } }); - const searchParams = new URLSearchParams(window.location.search); + const getClipboardText = (clipboardData) => { + for (const type of ["text/plain", "text/uri-list", "text"]) { + const text = clipboardData?.getData(type); + if ((typeof text === "string") && (text.length > 0)) + return text; + } + return ""; + }; + + const pasteTorrentLinks = (event) => { + const text = getClipboardText(event.clipboardData); + if (typeof text !== "string") + return false; + + const urls = window.qBittorrent.Misc.torrentLinksFromText(text); + if (urls.length === 0) + return false; + + event.preventDefault(); + event.stopImmediatePropagation(); + + document.getElementById("urls").value = urls.join("\n"); + submitted = true; + window.qBittorrent.AddTorrent.submitForm(); + document.getElementById("uploadForm").submit(); + return true; + }; + + document.addEventListener("paste", (event) => { + pasteTorrentLinks(event); + }, true); const source = searchParams.get("source"); if ((source === null) || (source.length === 0)) @@ -44,7 +78,6 @@ // fetch unless explicitly told not to const fetchMetadata = searchParams.get("fetch") !== "false"; - let submitted = false; const windowId = searchParams.get("windowId"); window.qBittorrent.AddTorrent.setWindowId(windowId); diff --git a/src/webui/www/private/scripts/client.js b/src/webui/www/private/scripts/client.js index 305b1646ce9b..c15a9518aaf6 100644 --- a/src/webui/www/private/scripts/client.js +++ b/src/webui/www/private/scripts/client.js @@ -45,6 +45,7 @@ window.qBittorrent.Client ??= (() => { isShowRssReader: isShowRssReader, isShowLogViewer: isShowLogViewer, createAddTorrentWindow: createAddTorrentWindow, + pasteTorrentLinks: pasteTorrentLinks, uploadTorrentFiles: uploadTorrentFiles, categoryMap: categoryMap, tagMap: tagMap @@ -56,6 +57,51 @@ window.qBittorrent.Client ??= (() => { // Map const tagMap = new Map(); + const addTorrentUrls = async (urls) => { + if (urls.length === 0) + return false; + + try { + const response = await fetch("api/v2/torrents/add", { + method: "POST", + body: new URLSearchParams({ + urls: urls.join("\n") + }) + }); + + if (response.status === 409) + return true; + return response.ok; + } + catch { + return false; + } + }; + + const getClipboardText = (clipboardData) => { + for (const type of ["text/plain", "text/uri-list", "text"]) { + const text = clipboardData?.getData(type); + if ((typeof text === "string") && (text.length > 0)) + return text; + } + return ""; + }; + + const pasteTorrentLinks = async (event) => { + const text = getClipboardText(event.clipboardData); + if (typeof text !== "string") + return false; + + const urls = window.qBittorrent.Misc.torrentLinksFromText(text); + if (urls.length === 0) + return false; + + event.preventDefault(); + event.stopImmediatePropagation(); + await addTorrentUrls(urls); + return true; + }; + let cacheAllSettled; let clientDataPromise; const setup = () => { @@ -1842,16 +1888,7 @@ window.addEventListener("DOMContentLoaded", async (event) => { if (droppedText.length > 0) { // dropped text - const urls = droppedText.split("\n") - .map((str) => str.trim()) - .filter((str) => { - const lowercaseStr = str.toLowerCase(); - return lowercaseStr.startsWith("http:") - || lowercaseStr.startsWith("https:") - || lowercaseStr.startsWith("magnet:") - || ((str.length === 40) && !(/[^0-9A-F]/i.test(str))) // v1 hex-encoded SHA-1 info-hash - || ((str.length === 32) && !(/[^2-7A-Z]/i.test(str))); // v1 Base32 encoded SHA-1 info-hash - }); + const urls = window.qBittorrent.Misc.torrentLinksFromText(droppedText); for (const url of urls) qBittorrent.Client.createAddTorrentWindow(url, url); @@ -1860,6 +1897,10 @@ window.addEventListener("DOMContentLoaded", async (event) => { }; registerDragAndDrop(); + document.addEventListener("paste", async (event) => { + await window.qBittorrent.Client.pasteTorrentLinks(event); + }, true); + window.addEventListener("keydown", (event) => { switch (event.key) { case "a": diff --git a/src/webui/www/private/scripts/misc.js b/src/webui/www/private/scripts/misc.js index c4e554dd401d..0090a4fe1f0f 100644 --- a/src/webui/www/private/scripts/misc.js +++ b/src/webui/www/private/scripts/misc.js @@ -35,6 +35,8 @@ window.qBittorrent.Misc ??= (() => { getHost: getHost, createDebounceHandler: createDebounceHandler, filterInPlace: filterInPlace, + isTorrentLink: isTorrentLink, + torrentLinksFromText: torrentLinksFromText, friendlyUnit: friendlyUnit, friendlyDuration: friendlyDuration, friendlyPercentage: friendlyPercentage, @@ -195,6 +197,49 @@ window.qBittorrent.Misc ??= (() => { return text.replace(exp, "$1"); }; + const isTorrentLink = (str) => { + const lowercase = str.toLowerCase(); + return lowercase.startsWith("http:") + || lowercase.startsWith("https:") + || lowercase.startsWith("magnet:") + || ((str.length === 40) && !(/[^0-9A-F]/i.test(str))) // v1 hex-encoded SHA-1 info-hash + || ((str.length === 32) && !(/[^2-7A-Z]/i.test(str))); // v1 Base32 encoded SHA-1 info-hash + }; + + const normalizeTorrentLink = (str) => { + const trimmed = safeTrim(str); + if (trimmed.length < 2) + return trimmed; + + const firstChar = trimmed[0]; + const lastChar = trimmed[trimmed.length - 1]; + if (((firstChar === "'") && (lastChar === "'")) + || ((firstChar === "\"") && (lastChar === "\"")) + || ((firstChar === "<") && (lastChar === ">"))) + return trimmed.slice(1, -1).trim(); + + return trimmed; + }; + + const torrentLinksFromText = (text) => { + const urls = []; + const seen = new Set(); + + for (const line of text.split("\n")) { + const url = normalizeTorrentLink(line); + if (url.length === 0) + continue; + + if (!isTorrentLink(url) || seen.has(url)) + continue; + + seen.add(url); + urls.push(url); + } + + return urls; + }; + /** * Parse a string into a Version Record. * It is generally recommended to use `compareVersions()` instead. diff --git a/src/webui/www/test/private/misc.test.js b/src/webui/www/test/private/misc.test.js index 0e5c28d691ad..dc3010ad5f05 100644 --- a/src/webui/www/test/private/misc.test.js +++ b/src/webui/www/test/private/misc.test.js @@ -217,3 +217,25 @@ test("Test formatDate() - Fallback Behavior", () => { // Restore original window.parent window.parent = originalParent; }); + +test("Test torrentLinksFromText()", () => { + const { isTorrentLink, torrentLinksFromText } = window.qBittorrent.Misc; + + expect(isTorrentLink("magnet:?xt=urn:btih:abc")).toBe(true); + expect(isTorrentLink("https://example.com/file.torrent")).toBe(true); + expect(isTorrentLink("not a torrent link")).toBe(false); + + expect(torrentLinksFromText([ + " magnet:?xt=urn:btih:abc ", + "\"magnet:?xt=urn:btih:abc\"", + "'magnet:?xt=urn:btih:abc'", + "", + "", + "https://example.com/file.torrent", + "not a torrent link", + "magnet:?xt=urn:btih:abc" + ].join("\n"))).toStrictEqual([ + "magnet:?xt=urn:btih:abc", + "https://example.com/file.torrent" + ]); +});