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"
+ ]);
+});