Booru Image DL (w/ Zoom + Hotkey)

Downloader with bulk, hover zoom, search suggestions, folder selection, and "D" hotkey.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

ستحتاج إلى تثبيت إضافة مثل Stylus لتثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتتمكن من تثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتثبيت هذا النمط.

(لدي بالفعل مثبت أنماط للمستخدم، دعني أقم بتثبيته!)

// ==UserScript==
// @name         Booru Image DL (w/ Zoom + Hotkey)
// @namespace    http://tampermonkey.net/
// @version      1.6
// @description  Downloader with bulk, hover zoom, search suggestions, folder selection, and "D" hotkey.
// @author       JohnPork
// @match        *://*.booru.org/*
// @connect      img.booru.org
// @connect      raw.githubusercontent.com
// @grant        GM_download
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_addStyle
// @grant        GM_xmlhttpRequest
// @require      https://code.jquery.com/jquery-2.1.4.min.js
// @license      MIT
// ==/UserScript==

/* globals jQuery */
(function () {
    "use strict";

    // --- Configuration ---
    const STORAGE_KEY_DOWNLOADED = "booruDownloader_downloaded_v1";
    const STORAGE_KEY_FOLDER = "booruDownloader_folder_path";
    const MAX_CONCURRENT = 2;
    const MAX_PREVIEW_HEIGHT = 600; // Max height of hover preview

    // --- Icons ---
    const ICON_DOWNLOAD = `<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24" fill="currentColor"><path d="M0 0h24v24H0z" fill="none"/><path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/></svg>`;
    const ICON_LOADING = `<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24" fill="currentColor" class="booru-dl-spinner"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/></svg>`;
    const ICON_CHECK = `<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24" fill="currentColor"><path d="M0 0h24v24H0z" fill="none"/><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>`;
    const ICON_SETTINGS = `<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24" fill="currentColor"><path d="M0 0h24v24H0z" fill="none"/><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .43-.17.47-.41l.36-2.54c.59-.24 1.13-.57 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>`;

    let downloadedSet = loadDownloadedSet();
    let downloadFolder = GM_getValue(STORAGE_KEY_FOLDER, null);

    let queue = [];
    let active = 0;

    // Globals for Hover/Hotkey logic
    let zoomContainer = null;
    let zoomImage = null;
    let currentHoveredBtn = null; // Tracks which image is currently hovered

    // --- Persistence ---

    function loadDownloadedSet() {
        try {
            const raw = GM_getValue(STORAGE_KEY_DOWNLOADED, "[]");
            return new Set(JSON.parse(raw));
        } catch (e) {
            console.error("Booru downloader load error:", e);
        }
        return new Set();
    }

    function saveDownloadedSet() {
        const arr = Array.from(downloadedSet);
        try {
            GM_setValue(STORAGE_KEY_DOWNLOADED, JSON.stringify(arr));
        } catch (e) {
            console.error("Booru downloader save error:", e);
        }
    }

    // --- Folder Management ---

    function configureFolder() {
        const current = downloadFolder || "";
        const msg = "Enter a subfolder name to save images.\n\n" +
                    "NOTE: Due to browser security, this must be inside your default Downloads folder.\n" +
                    "Example: 'MyBooru' will save to 'Downloads/MyBooru/'\n\n" +
                    "Leave empty to save directly in Downloads.";

        let input = prompt(msg, current);
        if (input !== null) {
            input = input.replace(/^[/\\]+|[/\\]+$/g, '').trim();
            downloadFolder = input;
            GM_setValue(STORAGE_KEY_FOLDER, downloadFolder);
            alert(`Download folder set to: ${downloadFolder ? downloadFolder : "(Default Downloads)"}`);
        }
    }

    // --- URL Logic ---

    function getFullImageUrl(thumbUrl) {
        if (!thumbUrl) return null;
        try {
            const u = new URL(thumbUrl);
            u.hostname = "img.booru.org";
            let p = u.pathname;
            p = p.replace("/thumbnails//", "//images/");
            p = p.replace("/thumbnail_", "/");
            u.pathname = p;
            return u.toString();
        } catch (e) {
            console.error("Booru downloader could not convert URL:", thumbUrl, e);
            return null;
        }
    }

    function getImageKey(fullUrl) {
        try {
            const u = new URL(fullUrl);
            const file = (u.pathname.split("/").pop() || "").trim();
            if (!file) return fullUrl;
            return file.split(".")[0] || fullUrl;
        } catch (e) {
            return fullUrl;
        }
    }

    // --- Download Queue ---

    function isDownloaded(key) {
        return downloadedSet.has(key);
    }

    function markAsDownloaded(key) {
        downloadedSet.add(key);
        saveDownloadedSet();
    }

    function enqueueDownload(url, filename) {
        return new Promise((resolve, reject) => {
            queue.push({ url, filename, resolve, reject });
            processQueue();
        });
    }

    function processQueue() {
        if (active >= MAX_CONCURRENT) return;
        const job = queue.shift();
        if (!job) return;

        active++;
        startDownload(job.url, job.filename)
            .then(() => job.resolve())
            .catch(err => job.reject(err))
            .finally(() => {
                active--;
                processQueue();
            });
    }

    function startDownload(url, filename) {
        return new Promise((resolve, reject) => {
            let finalPath = filename;
            if (downloadFolder && downloadFolder.length > 0) {
                finalPath = `${downloadFolder}/${filename}`;
            }

            if (typeof GM_download === "function") {
                GM_download({
                    url,
                    name: finalPath,
                    saveAs: false,
                    onload: () => resolve(),
                    onerror: err => reject(err)
                });
            } else {
                try {
                    const a = document.createElement("a");
                    a.href = url;
                    a.download = filename;
                    document.body.appendChild(a);
                    a.click();
                    document.body.removeChild(a);
                    resolve();
                } catch (e) {
                    reject(e);
                }
            }
        });
    }

    // --- Styles ---

    function injectStyles() {
        const css = `
        @keyframes booru-dl-spin {
            from { transform: rotate(0deg); }
            to { transform: rotate(360deg); }
        }
        span.thumb {
            position: relative;
            display: inline-block;
        }
        .booru-dl-topbar {
            display: flex;
            justify-content: flex-start;
            align-items: center;
            margin-bottom: 1rem;
            padding: 8px 0;
            gap: 1rem;
            font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
        }
        .booru-dl-pill {
            font-family: inherit;
            font-size: 13px;
            padding: 8px 16px;
            border-radius: 999px;
            border: 1px solid rgba(255, 255, 255, 0.12);
            background: linear-gradient(135deg, #111827, #020617);
            color: #e5e7eb;
            cursor: pointer;
            display: inline-flex;
            align-items: center;
            gap: 6px;
            box-shadow: 0 4px 14px rgba(15, 23, 42, 0.4);
            transition: background 0.15s ease, transform 0.1s ease, box-shadow 0.15s ease, opacity 0.15s ease;
        }
        .booru-dl-pill:hover {
            transform: translateY(-1px);
            box-shadow: 0 8px 24px rgba(15, 23, 42, 0.7);
            background: linear-gradient(135deg, #111827, #0f172a);
        }
        .booru-dl-pill:active {
            transform: translateY(0);
            box-shadow: 0 2px 8px rgba(15, 23, 42, 0.7);
        }
        .booru-dl-pill[disabled] {
            cursor: default;
            opacity: 0.6;
            box-shadow: none;
            transform: none;
        }
        .booru-dl-bulk-count {
            font-size: 12px;
            opacity: 0.8;
            font-weight: 500;
        }
        .booru-dl-icon-btn {
            padding: 8px;
            border-radius: 50%;
        }
        .thumb .booru-dl-wrapper {
            position: absolute;
            top: 4px;
            right: 4px;
            z-index: 10;
        }
        .booru-dl-btn {
            width: 28px;
            height: 28px;
            border-radius: 999px;
            border: 1px solid rgba(255, 255, 255, 0.1);
            background: rgba(15, 23, 42, 0.85);
            color: #e5e7eb;
            cursor: pointer;
            display: flex;
            align-items: center;
            justify-content: center;
            box-shadow: 0 2px 8px rgba(15, 23, 42, 0.6);
            transition: background 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
            padding: 0;
        }
        .booru-dl-btn:hover {
            box-shadow: 0 6px 16px rgba(15, 23, 42, 0.9);
            background: rgba(30, 41, 59, 0.95);
            transform: scale(1.1);
        }
        .booru-dl-btn:active {
            transform: scale(0.95);
            box-shadow: 0 1px 4px rgba(15, 23, 42, 0.8);
        }
        .booru-dl-btn[disabled] {
            cursor: default;
            opacity: 0.75;
            transform: none;
            box-shadow: 0 2px 8px rgba(15, 23, 42, 0.6);
        }
        .booru-dl-btn svg, .booru-dl-pill svg {
            width: 16px;
            height: 16px;
        }
        .booru-dl-btn .booru-dl-spinner {
            animation: booru-dl-spin 1s linear infinite;
        }
        .booru-dl-btn-downloading {
            background: rgba(55, 65, 81, 0.9);
        }
        .booru-dl-btn-downloaded {
            background: #16a34a;
            border-color: #16a34a;
            color: #ecfdf5;
        }
        .booru-zoom-container {
            position: fixed;
            max-width: 95vw;
            max-height: 95vh;
            padding: 6px;
            background: rgba(15, 23, 42, 0.96);
            border-radius: 8px;
            box-shadow: 0 18px 45px rgba(15, 23, 42, 0.95);
            display: none;
            z-index: 9999;
            pointer-events: none;
        }
        .booru-zoom-container img {
            display: block;
            max-width: 100%;
            max-height: ${MAX_PREVIEW_HEIGHT}px;
            width: auto;
            object-fit: contain;
        }
        `;
        GM_addStyle(css);
    }

    // --- Hover Zoom & Hotkey Tracking ---

    function ensureZoomElements() {
        if (zoomContainer && zoomImage) return;
        zoomContainer = document.createElement("div");
        zoomContainer.className = "booru-zoom-container";
        zoomImage = document.createElement("img");
        zoomContainer.appendChild(zoomImage);
        document.body.appendChild(zoomContainer);
    }

    function showZoom(fullUrl) {
        ensureZoomElements();
        zoomImage.src = fullUrl;
        zoomContainer.style.display = "block";
    }

    function updateZoomPosition(e) {
        if (!zoomContainer || zoomContainer.style.display === 'none') return;
        const x = e.clientX;
        const y = e.clientY;
        const vw = document.documentElement.clientWidth;
        const vh = document.documentElement.clientHeight;
        const offset = 15;

        zoomContainer.style.left = 'auto';
        zoomContainer.style.right = 'auto';
        zoomContainer.style.top = 'auto';
        zoomContainer.style.bottom = 'auto';
        zoomContainer.style.transform = 'none';

        if (x > vw / 2) zoomContainer.style.right = `${vw - x + offset}px`;
        else zoomContainer.style.left = `${x + offset}px`;

        if (y > vh / 2) zoomContainer.style.bottom = `${vh - y + offset}px`;
        else zoomContainer.style.top = `${y + offset}px`;
    }

    function hideZoom() {
        if (zoomContainer) {
            zoomContainer.style.display = "none";
            zoomImage.src = '';
        }
    }

    // --- Button Logic ---

    function setButtonDownloading(btn) {
        btn.disabled = true;
        btn.classList.remove("booru-dl-btn-download", "booru-dl-btn-downloaded");
        btn.classList.add("booru-dl-btn-downloading");
        btn.innerHTML = ICON_LOADING;
        btn.title = "Downloading";
    }

    function setButtonDownloaded(btn) {
        btn.disabled = true;
        btn.classList.remove("booru-dl-btn-download", "booru-dl-btn-downloading");
        btn.classList.add("booru-dl-btn-downloaded");
        btn.innerHTML = ICON_CHECK;
        btn.title = "Downloaded";
    }

    function setButtonReady(btn) {
        btn.disabled = false;
        btn.classList.remove("booru-dl-btn-downloading", "booru-dl-btn-downloaded");
        btn.classList.add("booru-dl-btn-download");
        btn.innerHTML = ICON_DOWNLOAD;
        btn.title = "Download image";
    }

    function triggerButtonDownload(btn) {
        if (!btn) return Promise.resolve();
        const fullUrl = btn.dataset.fullUrl;
        const key = btn.dataset.key;
        if (!fullUrl || !key) return Promise.resolve();

        if (isDownloaded(key)) {
            setButtonDownloaded(btn);
            return Promise.resolve();
        }

        const filename = fullUrl.split("/").pop() || (key + ".jpg");
        setButtonDownloading(btn);

        return enqueueDownload(fullUrl, filename)
            .then(() => {
                markAsDownloaded(key);
                setButtonDownloaded(btn);
            })
            .catch(err => {
                console.error("Download error:", err);
                setButtonReady(btn);
                btn.title = "Retry download";
            });
    }

    function setupThumb(img) {
        if (!img || img.dataset.booruDlAttached === "1") return;

        const fullUrl = getFullImageUrl(img.src);
        if (!fullUrl) return;
        const key = getImageKey(fullUrl);

        const spanThumb = img.closest("span.thumb");
        if (!spanThumb) return;

        let wrapper = spanThumb.querySelector(".booru-dl-wrapper");
        if (!wrapper) {
            wrapper = document.createElement("div");
            wrapper.className = "booru-dl-wrapper";
            spanThumb.appendChild(wrapper);
        }

        const btn = document.createElement("button");
        btn.type = "button";
        btn.className = "booru-dl-btn booru-dl-btn-download";
        btn.dataset.fullUrl = fullUrl;
        btn.dataset.key = key;

        if (isDownloaded(key)) setButtonDownloaded(btn);
        else setButtonReady(btn);

        btn.addEventListener("click", function (e) {
            e.stopPropagation(); // Prevent navigating to image page
            if (btn.classList.contains("booru-dl-btn-downloaded")) return;
            triggerButtonDownload(btn);
        });

        wrapper.appendChild(btn);

        // --- Mouse Events (Zoom + Hotkey Tracking) ---
        img.addEventListener("mouseenter", function () {
            showZoom(fullUrl);
            currentHoveredBtn = btn; // Set current hover target for hotkey
        });
        img.addEventListener("mousemove", updateZoomPosition);
        img.addEventListener("mouseleave", function () {
            hideZoom();
            if (currentHoveredBtn === btn) {
                currentHoveredBtn = null; // Clear target
            }
        });

        img.dataset.booruDlAttached = "1";
    }

    // --- Toolbar ---

    function addToolbar(content) {
        if (!content || content.querySelector(".booru-dl-topbar")) return;

        const topbar = document.createElement("div");
        topbar.className = "booru-dl-topbar";

        const bulkBtn = document.createElement("button");
        bulkBtn.type = "button";
        bulkBtn.className = "booru-dl-pill";
        bulkBtn.textContent = "Download all on page";

        const countSpan = document.createElement("span");
        countSpan.className = "booru-dl-bulk-count";

        const settingsBtn = document.createElement("button");
        settingsBtn.type = "button";
        settingsBtn.className = "booru-dl-pill booru-dl-icon-btn";
        settingsBtn.innerHTML = ICON_SETTINGS;
        settingsBtn.title = "Set Download Folder";

        bulkBtn.addEventListener("click", function () { bulkDownloadVisible(bulkBtn, countSpan); });
        settingsBtn.addEventListener("click", function() { configureFolder(); });

        topbar.appendChild(bulkBtn);
        topbar.appendChild(settingsBtn);
        topbar.appendChild(countSpan);

        content.insertBefore(topbar, content.firstChild);
    }

    function bulkDownloadVisible(bulkBtn, countSpan) {
        const buttons = Array.from(document.querySelectorAll(".thumb .booru-dl-btn[data-key]"));
        const targets = buttons.filter(btn => !btn.classList.contains("booru-dl-btn-downloaded"));

        if (!targets.length) {
            countSpan.textContent = "All images already downloaded";
            return;
        }
        countSpan.textContent = "Queued " + targets.length + " images";
        bulkBtn.disabled = true;
        let remaining = targets.length;

        function updateText() {
            if (remaining <= 0) {
                bulkBtn.disabled = false;
                countSpan.textContent = "Bulk download finished";
            } else {
                countSpan.textContent = remaining + " remaining";
            }
        }

        targets.forEach(btn => {
            if (btn.dataset.booruDlBulkQueued === "1") return;
            btn.dataset.booruDlBulkQueued = "1";
            triggerButtonDownload(btn).finally(() => {
                remaining -= 1;
                updateText();
            });
        });
        updateText();
    }

    function observeThumbs(content) {
        if (!window.MutationObserver) return;
        const observer = new MutationObserver(mutations => {
            mutations.forEach(m => {
                for (let i = 0; i < m.addedNodes.length; i++) {
                    const node = m.addedNodes[i];
                    if (node.matches && node.matches("span.thumb img")) {
                        setupThumb(node);
                    } else if (node.querySelectorAll) {
                        const imgs = node.querySelectorAll("span.thumb img");
                        for (let j = 0; j < imgs.length; j++) {
                            setupThumb(imgs[j]);
                        }
                    }
                }
            });
        });
        observer.observe(content, { childList: true, subtree: true });
    }

    // --- Search Suggestions ---

    function initSearchSuggestions() {
        if (!window.location.hostname.includes('blacked.booru.org')) return;
        if (window.location.search.includes("page=post") && window.location.search.includes("s=add")) return;

        const tagInput = document.getElementById('stags') || document.getElementById('tags');
        if (!tagInput) return;

        const suggestionsDatalist = document.createElement('datalist');
        suggestionsDatalist.id = "datalist";
        const historyDatalist = document.createElement('datalist');
        historyDatalist.id = "history";

        const ul = document.querySelector('.space');
        if (ul) ul.append(suggestionsDatalist, historyDatalist);
        else document.body.append(suggestionsDatalist, historyDatalist);

        let allTags = [];
        GM_xmlhttpRequest({
            method: "GET",
            url: 'https://raw.githubusercontent.com/Ankhanon/animated-octo-waffle/master/alltagsmarkedit.txt',
            onload: function(response) {
                if (response.status !== 200) return;
                allTags = response.responseText.match(/(?<=").*(?=")/g) || [];
            }
        });

        tagInput.setAttribute('list', "datalist");

        function appendOptions(arg, container) {
            const node = document.createElement("option");
            node.value = arg;
            container.appendChild(node);
        }

        tagInput.addEventListener("keyup", function(event) {
             const val = tagInput.value;
             if (val.length < 2) return;

             if (event.which == 32 && val.endsWith(', ')) {
                  appendOptions(val, historyDatalist);
                  let opts = [];
                  for(let i=0; i<historyDatalist.options.length; i++) opts.push(historyDatalist.options[i].value);
                  tagInput.placeholder = opts.join(" ");
                  tagInput.value = "";
                  return;
             }
             const match = val.match(/(?:.(?<!,|, ))+$/);
             if (!match) return;
             const currentWord = match[0].toLowerCase();

             suggestionsDatalist.innerHTML = "";
             let found = 0;
             for (let i = 0; i < allTags.length; i++) {
                 if (found >= 10) break;
                 if (allTags[i].toLowerCase().startsWith(currentWord)) {
                     appendOptions(allTags[i], suggestionsDatalist);
                     found++;
                 }
             }
        });
    }

    // --- Main Init ---

    function init() {
        const content = document.querySelector("div.content");
        if (!content) return;

        injectStyles();
        addToolbar(content);
        const thumbs = content.querySelectorAll("span.thumb img");
        thumbs.forEach(setupThumb);
        observeThumbs(content);
        initSearchSuggestions();

        if (downloadFolder === null) {
            setTimeout(configureFolder, 500);
        }

        // --- Hotkey Listener ---
        document.addEventListener("keydown", function(e) {
            // Check if key is "d" or "D"
            if (e.key === "d" || e.key === "D") {
                // Do not trigger if user is typing in an input field
                const activeTag = document.activeElement ? document.activeElement.tagName : "";
                if (activeTag === "INPUT" || activeTag === "TEXTAREA") return;

                // Check if we are hovering a valid image
                if (currentHoveredBtn) {
                    // console.log("Hotkey download triggered for:", currentHoveredBtn.dataset.key);
                    triggerButtonDownload(currentHoveredBtn);
                }
            }
        });
    }

    if (document.readyState === "loading") {
        document.addEventListener("DOMContentLoaded", init);
    } else {
        init();
    }
})();