Magnet Link to Real-Debrid

Automatically send magnet links to Real-Debrid

// ==UserScript==
// @name          Magnet Link to Real-Debrid
// @version       2.4.0
// @description   Automatically send magnet links to Real-Debrid
// @author        Journey Over
// @license       MIT
// @match         *://*/*
// @require       https://cdn.jsdelivr.net/gh/StylusThemes/Userscripts@c185c2777d00a6826a8bf3c43bbcdcfeba5a9566/libs/gm/gmcompat.min.js
// @require       https://cdn.jsdelivr.net/gh/StylusThemes/Userscripts@c185c2777d00a6826a8bf3c43bbcdcfeba5a9566/libs/utils/utils.min.js
// @grant         GM.xmlHttpRequest
// @grant         GM.getValue
// @grant         GM.setValue
// @grant         GM.registerMenuCommand
// @connect       api.real-debrid.com
// @icon          https://www.google.com/s2/favicons?sz=64&domain=real-debrid.com
// @homepageURL   https://github.com/StylusThemes/Userscripts
// @namespace https://greasyfork.org/users/32214
// ==/UserScript==

(function() {
  'use strict';

  const logger = Logger('Magnet Link to Real-Debrid', { debug: false });

  /* Constants & Utilities */
  const STORAGE_KEY = 'realDebridConfig';
  const API_BASE = 'https://api.real-debrid.com/rest/1.0';
  const ICON_SRC = 'https://fcdn.real-debrid.com/0830/favicons/favicon.ico';
  const INSERTED_ICON_ATTR = 'data-rd-inserted';
  const DEFAULTS = {
    apiKey: '',
    allowedExtensions: ['mp3', 'm4b', 'mp4', 'mkv', 'cbz', 'cbr'],
    filterKeywords: ['sample', 'bloopers', 'trailer']
  };

  /* Errors */
  class ConfigurationError extends Error {
    constructor(message) {
      super(message);
      this.name = 'ConfigurationError';
    }
  }

  class RealDebridError extends Error {
    constructor(message, statusCode = null) {
      super(message);
      this.name = 'RealDebridError';
      this.statusCode = statusCode;
    }
  }

  /* Config Manager */
  class ConfigManager {
    // Parse stored JSON safely, falling back to null on failure
    static _safeParse(value) {
      if (!value) return null;
      try {
        return typeof value === 'string' ? JSON.parse(value) : value;
      } catch (err) {
        logger.error('Config parse failed, resetting to defaults.', err);
        return null;
      }
    }

    static async getConfig() {
      const stored = await GMC.getValue(STORAGE_KEY);
      const parsed = this._safeParse(stored) || {};
      return {
        ...DEFAULTS,
        ...parsed
      };
    }

    // Persist configuration; API key required
    static async saveConfig(cfg) {
      if (!cfg || !cfg.apiKey) throw new ConfigurationError('API Key is required');
      await GMC.setValue(STORAGE_KEY, JSON.stringify(cfg));
    }

    static validateConfig(cfg) {
      const errors = [];
      if (!cfg || !cfg.apiKey) errors.push('API Key is missing');
      if (!Array.isArray(cfg.allowedExtensions)) errors.push('allowedExtensions must be an array');
      if (!Array.isArray(cfg.filterKeywords)) errors.push('filterKeywords must be an array');
      return errors;
    }
  }

  /* Real-Debrid Service */
  class RealDebridService {
    #apiKey;

    // Cross-tab reservation settings
    static RATE_STORE_KEY = 'realDebrid_rate_counter';
    static RATE_LIMIT = 250; // max requests per 60s
    static RATE_HEADROOM = 5; // leave a small headroom
    static RATE_WINDOW_MS = 60 * 1000;

    static _sleep(ms) { return new Promise(res => setTimeout(res, ms)); }

    // Reserve a request slot across tabs using a simple counter + window stored in GM storage
    static async _reserveRequestSlot() {
      const key = RealDebridService.RATE_STORE_KEY;
      const limit = RealDebridService.RATE_LIMIT - RealDebridService.RATE_HEADROOM;
      const windowMs = RealDebridService.RATE_WINDOW_MS;
      const maxRetries = 8;
      let attempt = 0;
      while (attempt < maxRetries) {
        const now = Date.now();
        let obj = null;
        try {
          const raw = await GMC.getValue(key);
          obj = raw ? JSON.parse(raw) : null;
        } catch (e) {
          obj = null;
        }

        if (!obj || typeof obj !== 'object' || !obj.windowStart || (now - obj.windowStart) >= windowMs) {
          // start a fresh window and take slot 1
          const fresh = { windowStart: now, count: 1 };
          try {
            await GMC.setValue(key, JSON.stringify(fresh));
            return;
          } catch (e) {
            // retry
            attempt += 1;
            await RealDebridService._sleep(40 * attempt);
            continue;
          }
        }

        // existing window
        if ((obj.count || 0) < limit) {
          obj.count = (obj.count || 0) + 1;
          try {
            await GMC.setValue(key, JSON.stringify(obj));
            return;
          } catch (e) {
            attempt += 1;
            await RealDebridService._sleep(40 * attempt);
            continue;
          }
        }

        // window full, wait until it expires
        const earliest = obj.windowStart;
        const waitFor = Math.max(50, windowMs - (now - earliest) + 50);
        logger.warn(`Rate window full (${obj.count}/${RealDebridService.RATE_LIMIT}), waiting ${Math.round(waitFor)}ms`);
        await RealDebridService._sleep(waitFor);
        attempt += 1;
      }
      throw new Error('Failed to reserve request slot');
    }

    constructor(apiKey) {
      if (!apiKey) throw new ConfigurationError('API Key required');
      this.#apiKey = apiKey;
    }

    // Generic request wrapper: handles headers, encoding and JSON parsing/errors
    #request(method, endpoint, data = null) {
      const maxAttempts = 5;
      const baseDelay = 500; // ms
      // Rate reservation keys and limits
      if (!RealDebridService.RATE_STORE_KEY) RealDebridService.RATE_STORE_KEY = 'realDebrid_rate_counter';
      if (!RealDebridService.RATE_LIMIT) RealDebridService.RATE_LIMIT = 250;
      if (!RealDebridService.RATE_HEADROOM) RealDebridService.RATE_HEADROOM = 5; // keep a small headroom
      const attemptRequest = async (attempt) => {
        // Reserve a slot across tabs before making the request to avoid hitting the 1-minute cap
        try {
          await RealDebridService._reserveRequestSlot();
        } catch (err) {
          // reservation failures fallback to proceeding; the request wrapper still handles 429
          logger.error('Request slot reservation failed, proceeding (will rely on backoff)', err);
        }

        return new Promise((resolve, reject) => {
          const url = `${API_BASE}${endpoint}`;
          const payload = data ? new URLSearchParams(data).toString() : null;
          logger.debug('[RealDebridService] request', { method, url, data, attempt });

          GMC.xmlHttpRequest({
            method,
            url,
            headers: {
              Authorization: `Bearer ${this.#apiKey}`,
              Accept: 'application/json',
              'Content-Type': 'application/x-www-form-urlencoded'
            },
            data: payload,
            onload: (resp) => {
              logger.debug('[RealDebridService] response', { status: resp.status });

              if (!resp || typeof resp.status === 'undefined') {
                return reject(new RealDebridError('Invalid API response'));
              }
              if (resp.status < 200 || resp.status >= 300) {
                // handle rate limit specially with retry/backoff
                if (resp.status === 429 && attempt < maxAttempts) {
                  const retryAfter = (() => {
                    try {
                      const parsed = JSON.parse(resp.responseText || '{}');
                      return parsed.retry_after || null;
                    } catch (e) {
                      return null;
                    }
                  })();
                  const jitter = Math.random() * 200;
                  const backoff = retryAfter ? (retryAfter * 1000) : (baseDelay * Math.pow(2, attempt) + jitter);
                  logger.warn(`[RealDebridService] Rate limited (429). Retrying in ${Math.round(backoff)}ms (attempt ${attempt + 1}/${maxAttempts})`);
                  return setTimeout(() => {
                    attemptRequest(attempt + 1).then(resolve).catch(reject);
                  }, backoff);
                }
                const msg = resp.responseText ? resp.responseText : `HTTP ${resp.status}`;
                return reject(new RealDebridError(`API Error: ${msg}`, resp.status));
              }
              if (resp.status === 204 || !resp.responseText) return resolve({});
              try {
                const parsed = JSON.parse(resp.responseText.trim());
                return resolve(parsed);
              } catch (err) {
                logger.error('[RealDebridService] parse error', err);
                return reject(new RealDebridError(`Failed to parse API response: ${err.message}`, resp.status));
              }
            },
            onerror: (err) => {
              logger.error('[RealDebridService] Network request failed', err);
              return reject(new RealDebridError('Network request failed'));
            },
            ontimeout: () => {
              logger.warn('[RealDebridService] Request timed out');
              return reject(new RealDebridError('Request timed out'));
            }
          });
        });
      };

      return attemptRequest(0);
    }

    async addMagnet(magnet) {
      return this.#request('POST', '/torrents/addMagnet', {
        magnet
      });
    }

    async getTorrentInfo(torrentId) {
      return this.#request('GET', `/torrents/info/${torrentId}`);
    }

    async selectFiles(torrentId, filesCsv) {
      return this.#request('POST', `/torrents/selectFiles/${torrentId}`, {
        files: filesCsv
      });
    }

    async getExistingTorrents() {
      // Paginate through all torrents using limit/offset until empty or error
      const all = [];
      const limit = 2500; // page size
      let pageNum = 1;
      while (true) {
        try {
          logger.debug(`[RealDebridService] Fetching torrents page ${pageNum} (limit=${limit})`);
          const page = await this.#request('GET', `/torrents?page=${pageNum}&limit=${limit}`);
          if (!Array.isArray(page) || page.length === 0) {
            logger.warn(`[RealDebridService] No torrents returned for page ${pageNum}`);
            break;
          }
          all.push(...page);
          if (page.length < limit) {
            logger.debug(`[RealDebridService] Last page reached (${pageNum}) with ${page.length} items`);
            break;
          }
          pageNum += 1;
        } catch (err) {
          // If rate limited, propagate so caller can handle backoff; otherwise return what we have
          if (err instanceof RealDebridError && err.statusCode === 429) throw err;
          logger.error('[RealDebridService] Failed to fetch existing torrents page', err);
          break;
        }
      }
      logger.debug(`[RealDebridService] Fetched total ${all.length} existing torrents`);
      return all;
    }
  }

  /* Magnet Processing */
  class MagnetLinkProcessor {
    #config;
    #api;
    #existing = [];

    constructor(config, api) {
      this.#config = config;
      this.#api = api;
    }

    async initialize() {
      try {
        this.#existing = await this.#api.getExistingTorrents();
        logger.debug('[MagnetLinkProcessor] existing torrents', this.#existing);
      } catch (err) {
        logger.error('[MagnetLinkProcessor] Failed to load existing torrents', err);
        this.#existing = [];
      }
    }

    // Extract BTIH (hash) from magnet link
    static parseMagnetHash(magnetLink) {
      if (!magnetLink || typeof magnetLink !== 'string') return null;
      try {
        const qIdx = magnetLink.indexOf('?');
        const qs = qIdx >= 0 ? magnetLink.slice(qIdx + 1) : magnetLink;
        const params = new URLSearchParams(qs);
        const xt = params.get('xt');
        if (xt) {
          const match = xt.match(/urn:btih:([A-Za-z0-9]+)/i);
          if (match) return match[1].toUpperCase();
        }
        const fallback = magnetLink.match(/xt=urn:btih:([A-Za-z0-9]+)/i);
        if (fallback) return fallback[1].toUpperCase();
        return null;
      } catch (err) {
        const m = magnetLink.match(/xt=urn:btih:([A-Za-z0-9]+)/i);
        return m ? m[1].toUpperCase() : null;
      }
    }

    isTorrentExists(hash) {
      if (!hash) return false;
      return Array.isArray(this.#existing) && this.#existing.some(t => (t.hash || '').toUpperCase() === hash);
    }

    // Filter torrent files by allowed extensions and filter keywords (supports regex-like /.../)
    filterFiles(files = []) {
      const allowed = new Set(this.#config.allowedExtensions.map(s => s.trim().toLowerCase()).filter(Boolean));
      const keywords = (this.#config.filterKeywords || []).map(k => k.trim()).filter(Boolean);

      return (files || []).filter(file => {
        const path = (file.path || '').toLowerCase();
        const name = path.split('/').pop() || '';
        const ext = name.includes('.') ? name.split('.').pop() : '';

        if (!allowed.has(ext)) return false;

        for (const kw of keywords) {
          if (!kw) continue;
          if (kw.startsWith('/') && kw.endsWith('/')) {
            try {
              const re = new RegExp(kw.slice(1, -1), 'i');
              if (re.test(path) || re.test(name)) return false;
            } catch (err) {
              // invalid regex: ignore it
            }
          }
          if (path.includes(kw.toLowerCase()) || name.includes(kw.toLowerCase())) return false;
        }
        return true;
      });
    }

    async processMagnetLink(magnetLink) {
      const hash = MagnetLinkProcessor.parseMagnetHash(magnetLink);
      if (!hash) throw new RealDebridError('Invalid magnet link');

      if (this.isTorrentExists(hash)) throw new RealDebridError('Torrent already exists on Real-Debrid');

      const addResult = await this.#api.addMagnet(magnetLink);
      if (!addResult || typeof addResult.id === 'undefined') {
        throw new RealDebridError('Failed to add magnet');
      }
      const torrentId = addResult.id;

      const info = await this.#api.getTorrentInfo(torrentId);
      const files = Array.isArray(info.files) ? info.files : [];

      const chosen = this.filterFiles(files).map(f => f.id);
      if (!chosen.length) throw new RealDebridError('No matching files found after filtering');

      await this.#api.selectFiles(torrentId, chosen.join(','));
      return chosen.length;
    }
  }

  /* UI Manager */
  class UIManager {
    // Build and return modal dialog DOM. Caller must append it to document.
    static createConfigDialog(currentConfig) {
      const dialog = document.createElement('div');
      dialog.innerHTML = `
          <div style="position:fixed;inset:0;background:rgba(0,0,0,0.8);display:flex;align-items:center;justify-content:center;z-index:10000;font-family:Inter, system-ui, -apple-system, 'Segoe UI', Roboto, Arial;">
            <div role="dialog" aria-modal="true" style="background:#0f1724;color:#e6eef3;padding:24px;border-radius:12px;max-width:560px;width:94%;box-shadow:0 8px 30px rgba(2,6,23,0.6);border:1px solid rgba(255,255,255,0.04);">
              <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:18px;">
                <h2 style="margin:0;font-size:18px;color:#7dd3fc;">Real-Debrid Settings</h2>
                <button id="cancelBtnTop" aria-label="Close" style="background:transparent;border:none;color:#9fb7c8;cursor:pointer;font-size:18px;">✕</button>
              </div>

              <div style="display:grid;grid-template-columns:1fr;gap:12px;">
                <label style="font-weight:600;color:#cfeeff;">API Key
                  <input type="text" id="apiKey" placeholder="Enter your Real-Debrid API Key" value="${currentConfig.apiKey}"
                    style="width:100%;margin-top:6px;padding:10px;border-radius:8px;border:1px solid rgba(125,211,252,0.12);background:#051229;color:#e6eef3;font-size:13px;" />
                </label>

                <label style="font-weight:600;color:#cfeeff;">Allowed Extensions
                  <textarea id="extensions" placeholder="mp4,mkv,avi" style="width:100%;margin-top:6px;padding:10px;border-radius:8px;border:1px solid rgba(125,211,252,0.12);background:#051229;color:#e6eef3;font-size:13px;min-height:84px;">${currentConfig.allowedExtensions.join(',')}</textarea>
                  <small style="color:#96c5d8;display:block;margin-top:6px;">Comma-separated (e.g., mp4,mkv,avi)</small>
                </label>

                <label style="font-weight:600;color:#cfeeff;">Filter Keywords
                  <textarea id="keywords" placeholder="sample,/trailer/" style="width:100%;margin-top:6px;padding:10px;border-radius:8px;border:1px solid rgba(125,211,252,0.12);background:#051229;color:#e6eef3;font-size:13px;min-height:84px;">${currentConfig.filterKeywords.join(',')}</textarea>
                  <small style="color:#96c5d8;display:block;margin-top:6px;">Keywords or regex-like entries (comma-separated)</small>
                </label>
              </div>

              <div style="display:flex;justify-content:flex-end;gap:10px;margin-top:18px;">
                <button id="saveBtn" style="background:#06b6d4;color:#04202a;border:none;padding:10px 16px;border-radius:8px;cursor:pointer;font-weight:700;">Save</button>
                <button id="cancelBtn" style="background:transparent;color:#9fb7c8;border:1px solid rgba(159,183,200,0.08);padding:10px 16px;border-radius:8px;cursor:pointer;">Cancel</button>
              </div>
            </div>
          </div>
      `;

      const saveBtn = dialog.querySelector('#saveBtn');
      const cancelBtn = dialog.querySelector('#cancelBtn');

      saveBtn.addEventListener('mouseover', () => saveBtn.style.background = '#2980b9');
      saveBtn.addEventListener('mouseout', () => saveBtn.style.background = '#4db6ac');

      cancelBtn.addEventListener('mouseover', () => cancelBtn.style.background = '#c0392b');
      cancelBtn.addEventListener('mouseout', () => cancelBtn.style.background = '#e57373');

      // ESC key handler: remove dialog on Escape
      const escHandler = (e) => {
        if (e.key === 'Escape') {
          if (dialog.parentNode) dialog.parentNode.removeChild(dialog);
          document.removeEventListener('keydown', escHandler);
        }
      };
      document.addEventListener('keydown', escHandler);
      dialog._escHandler = escHandler;

      return dialog;
    }

    static showToast(message, type = 'info') {
      const colors = {
        success: '#16a34a',
        error: '#dc2626',
        info: '#2563eb'
      };
      const msgDiv = document.createElement('div');
      Object.assign(msgDiv.style, {
        position: 'fixed',
        bottom: '20px',
        left: '20px',
        backgroundColor: colors[type] || colors.info,
        color: 'white',
        padding: '10px 14px',
        borderRadius: '8px',
        zIndex: 10000,
        fontWeight: '600'
      });
      msgDiv.textContent = message;
      document.body.appendChild(msgDiv);
      setTimeout(() => msgDiv.remove(), 8000);
    }

    static createMagnetIcon() {
      const icon = document.createElement('img');
      icon.src = ICON_SRC;
      icon.style.cursor = 'pointer';
      icon.style.width = '16px';
      icon.style.marginLeft = '5px';
      icon.setAttribute(INSERTED_ICON_ATTR, '1');
      return icon;
    }
  }

  /* Page Integration: find magnet links & insert icons (one icon per unique magnet) */
  class PageIntegrator {
    constructor(processor = null) {
      this.processor = processor;
      this.observer = null;
      this.configPromise = ConfigManager.getConfig();
      this.keyToIcon = new Map();
      this._populateFromDOM();
    }

    setProcessor(processor) {
      this.processor = processor;
    }

    _populateFromDOM() {
      const links = Array.from(document.querySelectorAll('a[href^="magnet:"]'));
      links.forEach(link => {
        const next = link.nextElementSibling;
        if (next?.getAttribute && next.getAttribute(INSERTED_ICON_ATTR)) {
          const key = this._magnetKeyFor(link.href);
          if (key && !this.keyToIcon.has(key)) {
            this.keyToIcon.set(key, next);
          }
        }
      });
    }


    _magnetKeyFor(href) {
      const hash = MagnetLinkProcessor.parseMagnetHash(href);
      if (hash) return `hash:${hash}`;
      try {
        return `href:${href.trim().toLowerCase()}`;
      } catch {
        return `href:${String(href).trim().toLowerCase()}`;
      }
    }

    _markIconAsExisting(icon, type) {
      icon.title = type === 'existing' ? 'Already on Real-Debrid' : 'Added to Real-Debrid';
      icon.style.filter = 'grayscale(100%)';
      icon.style.opacity = '0.65';
    }

    // Attach click behavior to the icon: lazily initializes API and processes magnet
    _attach(icon, link) {
      const processMagnet = async () => {
        const key = this._magnetKeyFor(link.href);
        const ok = await ensureApiInitialized();

        if (!ok) {
          UIManager.showToast('Real-Debrid API key not configured. Use the menu to set it.', 'info');
          return;
        }

        if (key?.startsWith('hash:') && this.processor?.isTorrentExists(key.split(':')[1])) {
          UIManager.showToast('Torrent already exists on Real-Debrid', 'info');
          this._markIconAsExisting(icon, 'existing');
          return;
        }

        try {
          const count = await this.processor.processMagnetLink(link.href);
          UIManager.showToast(`Added to Real-Debrid — ${count} file(s) selected`, 'success');
          this._markIconAsExisting(icon, 'added');
        } catch (err) {
          UIManager.showToast(err?.message || 'Failed to process magnet', 'error');
          logger.error(err);
        }
      };

      icon.addEventListener('click', (ev) => {
        ev.preventDefault();
        processMagnet();
      });
    }


    addIconsTo(documentRoot = document) {
      const links = Array.from(documentRoot.querySelectorAll('a[href^="magnet:"]'));
      const newlyAddedKeys = [];
      links.forEach(link => {
        if (!link.parentNode) return;
        const next = link.nextElementSibling;
        if (next && next.getAttribute && next.getAttribute(INSERTED_ICON_ATTR)) return;

        const key = this._magnetKeyFor(link.href);
        if (key && this.keyToIcon.has(key)) return;

        const icon = UIManager.createMagnetIcon();
        this._attach(icon, link);
        link.parentNode.insertBefore(icon, link.nextSibling);
        const storeKey = key || `href:${link.href.trim().toLowerCase()}`;
        this.keyToIcon.set(storeKey, icon);
        newlyAddedKeys.push(storeKey);
      });

      if (newlyAddedKeys.length) {
        ensureApiInitialized().then(ok => {
          if (ok) this.markExistingTorrents();
        });
      }
    }

    markExistingTorrents() {
      if (!this.processor) return;

      for (const [key, icon] of this.keyToIcon.entries()) {
        if (!key.startsWith('hash:')) continue;
        const hash = key.split(':')[1];
        if (this.processor.isTorrentExists(hash)) {
          this._markIconAsExisting(icon, 'existing');
        }
      }
    }


    startObserving() {
      if (this.observer) return;
      this.observer = new MutationObserver(debounce((mutations) => {
        for (const m of mutations) {
          if (m.addedNodes && m.addedNodes.length) {
            this.addIconsTo(document);
            break;
          }
        }
      }, 150));
      this.observer.observe(document.body, {
        childList: true,
        subtree: true
      });
    }

    stopObserving() {
      if (!this.observer) return;
      this.observer.disconnect();
      this.observer = null;
    }
  }

  /* Lazy API initialization - only when needed; cached promise so it runs once */
  let _apiInitPromise = null;
  let _realDebridService = null;
  let _magnetProcessor = null;
  let _integratorInstance = null;

  async function ensureApiInitialized() {
    if (_apiInitPromise) return _apiInitPromise;
    // Do not initialize API if page doesn't contain magnet links
    try {
      if (!document.querySelector || !document.querySelector('a[href^="magnet:"]')) {
        return Promise.resolve(false);
      }
    } catch (err) {
      // If DOM access fails, continue with init to be safe
    }

    const cfg = await ConfigManager.getConfig();
    if (!cfg.apiKey) {
      return Promise.resolve(false);
    }

    try {
      _realDebridService = new RealDebridService(cfg.apiKey);
    } catch (err) {
      logger.warn('RealDebridService not created:', err);
      return Promise.resolve(false);
    }

    _magnetProcessor = new MagnetLinkProcessor(cfg, _realDebridService);
    _apiInitPromise = _magnetProcessor.initialize()
      .then(() => {
        if (_integratorInstance) {
          _integratorInstance.setProcessor(_magnetProcessor);
          _integratorInstance.markExistingTorrents();
        }
        return true;
      })
      .catch(err => {
        logger.warn('Failed to initialize Real-Debrid integration', err);
        return false;
      });

    return _apiInitPromise;
  }

  /* Initialization & Menu */
  async function init() {
    try {
      _integratorInstance = new PageIntegrator(null);
      _integratorInstance.addIconsTo();
      _integratorInstance.startObserving();

      GMC.registerMenuCommand('Configure Real-Debrid Settings', async () => {
        const currentCfg = await ConfigManager.getConfig();
        const dialog = UIManager.createConfigDialog(currentCfg);
        document.body.appendChild(dialog);

        const saveBtn = dialog.querySelector('#saveBtn');
        const cancelBtn = dialog.querySelector('#cancelBtn');

        saveBtn.addEventListener('click', async () => {
          const newCfg = {
            apiKey: dialog.querySelector('#apiKey').value.trim(),
            allowedExtensions: dialog.querySelector('#extensions').value.split(',').map(e => e.trim()).filter(Boolean),
            filterKeywords: dialog.querySelector('#keywords').value.split(',').map(k => k.trim()).filter(Boolean)
          };
          try {
            await ConfigManager.saveConfig(newCfg);
            if (dialog.parentNode) document.body.removeChild(dialog);
            if (dialog._escHandler) document.removeEventListener('keydown', dialog._escHandler);
            UIManager.showToast('Configuration saved successfully!', 'success');
            location.reload();
          } catch (error) {
            UIManager.showToast(error.message, 'error');
          }
        });

        const cancelTop = dialog.querySelector('#cancelBtnTop');
        const doClose = () => {
          if (dialog.parentNode) document.body.removeChild(dialog);
          if (dialog._escHandler) document.removeEventListener('keydown', dialog._escHandler);
        };

        cancelBtn.addEventListener('click', doClose);
        if (cancelTop) cancelTop.addEventListener('click', doClose);

        const apiInput = dialog.querySelector('#apiKey');
        if (apiInput) apiInput.focus();
      });
    } catch (err) {
      logger.error('Initialization failed:', err);
    }
  }

  // Run immediately
  init();

})();