Booru → Eagle cool Saver

Save imgs from booru directly to Eagle cool (single-worker queue, draggable panel)

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Booru → Eagle cool Saver
// @namespace    booru-eagle
// @version      1.5 Final
// @license      MIT
// @description  Save imgs from booru directly to Eagle cool (single-worker queue, draggable panel)
// @match        https://rule34.xxx/*
// @match        https://danbooru.donmai.us/*
// @match        https://chan.sankakucomplex.com/*
// @match        https://gelbooru.com/*
// @match        https://konachan.com/*
// @match        https://eagle.cool/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @grant        GM_addValueChangeListener
// @grant        GM_registerMenuCommand
// @grant        GM_xmlhttpRequest
// ==/UserScript==

(function () {
    "use strict";

    const KEYS = {
        API_TOKEN: "eagle_api_token",
        API_PORT: "eagle_api_port",
        SETTINGS_DISMISSED: "eagle_settings_dismissed",
        PARENT_ID: "eagle_parent_id",
        QUEUE: "eagle_queue_v5",
        WORKER: "eagle_worker_v5",
        PANEL_STATE: "eagle_panel_v3",
        AUTO_CLOSE: "eagle_autoclose_v3",
        UI_STATE: "eagle_ui_state_v2",
        PROGRESS: "eagle_progress_v2",
    };

    const CONFIG = {
        DEFAULT_PORT: "41595",
        DEFAULT_TOKEN: "Your Eagle API token here",
        WORKER_TIMEOUT: 25000,
        WORKER_INTERVAL: 1500,
        HEARTBEAT_INTERVAL: 5000,
        WORKER_ELECTION_MIN_DELAY: 200,
        WORKER_ELECTION_MAX_DELAY: 800,
        WORKER_RECOVERY_INTERVAL: 5000,
        ENQUEUE_MAX_RETRIES: 5,
        ENQUEUE_BASE_DELAY: 50,
        ENQUEUE_JITTER: 50,
        DEBOUNCE_DELAY: 150,
        DEBOUNCE_RESET_DELAY: 100,
        PROGRESS_INCREMENT: 5,
        PROGRESS_INCREMENT_INTERVAL: 50,
        PROGRESS_MAX_SHOW: 90,
        PROGRESS_HIDE_DELAY: 400,
        AUTOCLOSE_FALLBACK_TIMEOUT: 300000,
        AUTOCLOSE_CLOSE_DELAY: 500,
        PANEL_CHECK_INTERVAL: 1000,
        TOAST_DISPLAY_TIME: 2500,
        TOAST_FADE_DURATION: 200,
        SYNC_APPLY_DELAY: 50,
        QUEUE_PROCESS_DELAY: 500,
    };

    const TAB_ID = generateTabId();

    const DOM = {
        panel: null,
        header: null,
        body: null,
        saveBtn: null,
        parentBtn: null,
        stopBtn: null,
        autoCloseBtn: null,
        resInfo: null,
        queueInfo: null,
        workerBadge: null,
        collapseBtn: null,
        progressOuter: null,
        progressInner: null,
    };

    const worker = {
        isWorker: false,
        timer: null,
        heartbeatTimer: null,
        processing: false,
        currentPostId: null,
    };

    const ui = {
        collapsed: false,
        autoCloseEnabled: false,
        panelSyncing: false,
        progressSyncing: false,
        uiStateSyncing: false,
        _applyingQueueUpdate: false,
        _applyingWorkerUpdate: false,
        _applyingUIState: false,
        _applyingProgress: false,
        _applyingPanelState: false,
        _closingTab: false,
    };

    const debounceTimers = {
        uiState: null,
        panelState: null,
        progress: null,
    };

    const autoClose = {
        pendingPostId: null,
        timer: null,
        FALLBACK_TIMEOUT: CONFIG.AUTOCLOSE_FALLBACK_TIMEOUT,
    };

    const progress = {
        interval: null,
    };

    function generateTabId() {
        return Date.now().toString(36) + Math.random().toString(36).substr(2, 6);
    }

    function log(level, ...args) {
        const prefix = `[Eagle ${TAB_ID}]`;
        if (level === 'error') console.error(prefix, ...args);
        else if (level === 'warn') console.warn(prefix, ...args);
        else console.log(prefix, ...args);
    }

    function getQueue() {
        try {
            const raw = GM_getValue(KEYS.QUEUE, null);
            if (!raw) return [];
            const parsed = JSON.parse(raw);
            return Array.isArray(parsed) ? parsed : [];
        } catch (e) {
            log('error', 'Queue parse error:', e);
            return [];
        }
    }

    function saveQueue(queue) {
        try {
            GM_setValue(KEYS.QUEUE, JSON.stringify(queue));
        } catch (e) {
            log('error', 'Queue save error:', e);
        }
    }

    async function enqueueItem(url, tags, postId) {
        if (!postId || !url) {
            log('warn', 'enqueueItem: missing postId or url');
            return false;
        }

        const maxRetries = CONFIG.ENQUEUE_MAX_RETRIES;
        const baseDelay = CONFIG.ENQUEUE_BASE_DELAY;

        for (let attempt = 1; attempt <= maxRetries; attempt++) {
            const queue = getQueue();

            if (queue.some(item => item.postId === postId)) {
                log('warn', `Post ${postId} already in queue`);
                toast("⚠ Already in queue");
                return false;
            }

            queue.push({
                postId,
                url,
                tags,
                timestamp: Date.now(),
                addedByTab: TAB_ID,
            });

            saveQueue(queue);

            const verify = getQueue();
            if (verify.some(item => item.postId === postId)) {
                log('info', `Enqueued ${postId}, queue length: ${verify.length}`);
                return true;
            }

            const delay = baseDelay * Math.pow(2, attempt - 1) + Math.random() * CONFIG.ENQUEUE_JITTER;
            log('warn', `Queue conflict on ${postId}, attempt ${attempt}/${maxRetries}, retrying in ${Math.round(delay)}ms`);

            await new Promise(resolve => setTimeout(resolve, delay));
        }

        log('error', `Failed to enqueue ${postId} after ${maxRetries} attempts`);
        toast("⚠ Failed to add to queue — too many conflicts");
        return false;
    }

    function dequeueItem(postId) {
        const queue = getQueue();
        const idx = queue.findIndex(item => item.postId === postId);

        if (idx === -1) return false;

        queue.splice(idx, 1);
        saveQueue(queue);

        const verify = getQueue();
        if (!verify.some(item => item.postId === postId)) {
            log('info', `Dequeued ${postId}, queue length: ${verify.length}`);
            return true;
        }

        log('warn', `Dequeue verification failed for ${postId}`);
        return false;
    }

    function clearQueue() {
        const queue = getQueue();
        const wasProcessing = worker.processing;
        const currentPostId = worker.currentPostId;

        saveQueue([]);

        if (worker.processing) {
            worker.processing = false;
            worker.currentPostId = null;
            log('warn', `Cancelled processing of ${currentPostId}`);
        }

        showProgressBar(false);

        updateQueueUI();

        const count = queue.length;
        if (count > 0 || wasProcessing) {
            const text = wasProcessing
                ? `Cleared ${count} items + cancelled ${currentPostId}`
                : `Cleared ${count} items from queue`;
            log('info', text);
            toast(`✓ ${text}`);
        } else {
            toast("Queue already empty");
        }
    }

    function getActiveWorker() {
        try {
            const w = GM_getValue(KEYS.WORKER, null);
            if (!w) return null;

            const age = Date.now() - w.heartbeat;
            if (age > CONFIG.WORKER_TIMEOUT) {
                log('warn', `Worker ${w.tabId} expired (${age}ms ago)`);
                return null;
            }

            return w;
        } catch (e) {
            log('error', 'getActiveWorker error:', e);
            return null;
        }
    }

    function becomeWorker() {
        const active = getActiveWorker();

        if (active && active.tabId !== TAB_ID) {
            log('info', `Cannot become worker — ${active.tabId} is active`);
            return false;
        }

        worker.isWorker = true;
        saveWorkerState();
        startHeartbeat();

        log('info', 'Became WORKER');
        updateWorkerIndicator();
        return true;
    }

    function resignWorker() {
        worker.isWorker = false;
        worker.processing = false;
        worker.currentPostId = null;

        stopHeartbeat();
        stopWorkerLoop();

        const active = getActiveWorker();
        if (active && active.tabId === TAB_ID) {
            GM_setValue(KEYS.WORKER, null);
        }

        log('info', 'Resigned as worker');
        updateWorkerIndicator();
    }

    function saveWorkerState() {
        GM_setValue(KEYS.WORKER, {
            tabId: TAB_ID,
            heartbeat: Date.now(),
        });
    }

    function heartbeat() {
        const active = getActiveWorker();

        if (active && active.tabId === TAB_ID) {
            saveWorkerState();
        } else {
            if (worker.isWorker) {
                log('warn', 'Lost worker status, resigning');
                resignWorker();
            }
        }
    }

    function startHeartbeat() {
        stopHeartbeat();
        worker.heartbeatTimer = setInterval(heartbeat, CONFIG.HEARTBEAT_INTERVAL);
    }

    function stopHeartbeat() {
        if (worker.heartbeatTimer) {
            clearInterval(worker.heartbeatTimer);
            worker.heartbeatTimer = null;
        }
    }

    function startWorkerLoop() {
        stopWorkerLoop();

        worker.timer = setInterval(() => {
            if (!worker.isWorker) {
                stopWorkerLoop();
                return;
            }

            const queue = getQueue();
            if (queue.length === 0) {
                resignWorker();
                updateQueueUI();
                return;
            }

            heartbeat();
            processNextItem();
        }, CONFIG.WORKER_INTERVAL);

        setTimeout(() => {
            if (worker.isWorker) {
                heartbeat();
                processNextItem();
            }
        }, CONFIG.WORKER_ELECTION_MIN_DELAY);
    }

    function stopWorkerLoop() {
        if (worker.timer) {
            clearInterval(worker.timer);
            worker.timer = null;
        }
    }

    function tryBecomeWorker() {
        if (worker.isWorker) return;

        const active = getActiveWorker();
        if (!active) {
            const randomDelay = CONFIG.WORKER_ELECTION_MIN_DELAY + Math.random() * (CONFIG.WORKER_ELECTION_MAX_DELAY - CONFIG.WORKER_ELECTION_MIN_DELAY);
            log('info', `Attempting to become worker in ${Math.round(randomDelay)}ms`);

            setTimeout(() => {
                const stillNoActive = getActiveWorker();
                if (!stillNoActive && !worker.isWorker) {
                    if (becomeWorker()) {
                        startWorkerLoop();
                    }
                }
            }, randomDelay);
        }
    }

    async function processNextItem() {
        if (!worker.isWorker || worker.processing) return;

        const queue = getQueue();
        if (queue.length === 0) {
            updateQueueUI();
            return;
        }

        const item = queue[0];

        worker.processing = true;
        worker.currentPostId = item.postId;

        log('info', `Processing ${item.postId} (${item.url.substring(0, 50)}...)`);
        updateQueueUI();
        saveProgressState(true, 0);

        try {
            const success = await sendToEagle(item.url, item.tags);

            if (success) {
                dequeueItem(item.postId);
                saveProgressState(false, 100);
            } else {
                log('warn', `Failed to send ${item.postId}, keeping in queue`);
                saveProgressState(false, 0);
            }
        } catch (err) {
            log('error', `Error processing ${item.postId}:`, err);
            saveProgressState(false, 0);
        } finally {
            worker.processing = false;
            worker.currentPostId = null;
            updateQueueUI();
            checkAutoClose();
        }
    }

    function waitForAutoClose(postId) {
        if (autoClose.timer) {
            clearTimeout(autoClose.timer);
            autoClose.timer = null;
        }

        autoClose.pendingPostId = postId;

        toast("✓ Queued — will close after upload");

        autoClose.timer = setTimeout(() => {
            if (autoClose.pendingPostId === postId) {
                log('warn', `Auto-close fallback for ${postId}, closing tab`);
                autoClose.pendingPostId = null;
                autoClose.timer = null;
                closeTab();
            }
        }, autoClose.FALLBACK_TIMEOUT);
    }

    function checkAutoClose() {
        if (!autoClose.pendingPostId) return;

        const queue = getQueue();
        const stillInQueue = queue.some(item => item.postId === autoClose.pendingPostId);
        const stillProcessing = worker.currentPostId === autoClose.pendingPostId;

        if (!stillInQueue && !stillProcessing) {
            log('info', `Auto-close: ${autoClose.pendingPostId} processed, closing tab`);
            toast("✓ Done — closing tab");

            if (autoClose.timer) {
                clearTimeout(autoClose.timer);
                autoClose.timer = null;
            }

            autoClose.pendingPostId = null;

            setTimeout(() => closeTab(), CONFIG.AUTOCLOSE_CLOSE_DELAY);
        }
    }

    function closeTab() {
        if (ui._closingTab) {
            log('warn', 'closeTab called while already closing — ignoring');
            return;
        }
        ui._closingTab = true;

        autoClose.pendingPostId = null;
        if (autoClose.timer) {
            clearTimeout(autoClose.timer);
            autoClose.timer = null;
        }

        log('info', `Attempting to close tab (history.length=${history.length})`);

        if (history.length > 1) {
            log('info', 'Navigating back (history.back)');
            history.back();
        } else {
            try {
                const closed = window.close();
                if (closed || window.closed) {
                    log('info', 'Tab closed via window.close()');
                    return;
                }
            } catch (e) {
                log('warn', 'window.close() threw:', e);
            }
            try {
                location.replace('about:blank');
            } catch (e2) {
                log('error', 'All close methods failed');
            }
        }

        setTimeout(() => {
            ui._closingTab = false;
        }, 2000);
    }

    function saveUIState() {
        if (!DOM.panel || ui._applyingUIState) return;

        if (debounceTimers.uiState) {
            clearTimeout(debounceTimers.uiState);
        }

        debounceTimers.uiState = setTimeout(() => {
            const state = {
                autoClose: ui.autoCloseEnabled,
                parentID: GM_getValue(KEYS.PARENT_ID, null),
                tabId: TAB_ID,
                timestamp: Date.now(),
            };

            ui.uiStateSyncing = true;
            GM_setValue(KEYS.UI_STATE, JSON.stringify(state));
            setTimeout(() => { ui.uiStateSyncing = false; }, CONFIG.DEBOUNCE_RESET_DELAY);
            debounceTimers.uiState = null;
        }, CONFIG.DEBOUNCE_DELAY);
    }

    function applyUIState(state) {
        if (!state || !DOM.panel || ui._applyingUIState) return;

        ui._applyingUIState = true;

        try {
            if (state.autoClose !== undefined && state.autoClose !== ui.autoCloseEnabled) {
                ui.autoCloseEnabled = state.autoClose;
                updateAutoCloseBtn();
                if (DOM.saveBtn) {
                    DOM.saveBtn.textContent = ui.autoCloseEnabled ? "Save+Close" : "Save";
                    DOM.saveBtn.title = ui.autoCloseEnabled
                        ? "Alt+Z — add to queue & close tab"
                        : "Alt+Z — add to queue";
                }
            }

            updatePostPageUI();

            if (DOM.resInfo) {
                const onPost = isPostPage();
                const resolution = getImageResolution();
                DOM.resInfo.textContent = onPost ? resolution : "[not a post page]";
            }

            if (state.parentID !== undefined) {
                updateColors();
            }
        } finally {
            setTimeout(() => { ui._applyingUIState = false; }, CONFIG.SYNC_APPLY_DELAY);
        }
    }

    function saveProgressState(show, width) {
        if (ui._applyingProgress) return;

        if (debounceTimers.progress) {
            clearTimeout(debounceTimers.progress);
        }

        debounceTimers.progress = setTimeout(() => {
            ui.progressSyncing = true;
            GM_setValue(KEYS.PROGRESS, JSON.stringify({
                show,
                width,
                tabId: TAB_ID,
                timestamp: Date.now()
            }));
            setTimeout(() => { ui.progressSyncing = false; }, CONFIG.DEBOUNCE_RESET_DELAY);
            debounceTimers.progress = null;
        }, CONFIG.DEBOUNCE_DELAY);
    }

    function applyProgressState(state) {
        if (!state || !DOM.progressOuter || !DOM.progressInner || ui._applyingProgress) return;

        ui._applyingProgress = true;

        try {
            if (state.show) {
                DOM.progressOuter.style.display = "block";
                DOM.progressInner.style.width = (state.width || 0) + "%";
            } else {
                DOM.progressOuter.style.display = "none";
                DOM.progressInner.style.width = "0%";
            }
        } finally {
            setTimeout(() => { ui._applyingProgress = false; }, CONFIG.SYNC_APPLY_DELAY);
        }
    }

    function savePanelState() {
        if (!DOM.panel || ui._applyingPanelState) return;

        if (debounceTimers.panelState) {
            clearTimeout(debounceTimers.panelState);
        }

        debounceTimers.panelState = setTimeout(() => {
            const state = {
                left: DOM.panel.style.left,
                top: DOM.panel.style.top,
                transform: DOM.panel.style.transform,
                collapsed: ui.collapsed,
            };

            ui.panelSyncing = true;
            GM_setValue(KEYS.PANEL_STATE, JSON.stringify(state));
            setTimeout(() => { ui.panelSyncing = false; }, CONFIG.DEBOUNCE_RESET_DELAY);
            debounceTimers.panelState = null;
        }, CONFIG.DEBOUNCE_DELAY);
    }

    function applyPanelState(state) {
        if (!state || !DOM.panel || ui._applyingPanelState) return;

        ui._applyingPanelState = true;

        try {
            DOM.panel.style.left = state.left;
            DOM.panel.style.top = state.top;
            DOM.panel.style.transform = state.transform;

            if (state.collapsed !== undefined && state.collapsed !== ui.collapsed) {
                setCollapsed(state.collapsed);
            }
        } finally {
            setTimeout(() => { ui._applyingPanelState = false; }, CONFIG.SYNC_APPLY_DELAY);
        }
    }

    function setupValueChangeListeners() {
        GM_addValueChangeListener(KEYS.QUEUE, (name, oldVal, newVal) => {
            if (ui._applyingQueueUpdate) return;

            ui._applyingQueueUpdate = true;
            try {
                updateQueueUI();
                checkAutoClose();

                if (worker.isWorker && !worker.processing) {
                    setTimeout(() => processNextItem(), CONFIG.QUEUE_PROCESS_DELAY);
                }
            } finally {
                setTimeout(() => { ui._applyingQueueUpdate = false; }, CONFIG.DEBOUNCE_RESET_DELAY);
            }
        });

        GM_addValueChangeListener(KEYS.WORKER, (name, oldVal, newVal) => {
            if (ui._applyingWorkerUpdate) return;

            ui._applyingWorkerUpdate = true;
            try {
                if (!worker.isWorker) {
                    const randomDelay = 1000 + Math.random() * 1000;
                    setTimeout(() => {
                        const active = getActiveWorker();
                        if (!active && getQueue().length > 0) {
                            tryBecomeWorker();
                        }
                    }, randomDelay);
                }
            } finally {
                setTimeout(() => { ui._applyingWorkerUpdate = false; }, CONFIG.DEBOUNCE_RESET_DELAY);
            }
        });

        GM_addValueChangeListener(KEYS.UI_STATE, (name, oldVal, newVal) => {
            if (ui.uiStateSyncing || ui._applyingUIState) return;
            try {
                if (newVal) {
                    ui._applyingUIState = true;
                    applyUIState(JSON.parse(newVal));
                }
            } catch (e) {
                log('warn', 'UI_STATE parse error:', e);
            }
        });

        GM_addValueChangeListener(KEYS.PROGRESS, (name, oldVal, newVal) => {
            if (ui.progressSyncing || ui._applyingProgress) return;
            try {
                if (newVal) {
                    ui._applyingProgress = true;
                    applyProgressState(JSON.parse(newVal));
                }
            } catch (e) {
                log('warn', 'PROGRESS parse error:', e);
            }
        });

        GM_addValueChangeListener(KEYS.PANEL_STATE, (name, oldVal, newVal) => {
            if (ui.panelSyncing || ui._applyingPanelState) return;
            try {
                if (newVal) {
                    ui._applyingPanelState = true;
                    applyPanelState(JSON.parse(newVal));
                }
            } catch (e) {
                log('warn', 'PANEL_STATE parse error:', e);
            }
        });
    }

    function startWorkerRecoveryCheck() {
        setInterval(() => {
            if (!worker.isWorker) {
                const active = getActiveWorker();
                const queue = getQueue();

                if (!active && queue.length > 0) {
                    log('info', 'No active worker found in recovery check, trying to become worker');
                    tryBecomeWorker();
                }
            }
        }, CONFIG.WORKER_RECOVERY_INTERVAL);
    }

    function getApiUrl() {
        const port = GM_getValue(KEYS.API_PORT, CONFIG.DEFAULT_PORT);
        const token = GM_getValue(KEYS.API_TOKEN, null);

        if (!token) {
            log('warn', 'API token not configured');
            return null;
        }

        return `http://localhost:${port}/api/item/addFromURL?token=${token}`;
    }

    async function sendToEagle(url, tags) {
        if (!url) {
            log('warn', 'sendToEagle: empty URL');
            toast("✗ No URL");
            return false;
        }

        const apiUrl = getApiUrl();
        if (!apiUrl) {
            log('error', 'sendToEagle: API not configured');
            toast("⚠️ No API token! (Alt+S to configure)");
            return false;
        }

        showProgressBar(true);

        let finalUrl = url;
        if (isSankaku() && !url.startsWith('data:')) {
            try {
                finalUrl = await toDataURL(url);
            } catch (err) {
                log('error', 'Sankaku URL conversion failed:', err);
                toast("⚠ Image conversion failed, using original URL");
            }
        }

        const payload = JSON.stringify({
            url: finalUrl,
            name: document.title,
            website: location.href,
            tags: tags,
            headers: { referer: location.href }
        });

        log('info', `Sending to Eagle: ${finalUrl.substring(0, 60)}...`);

        return new Promise((resolve) => {
            GM_xmlhttpRequest({
                method: "POST",
                url: apiUrl,
                headers: { "Content-Type": "application/json" },
                data: payload,
                timeout: 60000,
                onload: function(response) {
                    showProgressBar(false);

                    try {
                        const data = JSON.parse(response.responseText);

                        if (data.status === "success") {
                            log('info', 'Successfully saved to Eagle');
                            toast("✓ Saved to Eagle");
                            resolve(true);
                        } else {
                            log('error', 'Eagle API error:', data);
                            toast("✗ Eagle: " + (data.message || "Unknown error"));
                            resolve(false);
                        }
                    } catch (e) {
                        log('error', 'Response parse error:', e,
                            'Response:', response.responseText?.substring(0, 200));
                        toast("✗ Response parse error");
                        resolve(false);
                    }
                },
                onerror: function(err) {
                    showProgressBar(false);
                    log('error', 'Network error:', err);
                    toast("✗ Network error — is Eagle running?");
                    resolve(false);
                },
                ontimeout: function() {
                    showProgressBar(false);
                    log('warn', 'Request timeout (60s)');
                    toast("✗ Timeout — Eagle may be busy");
                    resolve(false);
                }
            });
        });
    }

    async function toDataURL(url) {
        return new Promise((resolve) => {
            GM_xmlhttpRequest({
                method: "GET",
                url: url,
                responseType: "blob",
                timeout: 60000,
                onload: function(response) {
                    const blob = response.response;

                    if (!blob) {
                        log('error', 'toDataURL: empty response');
                        resolve(url);
                        return;
                    }

                    const reader = new FileReader();
                    reader.onloadend = () => resolve(reader.result);
                    reader.onerror = () => {
                        log('error', 'toDataURL: FileReader error');
                        resolve(url);
                    };
                    reader.readAsDataURL(blob);
                },
                onerror: function(err) {
                    log('error', 'toDataURL: network error', err);
                    resolve(url);
                },
                ontimeout: function() {
                    log('warn', 'toDataURL: timeout');
                    resolve(url);
                }
            });
        });
    }

    function toast(text) {
        if (!document.body) return;

        const d = document.createElement("div");
        d.textContent = text;
        d.style.cssText = `
            position:fixed;top:20px;right:20px;
            background:rgba(18,18,24,0.92);
            backdrop-filter:blur(12px);
            color:#e8e8e8;
            padding:9px 16px;
            border-radius:10px;
            z-index:1000000;
            font-size:13px;
            font-family:'Segoe UI',system-ui,sans-serif;
            font-weight:500;
            box-shadow:0 6px 20px rgba(0,0,0,0.4);
            border:1px solid rgba(255,255,255,0.06);
            opacity:0;
            transform:translateY(-8px);
            transition:opacity 0.2s ease,transform 0.2s ease;
        `;
        document.body.appendChild(d);

        requestAnimationFrame(() => {
            d.style.opacity = "1";
            d.style.transform = "translateY(0)";
        });

        setTimeout(() => {
            d.style.opacity = "0";
            d.style.transform = "translateY(-8px)";
            setTimeout(() => d.remove(), CONFIG.TOAST_FADE_DURATION);
        }, CONFIG.TOAST_DISPLAY_TIME);
    }

    function createPanel() {
        if (DOM.panel) return;

        DOM.panel = document.createElement("div");
        DOM.panel.id = "eagle-panel";
        DOM.panel.style.cssText = `
            position: fixed;
            top: 50px;
            left: 50%;
            transform: translateX(-50%);
            min-width: 280px;
            z-index: 999999;
            font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
            background: rgba(18, 18, 24, 0.88);
            backdrop-filter: blur(16px) saturate(180%);
            -webkit-backdrop-filter: blur(16px) saturate(180%);
            border: 1px solid rgba(255, 255, 255, 0.08);
            border-radius: 14px;
            box-shadow: 0 12px 40px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.04) inset;
            overflow: hidden;
            transition: box-shadow 0.3s ease;
            user-select: none;
        `;

        DOM.header = document.createElement("div");
        DOM.header.style.cssText = `
            display: flex;
            align-items: center;
            justify-content: space-between;
            padding: 10px 14px;
            cursor: grab;
            border-bottom: 1px solid rgba(255,255,255,0.06);
            background: rgba(255,255,255,0.02);
        `;
        DOM.header.onmouseenter = () => {
            DOM.panel.style.boxShadow = "0 16px 48px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.06) inset";
        };
        DOM.header.onmouseleave = () => {
            DOM.panel.style.boxShadow = "0 12px 40px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.04) inset";
        };

        const title = document.createElement("span");
        title.style.cssText = "color: #e8e8e8; font-size: 13px; font-weight: 600; letter-spacing: 0.3px;";
        title.textContent = "Eagle Saver";

        DOM.workerBadge = document.createElement("span");
        DOM.workerBadge.style.cssText = "font-size: 12px; line-height: 1; transition: all 0.3s ease;";

        DOM.collapseBtn = document.createElement("button");
        DOM.collapseBtn.style.cssText = `
            background: none;
            border: none;
            color: #888;
            cursor: pointer;
            font-size: 16px;
            padding: 2px 6px;
            border-radius: 6px;
            line-height: 1;
            transition: all 0.2s ease;
            transform: rotate(180deg);
        `;
        DOM.collapseBtn.textContent = "▾";
        DOM.collapseBtn.onmouseenter = () => {
            DOM.collapseBtn.style.color = "#fff";
            DOM.collapseBtn.style.background = "rgba(255,255,255,0.08)";
        };
        DOM.collapseBtn.onmouseleave = () => {
            DOM.collapseBtn.style.color = "#888";
            DOM.collapseBtn.style.background = "none";
        };
        DOM.collapseBtn.onclick = (e) => {
            e.stopPropagation();
            toggleCollapse();
        };

        DOM.header.appendChild(title);
        DOM.header.appendChild(DOM.workerBadge);
        DOM.header.appendChild(DOM.collapseBtn);
        DOM.panel.appendChild(DOM.header);

        DOM.body = document.createElement("div");
        DOM.body.style.cssText = `
            display: flex;
            flex-direction: column;
            align-items: center;
            gap: 6px;
            padding: 10px 14px 12px;
            max-height: 500px;
            opacity: 1;
            pointer-events: auto;
            transition: max-height 0.35s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.2s ease;
        `;

        const btnBox = document.createElement("div");
        btnBox.style.cssText = "display:flex;gap:6px;flex-wrap:wrap;justify-content:center;";

        DOM.saveBtn = document.createElement("button");
        DOM.saveBtn.textContent = "Save";
        DOM.saveBtn.title = "Alt+Z — add to queue";

        DOM.parentBtn = document.createElement("button");
        DOM.parentBtn.textContent = "Parent";
        DOM.parentBtn.title = "Alt+X — set parent ID & save";

        DOM.stopBtn = document.createElement("button");
        DOM.stopBtn.textContent = "Stop";
        DOM.stopBtn.title = "Alt+C — clear parent, stop worker & clear queue";

        DOM.autoCloseBtn = document.createElement("button");
        DOM.autoCloseBtn.textContent = "⏻";
        DOM.autoCloseBtn.title = "Auto-close tab after Save";

        const baseBtnStyle = "padding:7px 14px;border:none;border-radius:8px;cursor:pointer;font-size:12px;font-weight:600;letter-spacing:0.2px;transition:all 0.15s ease;";

        DOM.saveBtn.style.cssText = baseBtnStyle + "background:rgba(255,152,0,0.85);color:#fff;";
        DOM.parentBtn.style.cssText = baseBtnStyle + "background:rgba(255,152,0,0.85);color:#fff;";
        DOM.stopBtn.style.cssText = baseBtnStyle + "background:rgba(244,67,54,0.85);color:#fff;";
        DOM.autoCloseBtn.style.cssText = baseBtnStyle + "background:rgba(96,125,139,0.6);color:#90a4ae;font-size:16px;padding:7px 10px;";
        DOM.autoCloseBtn.style.minWidth = "36px";
        DOM.autoCloseBtn.style.textAlign = "center";

        [DOM.saveBtn, DOM.parentBtn, DOM.stopBtn].forEach(b => {
            b.onmouseenter = () => {
                b.style.transform = "translateY(-1px)";
                b.style.filter = "brightness(1.15)";
                b.style.boxShadow = "0 4px 12px rgba(0,0,0,0.3)";
            };
            b.onmouseleave = () => {
                b.style.transform = "none";
                b.style.filter = "none";
                b.style.boxShadow = "none";
            };
            b.onmousedown = () => {
                b.style.transform = "translateY(0) scale(0.97)";
            };
            b.onmouseup = () => {
                b.style.transform = "translateY(-1px)";
            };
        });

        DOM.autoCloseBtn.onmouseenter = () => {
            DOM.autoCloseBtn.style.transform = "translateY(-1px)";
            DOM.autoCloseBtn.style.filter = "brightness(1.3)";
        };
        DOM.autoCloseBtn.onmouseleave = () => {
            if (!ui.autoCloseEnabled) {
                DOM.autoCloseBtn.style.transform = "none";
                DOM.autoCloseBtn.style.filter = "none";
            }
        };

        btnBox.appendChild(DOM.saveBtn);
        btnBox.appendChild(DOM.parentBtn);
        btnBox.appendChild(DOM.stopBtn);
        btnBox.appendChild(DOM.autoCloseBtn);
        DOM.body.appendChild(btnBox);

        DOM.resInfo = document.createElement("div");
        DOM.resInfo.style.cssText = "color:#4fc3f7;font-size:11px;font-weight:600;letter-spacing:0.3px;";
        DOM.resInfo.textContent = "[...]";
        DOM.body.appendChild(DOM.resInfo);

        DOM.queueInfo = document.createElement("div");
        DOM.queueInfo.style.cssText = "color:#ffeb3b;font-size:11px;font-weight:600;display:none;letter-spacing:0.3px;";
        DOM.body.appendChild(DOM.queueInfo);

        DOM.progressOuter = document.createElement("div");
        DOM.progressOuter.id = "eagle-progress";
        DOM.progressOuter.style.cssText = `
            width: 100%;
            height: 4px;
            background: rgba(255,255,255,0.08);
            border-radius: 2px;
            overflow: hidden;
            display: none;
        `;
        DOM.progressInner = document.createElement("div");
        DOM.progressInner.id = "eagle-progress-inner";
        DOM.progressInner.style.cssText = `
            width: 0%;
            height: 100%;
            background: linear-gradient(90deg, #4caf50, #66bb6a);
            border-radius: 2px;
            transition: width 0.1s linear;
        `;
        DOM.progressOuter.appendChild(DOM.progressInner);
        DOM.body.appendChild(DOM.progressOuter);

        DOM.panel.appendChild(DOM.body);

        setupDragAndDrop();

        document.body.appendChild(DOM.panel);

        try {
            const savedState = GM_getValue(KEYS.PANEL_STATE, null);
            if (savedState) applyPanelState(JSON.parse(savedState));
        } catch (e) {}

        ui.autoCloseEnabled = GM_getValue(KEYS.AUTO_CLOSE, false);
        updateAutoCloseBtn();
        if (ui.autoCloseEnabled && DOM.saveBtn) {
            DOM.saveBtn.textContent = "Save+Close";
            DOM.saveBtn.title = "Alt+Z — add to queue & close tab";
        }

        updatePostPageUI();
        updateColors();
    }

    function setupDragAndDrop() {
        let isDragging = false;
        let dragStartX = 0, dragStartY = 0;
        let panelStartLeft = 0, panelStartTop = 0;

        DOM.header.onmousedown = (e) => {
            if (e.target === DOM.collapseBtn) return;

            isDragging = true;
            dragStartX = e.clientX;
            dragStartY = e.clientY;
            panelStartLeft = DOM.panel.offsetLeft;
            panelStartTop = DOM.panel.offsetTop;

            DOM.panel.style.transform = "none";
            DOM.header.style.cursor = "grabbing";

            e.preventDefault();
            e.stopPropagation();
        };

        document.addEventListener("mousemove", (e) => {
            if (!isDragging) return;

            const dx = e.clientX - dragStartX;
            const dy = e.clientY - dragStartY;
            const newLeft = panelStartLeft + dx;
            const newTop = panelStartTop + dy;

            const panelRect = DOM.panel.getBoundingClientRect();
            const vw = window.innerWidth;
            const vh = window.innerHeight;

            DOM.panel.style.left = Math.max(0, Math.min(newLeft, vw - panelRect.width)) + "px";
            DOM.panel.style.top = Math.max(0, Math.min(newTop, vh - panelRect.height)) + "px";
        });

        document.addEventListener("mouseup", () => {
            if (isDragging) {
                isDragging = false;
                DOM.header.style.cursor = "";
                savePanelState();
            }
        });

        DOM.header.addEventListener("selectstart", (e) => e.preventDefault());
    }

    function updateColors() {
        const parentID = GM_getValue(KEYS.PARENT_ID, null);

        if (parentID) {
            DOM.saveBtn.style.background = "rgba(76,175,80,0.85)";
            DOM.parentBtn.style.background = "rgba(76,175,80,0.85)";
            DOM.parentBtn.textContent = "Parent: " + parentID;
        } else {
            DOM.saveBtn.style.background = "rgba(255,152,0,0.85)";
            DOM.parentBtn.style.background = "rgba(255,152,0,0.85)";
            DOM.parentBtn.textContent = "Parent";
        }

        if (DOM.resInfo) {
            DOM.resInfo.textContent = getImageResolution();
        }

        updateQueueUI();
        updateWorkerIndicator();
        saveUIState();
    }

    function updatePostPageUI() {
        const onPost = isPostPage();
        const opacity = onPost ? "1" : "0.35";
        const pointer = onPost ? "auto" : "none";

        if (DOM.saveBtn) {
            DOM.saveBtn.style.opacity = opacity;
            DOM.saveBtn.style.pointerEvents = pointer;
        }
        if (DOM.parentBtn) {
            DOM.parentBtn.style.opacity = opacity;
            DOM.parentBtn.style.pointerEvents = pointer;
        }
        if (DOM.stopBtn) {
            DOM.stopBtn.style.opacity = opacity;
            DOM.stopBtn.style.pointerEvents = pointer;
        }

        if (DOM.autoCloseBtn) {
            DOM.autoCloseBtn.style.opacity = "1";
            DOM.autoCloseBtn.style.pointerEvents = "auto";
        }

        if (!onPost) {
            if (DOM.resInfo) DOM.resInfo.textContent = "[not a post page]";
            if (DOM.queueInfo) DOM.queueInfo.style.display = "none";
        }

        saveUIState();
    }

    function updateQueueUI() {
        if (!DOM.queueInfo) return;

        const queue = getQueue();
        const processingText = worker.currentPostId
            ? ` | Processing: ${worker.currentPostId}`
            : '';

        if (queue.length > 0) {
            DOM.queueInfo.textContent = `${queue.length} in queue${processingText}`;
            DOM.queueInfo.style.display = "block";
        } else {
            DOM.queueInfo.style.display = "none";
        }
    }

    function updateWorkerIndicator() {
        if (!DOM.workerBadge) return;

        if (worker.isWorker) {
            DOM.workerBadge.textContent = "●";
            DOM.workerBadge.style.color = "#00e676";
            DOM.workerBadge.style.textShadow = "0 0 6px rgba(0,230,118,0.6)";
        } else {
            DOM.workerBadge.textContent = "○";
            DOM.workerBadge.style.color = "#555";
            DOM.workerBadge.style.textShadow = "none";
        }

        if (DOM.saveBtn) {
            if (worker.isWorker) {
                DOM.saveBtn.style.outline = "2px solid #00e676";
                DOM.saveBtn.style.outlineOffset = "2px";
            } else {
                DOM.saveBtn.style.outline = "none";
            }
        }
    }

    function updateAutoCloseBtn() {
        if (!DOM.autoCloseBtn) return;

        if (ui.autoCloseEnabled) {
            DOM.autoCloseBtn.style.background = "rgba(76,175,80,0.85)";
            DOM.autoCloseBtn.style.color = "#fff";
            DOM.autoCloseBtn.style.boxShadow = "0 0 10px rgba(76,175,80,0.4)";
        } else {
            DOM.autoCloseBtn.style.background = "rgba(96,125,139,0.6)";
            DOM.autoCloseBtn.style.color = "#90a4ae";
            DOM.autoCloseBtn.style.boxShadow = "none";
        }
    }

    function setCollapsed(collapsed) {
        ui.collapsed = collapsed;

        if (DOM.body) {
            DOM.body.style.maxHeight = collapsed ? "0" : "500px";
            DOM.body.style.opacity = collapsed ? "0" : "1";
            DOM.body.style.pointerEvents = collapsed ? "none" : "auto";
        }
        if (DOM.collapseBtn) {
            DOM.collapseBtn.style.transform = collapsed ? "rotate(0deg)" : "rotate(180deg)";
        }
    }

    function toggleCollapse() {
        setCollapsed(!ui.collapsed);
        savePanelState();
    }

    function showProgressBar(show) {
        if (!DOM.progressOuter || !DOM.progressInner) return;

        if (show) {
            if (progress.interval) {
                clearInterval(progress.interval);
                progress.interval = null;
            }

            DOM.progressOuter.style.display = "block";
            DOM.progressInner.style.width = "0%";

            if (worker.isWorker) {
                let width = 0;
                progress.interval = setInterval(() => {
                    width += CONFIG.PROGRESS_INCREMENT;
                    if (width >= CONFIG.PROGRESS_MAX_SHOW) {
                        clearInterval(progress.interval);
                        progress.interval = null;
                        return;
                    }
                    DOM.progressInner.style.width = width + "%";
                    saveProgressState(true, width);
                }, CONFIG.PROGRESS_INCREMENT_INTERVAL);
            } else {
                DOM.progressInner.style.width = "0%";
                saveProgressState(true, 0);
            }
        } else {
            if (progress.interval) {
                clearInterval(progress.interval);
                progress.interval = null;
            }

            DOM.progressInner.style.width = "100%";
            saveProgressState(false, 100);

            setTimeout(() => {
                DOM.progressOuter.style.display = "none";
                DOM.progressInner.style.width = "0%";
            }, CONFIG.PROGRESS_HIDE_DELAY);
        }
    }

    function bindButtons() {
        if (!DOM.saveBtn) return;

        DOM.saveBtn.onclick = async () => {
            const url = getOriginalImageURL();
            const tags = getTags();
            const postId = getPostID();

            if (!url) { toast("✗ No image URL"); return; }
            if (!postId) { toast("✗ No post ID"); return; }

            const added = await enqueueItem(url, tags, postId);
            if (added) {
                tryBecomeWorker();

                if (ui.autoCloseEnabled) {
                    waitForAutoClose(postId);
                }
            }
        };

        DOM.autoCloseBtn.onclick = () => {
            ui.autoCloseEnabled = !ui.autoCloseEnabled;
            GM_setValue(KEYS.AUTO_CLOSE, ui.autoCloseEnabled);
            updateAutoCloseBtn();
            toast(ui.autoCloseEnabled ? "Auto-close ON" : "Auto-close OFF");

            if (DOM.saveBtn) {
                if (ui.autoCloseEnabled) {
                    DOM.saveBtn.textContent = "Save+Close";
                    DOM.saveBtn.title = "Alt+Z — add to queue & close tab";
                } else {
                    DOM.saveBtn.textContent = "Save";
                    DOM.saveBtn.title = "Alt+Z — add to queue";
                }
            }

            saveUIState();
        };

        DOM.parentBtn.onclick = async () => {
            const id = getPostID();
            if (!id) { toast("✗ No post ID"); return; }

            GM_setValue(KEYS.PARENT_ID, id);
            updateColors();
            toast("Parent set: " + id);

            const url = getOriginalImageURL();
            const tags = getTags();
            if (!url) { toast("✗ No image URL"); return; }

            const added = await enqueueItem(url, tags, id);
            if (added) {
                tryBecomeWorker();

                if (ui.autoCloseEnabled) {
                    waitForAutoClose(id);
                }
            }
        };

        DOM.stopBtn.onclick = () => {
            GM_deleteValue(KEYS.PARENT_ID);
            resignWorker();
            clearQueue();
            updateColors();
            toast("Parent cleared, worker stopped & queue cleared");
        };
    }

    function setupHotkeys() {
        document.addEventListener("keydown", (e) => {
            if (!e.altKey) return;

            if (e.code === "KeyZ") {
                DOM.saveBtn?.click();
                e.preventDefault();
            }
            if (e.code === "KeyX") {
                DOM.parentBtn?.click();
                e.preventDefault();
            }
            if (e.code === "KeyC") {
                DOM.stopBtn?.click();
                e.preventDefault();
            }
            if (e.code === "KeyQ") {
                clearQueue();
                e.preventDefault();
            }
            if (e.code === "KeyS") {
                if (ui.autoCloseEnabled) {
                    DOM.saveBtn?.click();
                } else {
                    DOM.autoCloseBtn?.click();
                }
                e.preventDefault();
            }
        });
    }

    function showSettingsModal(callback) {
        if (document.getElementById("eagle-settings-modal")) return;

        const overlay = document.createElement("div");
        overlay.id = "eagle-settings-modal";
        overlay.style.cssText = "position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.7);z-index:999999;display:flex;align-items:center;justify-content:center;";

        const modal = document.createElement("div");
        modal.style.cssText = "background:#1e1e1e;color:#e0e0e0;padding:24px;border-radius:12px;width:400px;max-width:90%;font-family:'Segoe UI',Tahoma,sans-serif;box-shadow:0 8px 32px rgba(0,0,0,0.5);";

        modal.innerHTML = `
            <h3 style="margin:0 0 20px 0;font-size:18px;font-weight:600;">⚙️ Eagle Saver Settings</h3>
            <div style="margin-bottom:16px;">
                <label style="display:block;margin-bottom:6px;font-size:13px;color:#aaa;">API Token</label>
                <input id="eagle-settings-token" type="text" value="${GM_getValue(KEYS.API_TOKEN, CONFIG.DEFAULT_TOKEN)}"
                    style="width:100%;padding:8px 12px;background:#2d2d2d;border:1px solid #444;border-radius:6px;color:#e0e0e0;font-size:13px;box-sizing:border-box;"
                    placeholder="Your Eagle API token">
            </div>
            <div style="margin-bottom:20px;">
                <label style="display:block;margin-bottom:6px;font-size:13px;color:#aaa;">Port</label>
                <input id="eagle-settings-port" type="text" value="${GM_getValue(KEYS.API_PORT, CONFIG.DEFAULT_PORT)}"
                    style="width:100%;padding:8px 12px;background:#2d2d2d;border:1px solid #444;border-radius:6px;color:#e0e0e0;font-size:13px;box-sizing:border-box;"
                    placeholder="41595">
            </div>
            <div style="display:flex;gap:8px;justify-content:flex-end;align-items:center;">
                <button id="eagle-settings-reset-key"
                    style="padding:8px 16px;background:#f44336;color:white;border:none;border-radius:6px;cursor:pointer;font-size:13px;">Reset Token</button>
                <div style="flex:1;"></div>
                <button id="eagle-settings-cancel"
                    style="padding:8px 16px;background:#444;color:#e0e0e0;border:none;border-radius:6px;cursor:pointer;font-size:13px;">Cancel</button>
                <button id="eagle-settings-save"
                    style="padding:8px 16px;background:#4caf50;color:white;border:none;border-radius:6px;cursor:pointer;font-size:13px;font-weight:bold;">Save</button>
            </div>
        `;

        overlay.appendChild(modal);
        document.body.appendChild(overlay);
        setTimeout(() => document.getElementById("eagle-settings-token").focus(), CONFIG.DEBOUNCE_RESET_DELAY);

        const handleEnter = (e) => {
            if (e.key === "Enter") document.getElementById("eagle-settings-save").click();
        };
        document.getElementById("eagle-settings-token").addEventListener("keydown", handleEnter);
        document.getElementById("eagle-settings-port").addEventListener("keydown", handleEnter);

        document.getElementById("eagle-settings-reset-key").onclick = () => {
            GM_deleteValue(KEYS.API_TOKEN);
            GM_deleteValue(KEYS.SETTINGS_DISMISSED);
            document.getElementById("eagle-settings-token").value = "";
            document.getElementById("eagle-settings-token").placeholder = "Token removed";
            document.getElementById("eagle-settings-token").focus();
            toast("⚠️ Token cleared");
        };

        document.getElementById("eagle-settings-cancel").onclick = () => {
            GM_setValue(KEYS.SETTINGS_DISMISSED, true);
            overlay.remove();
            if (callback) callback(false);
        };

        document.getElementById("eagle-settings-save").onclick = () => {
            const token = document.getElementById("eagle-settings-token").value.trim();
            const port = document.getElementById("eagle-settings-port").value.trim();

            if (!token || !port) {
                alert("Token and port are required!");
                return;
            }

            GM_setValue(KEYS.API_TOKEN, token);
            GM_setValue(KEYS.API_PORT, port);
            GM_setValue(KEYS.SETTINGS_DISMISSED, true);
            overlay.remove();

            if (callback) callback(true);
        };

        overlay.onclick = (e) => {
            if (e.target === overlay) {
                document.getElementById("eagle-settings-cancel").click();
            }
        };
    }

    function checkSettings() {
        const token = GM_getValue(KEYS.API_TOKEN, null);
        const port = GM_getValue(KEYS.API_PORT, null);
        const dismissed = GM_getValue(KEYS.SETTINGS_DISMISSED, false);

        if ((!token || !port) && !dismissed) {
            showSettingsModal((saved) => {
                if (saved) setTimeout(() => toast("Settings saved!"), CONFIG.SYNC_APPLY_DELAY);
            });
        }
    }

    function isRule34() { return location.hostname.includes("rule34"); }
    function isDanbooru() { return location.hostname.includes("danbooru"); }
    function isSankaku() { return location.hostname.includes("sankaku"); }
    function isGelbooru() { return location.hostname.includes("gelbooru"); }
    function isKonachan() { return location.hostname.includes("konachan"); }

    function isPostPage() {
        return getPostID() !== null;
    }

    function getPostID() {
        if (isRule34() || isGelbooru()) {
            return new URLSearchParams(location.search).get("id");
        }
        if (isDanbooru()) {
            const m = location.pathname.match(/posts\/(\d+)/);
            return m ? m[1] : null;
        }
        if (isSankaku()) {
            const m = location.pathname.match(/posts\/(\w+)/);
            return m ? m[1] : null;
        }
        if (isKonachan()) {
            const m = location.pathname.match(/post\/show\/(\d+)/);
            return m ? m[1] : null;
        }
        return null;
    }

    function getOriginalImageURL() {
        if (isRule34() || isGelbooru()) {
            let origLink = Array.from(document.querySelectorAll('li a, div a')).find(a =>
                /original image/i.test(a.textContent) ||
                (a.parentElement && /original image/i.test(a.parentElement.textContent))
            );
            if (origLink) return origLink.href;

            let img = document.querySelector("#image");
            if (img) {
                if (img.parentElement && img.parentElement.tagName === 'A') {
                    return img.parentElement.href;
                }
                return img.src;
            }
        }
        if (isDanbooru()) {
            let dl = document.querySelector("#post-option-download a, #image-download-link");
            if (dl) return dl.href;

            let img = document.querySelector("#image");
            if (img) return img.src;
        }

        if (isSankaku()) {
            let highres = document.querySelector('#highres');
            if (highres) {
                let url = highres.href;
                if (url.startsWith("./") || url.startsWith("/")) {
                    let img = document.querySelector("#image");
                    if (img) return img.src;
                }
                return url;
            }
            let img = document.querySelector("#image");
            if (img) return img.src;
        }
        if (isKonachan()) {
            let png = document.querySelector('a.png');
            if (png) return png.href;

            let highres = document.querySelector('#highres, #highres-show a');
            if (highres) return highres.href;

            let img = document.querySelector("#image");
            if (img) return img.src;
        }
        return null;
    }

    function getImageResolution() {
        if (isSankaku()) {
            const stats = document.querySelector('#stats');
            if (stats) {
                const m = stats.textContent.match(/Original:\s*(\d+)\s*[x×]\s*(\d+)/i);
                if (m) return `[${m[1]}x${m[2]}]`;
            }
        }
        if (isDanbooru()) {
            const sizeLi = Array.from(document.querySelectorAll('#post-information li'))
                .find(li => li.textContent.includes('Size'));
            if (sizeLi) {
                const m = sizeLi.textContent.match(/(\d+)\s*[x×]\s*(\d+)/);
                if (m) return `[${m[1]}x${m[2]}]`;
            }
        }
        if (isRule34() || isGelbooru() || isKonachan()) {
            const stats = document.querySelector('#stats, .sidebar, #tag-sidebar');
            if (stats) {
                const m = stats.textContent.match(/Size:\s*(\d+)\s*[x×]\s*(\d+)/i) ||
                          stats.textContent.match(/(\d+)\s*[x×]\s*(\d+)/);
                if (m) return `[${m[1]}x${m[2]}]`;
            }
        }
        const img = document.querySelector("#image");
        if (img && img.naturalWidth && img.naturalWidth > 500) {
            return `[${img.naturalWidth}x${img.naturalHeight}]`;
        }
        return "[Res: ?]";
    }

    function cleanTag(tag) {
        return tag.trim().replace(/_/g, " ").toLowerCase();
    }

    function getTags() {
        const tags = [];
        const artists = [];
        const seenArtists = new Set();
        const seenTags = new Set();

        const addArtist = (t) => {
            if (t && t !== "?" && !seenArtists.has(t)) {
                seenArtists.add(t);
                artists.push("artist:" + t);
            }
        };
        const addTag = (t) => {
            if (t && t !== "?" && !seenTags.has(t)) {
                seenTags.add(t);
                tags.push(t);
            }
        };

        if (isRule34()) {
            document.querySelectorAll("#tag-sidebar li a").forEach(a => {
                const t = cleanTag(a.textContent);
                const li = a.parentElement;
                const isArtist = li && li.classList.contains("tag-type-artist");
                if (isArtist) addArtist(t);
                else addTag(t);
            });
        }
        if (isDanbooru()) {
            document.querySelectorAll(".artist-tag-list li a").forEach(a => addArtist(cleanTag(a.textContent)));
            document.querySelectorAll(".general-tag-list li a, .character-tag-list li a, .copyright-tag-list li a, .meta-tag-list li a").forEach(a => addTag(cleanTag(a.textContent)));
        }
        if (isSankaku()) {
            document.querySelectorAll('#tag-sidebar li a').forEach(a => {
                const t = cleanTag(a.textContent);
                const li = a.parentElement;
                const isArtist = li && (
                    li.dataset.tagType === "artist" ||
                    li.classList.contains("tag-type-artist")
                );
                if (isArtist) addArtist(t);
                else addTag(t);
            });
        }
        if (isGelbooru() || isKonachan()) {
            document.querySelectorAll("li a").forEach(a => {
                const t = cleanTag(a.textContent);
                const li = a.parentElement;
                const type = li ? li.className.match(/tag-type-(\w+)/) : null;

                if (type && type[1] === "artist") addArtist(t);
                else if (type && ["general", "character", "copyright"].includes(type[1])) addTag(t);
                else addTag(t);
            });
        }

        const parentID = GM_getValue(KEYS.PARENT_ID, null);
        if (parentID) {
            tags.push("parent:" + parentID);
        }

        return [...new Set([...artists, ...tags])];
    }

    function init() {
        log('info', `Initializing v2.0.3, tab: ${TAB_ID}`);

        createPanel();

        bindButtons();
        setupHotkeys();

        setupValueChangeListeners();

        startWorkerRecoveryCheck();

        checkSettings();

        const queue = getQueue();
        if (queue.length > 0) {
            log('info', `Queue has ${queue.length} items, trying to become worker`);
            tryBecomeWorker();
        }

        let lastUrl = location.href;
        const urlObserver = new MutationObserver(() => {
            if (location.href !== lastUrl) {
                lastUrl = location.href;
                updatePostPageUI();
                if (DOM.resInfo) {
                    DOM.resInfo.textContent = getImageResolution();
                }
            }
        });
        urlObserver.observe(document.body, { childList: true, subtree: true });

        document.addEventListener('visibilitychange', () => {
            if (document.visibilityState === 'visible') {
                log('info', 'Tab became visible — refreshing UI state');

                try {
                    const uiStateRaw = GM_getValue(KEYS.UI_STATE, null);
                    if (uiStateRaw) {
                        const uiState = JSON.parse(uiStateRaw);
                        applyUIState(uiState);
                    }
                } catch (e) {
                    log('warn', 'visibilitychange: UI_STATE parse error', e);
                }

                updatePostPageUI();
                updateQueueUI();
                updateWorkerIndicator();
                if (DOM.resInfo) {
                    const onPost = isPostPage();
                    const resolution = getImageResolution();
                    DOM.resInfo.textContent = onPost ? resolution : "[not a post page]";
                }
            }
        });

        window.addEventListener('focus', () => {
            log('info', 'Window focused — refreshing queue and worker info');
            updateQueueUI();
            updateWorkerIndicator();
        });
    }

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

    if (typeof GM_registerMenuCommand === "function") {
        GM_registerMenuCommand("Eagle Saver Settings", () => {
            showSettingsModal((saved) => {
                if (saved) toast("Settings saved!");
            });
        });
    }

    window.addEventListener('beforeunload', () => {
        log('info', 'Tab closing, cleaning up');

        stopHeartbeat();
        stopWorkerLoop();
        if (autoClose.timer) clearTimeout(autoClose.timer);
        if (progress.interval) clearInterval(progress.interval);

        if (worker.isWorker) {
            const active = getActiveWorker();
            if (active && active.tabId === TAB_ID) {
                GM_setValue(KEYS.WORKER, null);
                log('info', 'Worker status cleared');
            }
        }
    });

})();