DMM - Add Trash Guide Regex Buttons

Adds buttons to Debrid Media Manager for applying Trash Guide regex patterns.

Version au 22/10/2025. Voir la dernière version.

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

// ==UserScript==
// @name          DMM - Add Trash Guide Regex Buttons
// @version       3.2.0
// @description   Adds buttons to Debrid Media Manager for applying Trash Guide regex patterns.
// @author        Journey Over
// @license       MIT
// @match         *://debridmediamanager.com/*
// @require       https://cdn.jsdelivr.net/gh/StylusThemes/Userscripts@9db06a14c296ae584e0723cde883729d819e0625/libs/dmm/button-data.min.js
// @require       https://cdn.jsdelivr.net/gh/StylusThemes/Userscripts@9db06a14c296ae584e0723cde883729d819e0625/libs/utils/utils.min.js
// @require       https://cdn.jsdelivr.net/gh/StylusThemes/Userscripts@644b86d55bf5816a4fa2a165bdb011ef7c22dfe1/libs/metadata/armhaglund/armhaglund.min.js
// @grant         GM_getValue
// @grant         GM_setValue
// @grant         GM_xmlhttpRequest
// @icon          https://www.google.com/s2/favicons?sz=64&domain=debridmediamanager.com
// @homepageURL   https://github.com/StylusThemes/Userscripts
// @namespace https://greasyfork.org/users/32214
// ==/UserScript==

(function() {
  'use strict';

  const logger = Logger('DMM - Add Trash Guide Regex Buttons', { debug: false });

  const CONFIG = {
    // Page and DOM selectors
    RELEVANT_PAGE_RX: /debridmediamanager\.com\/(movie|show)\/[^\/]+/, // Pages where buttons should appear
    CONTAINER_SELECTOR: '.mb-2', // CSS selector for button container
    MAX_RETRIES: 20, // Max attempts to find container on SPA pages

    // UI styling
    CSS_CLASS_PREFIX: 'dmm-tg', // Prefix for all CSS classes to avoid conflicts

    // Storage keys
    QUALITY_OPTIONS_KEY: 'dmm-tg-quality-options', // Local storage key for selected quality options
    QUALITY_POLARITY_KEY: 'dmm-tg-quality-polarity', // Storage key for quality polarity (positive/negative)
    LOGIC_MODE_KEY: 'dmm-tg-logic-mode', // Storage key for AND/OR logic mode preference

    // Caching settings
    CACHE_KEY: 'cache',
    CACHE_PREFIX: 'dmm-anime-cache-',
    CACHE_LAST_CLEANUP_KEY: 'cache-last-cleanup',
    CACHE_DURATION: 24 * 60 * 60 * 1000,

    // Regex patterns for quality removal
    REGEX_PATTERNS: {
      AND_LOOKAHEAD: /\^(\(\?[\=!].*?\))+\.\*/,
      OR_ALTERNATION: /\|\([^)]+\)$/,
      QUALITY_GROUP: /^\([^)]+\)$/,
      NEGATIVE_LOOKAHEAD: /^\(\?[\=!].*?\)$/
    },

    // Timing settings
    POLLING_INTERVAL: 100,
    DEBOUNCE_DELAY: 50
  };

  const BUTTON_DATA = Array.isArray(window?.DMM_BUTTON_DATA) ? window.DMM_BUTTON_DATA : [];
  const armhaglund = new ArmHaglund();

  // Quality tokens for building regex patterns - each represents a quality indicator in filenames
  const QUALITY_TOKENS = [
    { key: '720p', name: '720p', values: ['720p'] },
    { key: '1080p', name: '1080p', values: ['1080p'] },
    { key: '4k', name: '4k', values: ['\\b4k\\b', '2160p'] },
    { key: 'dv', name: 'Dolby Vision', values: ['dovi', '\\bdv\\b', 'dolby', 'vision'] },
    { key: 'x264', name: 'x264', values: ['264'] },
    { key: 'x265', name: 'x265', values: ['265', '\\bHEVC\\b'] },
    { key: 'hdr', name: 'HDR', values: ['hdr'] },
    { key: 'remux', name: 'Remux', values: ['remux'] },
    { key: 'atmos', name: 'Atmos', values: ['atmos'] }
  ];

  const allQualityValues = QUALITY_TOKENS.flatMap(token => token.values);

  const getCachedAnimeData = async (imdbId) => {
    const cache = GM_getValue(CONFIG.CACHE_KEY) || {};
    if (typeof cache !== 'object' || Array.isArray(cache)) return null;
    const cacheKey = `${CONFIG.CACHE_PREFIX}${imdbId}`;
    const cached = cache[cacheKey];
    if (cached && (Date.now() - cached.timestamp) < CONFIG.CACHE_DURATION) {
      return cached.data;
    }
    return null;
  };

  const fetchAnimeData = async (imdbId) => {
    try {
      const data = await armhaglund.fetchIds('imdb', imdbId);
      return data && data.anilist ? { isAnime: true, anilistId: data.anilist } : { isAnime: false, anilistId: null };
    } catch (error) {
      logger.debug(`Failed to fetch from ArmHaglund: ${error.message}`);
      return { isAnime: false, anilistId: null };
    }
  };

  const updateCache = async (imdbId, result) => {
    let cache = GM_getValue(CONFIG.CACHE_KEY) || {};
    if (typeof cache !== 'object' || Array.isArray(cache)) cache = {};
    const cacheKey = `${CONFIG.CACHE_PREFIX}${imdbId}`;
    cache[cacheKey] = { data: result, timestamp: Date.now() };

    // Cleanup old entries to prevent cache bloat
    const now = Date.now();
    const lastCleanup = GM_getValue(CONFIG.CACHE_LAST_CLEANUP_KEY) || 0;
    if (now - lastCleanup >= CONFIG.CACHE_DURATION) {
      let cleanedCount = 0;
      for (const [key, entry] of Object.entries(cache)) {
        if (key.startsWith(CONFIG.CACHE_PREFIX) && (now - entry.timestamp) > CONFIG.CACHE_DURATION) {
          delete cache[key];
          cleanedCount++;
        }
      }
      GM_setValue(CONFIG.CACHE_LAST_CLEANUP_KEY, now);
      if (cleanedCount > 0) {
        logger.debug(`Cache cleanup: Removed ${cleanedCount} expired entries`);
      }
    }
    GM_setValue(CONFIG.CACHE_KEY, cache);
  };

  const checkReleasesMoeExists = (anilistId) => {
    return new Promise((resolve) => {
      const apiUrl = `https://releases.moe/api/collections/entries/records?filter=alID=${anilistId}`;

      GM_xmlhttpRequest({
        method: 'GET',
        url: apiUrl,
        onload: (response) => {
          try {
            const data = JSON.parse(response.responseText);
            const exists = data.totalItems > 0;
            logger.debug(`Releases.moe: Anime ${anilistId} ${exists ? 'found' : 'not found'}`);
            resolve(exists);
          } catch (error) {
            logger.error(`Releases.moe API parse error for ${anilistId}:`, error);
            resolve(false);
          }
        },
        onerror: (error) => {
          logger.error(`Releases.moe API request failed for ${anilistId}:`, error);
          resolve(false);
        }
      });
    });
  };

  // Remove quality-related regex patterns while preserving user input
  // Handles both AND mode lookaheads (^.*(?=.*quality)) and OR mode alternations (|quality)
  const removeQualityFromRegex = (regex) => {
    if (!regex || typeof regex !== 'string') return '';

    let cleaned = regex;

    // Remove AND patterns: lookaheads at the beginning (after ^)
    const andMatch = cleaned.match(CONFIG.REGEX_PATTERNS.AND_LOOKAHEAD);
    if (andMatch && andMatch.index === 0) {
      const matched = andMatch[0];
      const hasQuality = allQualityValues.some(qualityValue => matched.includes(qualityValue));
      cleaned = hasQuality ? cleaned.replace(matched, '') : cleaned;
    }

    // Remove OR patterns: alternations at the end
    const orMatch = cleaned.match(CONFIG.REGEX_PATTERNS.OR_ALTERNATION);
    if (orMatch) {
      const matched = orMatch[0];
      const hasQuality = allQualityValues.some(qualityValue => matched.includes(qualityValue));
      if (hasQuality) {
        cleaned = cleaned.replace(matched, '');
      }
    }

    // Clear standalone quality patterns that contain known quality values
    if (cleaned.match(CONFIG.REGEX_PATTERNS.QUALITY_GROUP) || cleaned.match(CONFIG.REGEX_PATTERNS.NEGATIVE_LOOKAHEAD)) {
      const hasQuality = allQualityValues.some(qualityValue => cleaned.includes(qualityValue));
      if (hasQuality) {
        cleaned = '';
      }
    }

    return cleaned.trim();
  };

  // Build quality regex string based on selected options and logic mode
  // AND mode uses lookaheads (?=.*quality), OR mode uses alternations (quality|other)
  const buildQualityString = (selectedOptions, useAndLogic = false, qualityPolarity = new Map()) => {
    if (!selectedOptions.length) return '';

    const tokenValues = [];
    for (const optionKey of selectedOptions) {
      const token = QUALITY_TOKENS.find((qualityToken) => qualityToken.key === optionKey);
      if (token && token.values) tokenValues.push(token.values);
    }

    if (!tokenValues.length) return '';

    if (useAndLogic) {
      // AND logic: Each token uses positive or negative lookaheads based on polarity
      const lookaheads = selectedOptions.map((optionKey, index) => {
        const values = tokenValues[index];
        const isPositive = qualityPolarity.get(optionKey) !== false;
        const lookaheadType = isPositive ? '=' : '!';

        if (values.length === 1) {
          return `(?${lookaheadType}.*${values[0]})`;
        }
        // Multiple values for one token = internal OR with non-capturing group
        return `(?${lookaheadType}.*(?:${values.join('|')}))`;
      }).join('');
      return lookaheads;
    } else {
      // OR logic: Any token can match, flatten all values
      const flat = tokenValues.flat();
      return `(${flat.join('|')})`;
    }
  };

  const generateStyles = () => {
    const prefix = CONFIG.CSS_CLASS_PREFIX;
    return `
      .${prefix}-btn{cursor:pointer;display:inline-flex;align-items:center;gap:.35rem;margin-right:.5rem;padding:.25rem .5rem;font-size:12px;line-height:1;border-radius:.375rem;color:#e6f0ff;background:rgba(15,23,42,.5);border:1px solid rgba(59,130,246,.55);box-shadow:none;user-select:none;white-space:nowrap;}
      .${prefix}-btn:hover{background:rgba(59,130,246,.08);}
      .${prefix}-btn:focus{outline:2px solid rgba(59,130,246,.18);outline-offset:2px;}
      .${prefix}-chev{width:12px;height:12px;color:rgba(226,240,255,.95);margin-left:.15rem;display:inline-block;transition:transform 160ms ease;transform-origin:center;}
      .${prefix}-btn[aria-expanded="true"] .${prefix}-chev{transform:rotate(180deg);}
      .${prefix}-menu{position:absolute;min-width:10rem;background:#111827;color:#fff;border:1px solid rgba(148,163,184,.06);border-radius:.375rem;box-shadow:0 6px 18px rgba(2,6,23,.6);padding:.25rem 0;z-index:9999;display:none;}
      .${prefix}-menu::before{content:"";position:absolute;top:-6px;left:12px;width:0;height:0;border-left:6px solid transparent;border-right:6px solid transparent;border-bottom:6px solid #111827;}
      .${prefix}-item{padding:.45rem .75rem;cursor:pointer;font-size:13px;white-space:nowrap;border-bottom:1px solid rgba(255,255,255,.03);}
      .${prefix}-item:last-child{border-bottom:none;}
      .${prefix}-item:hover{background:#1f2937;}
      .${prefix}-quality-section{display:flex;align-items:center;gap:.75rem;margin-left:.75rem;padding-left:.75rem;border-left:1px solid rgba(148,163,184,.15);}
      .${prefix}-quality-grid{display:flex;flex-wrap:wrap;gap:.6rem;}
      .${prefix}-quality-item{display:inline-flex;align-items:center;font-size:12px;}
      .${prefix}-quality-button{padding:.25rem .5rem;border-radius:.375rem;border:1px solid rgba(148,163,184,.15);background:transparent;color:#e6f0ff;cursor:pointer;font-size:12px;line-height:1}
      .${prefix}-quality-button.active{background:#3b82f6;color:#fff;border-color:#3b82f6}
      .${prefix}-quality-button.active.negative{background:#dc2626;color:#fff;border-color:#dc2626}
      .${prefix}-quality-button:focus{outline:1px solid rgba(59,130,246,.5);}
      .${prefix}-quality-label{color:#e6f0ff;cursor:pointer;white-space:nowrap;}
      .${prefix}-logic-selector{margin-right:.75rem;padding-right:.75rem;border-right:1px solid rgba(148,163,184,.15);display:flex;align-items:center;}
      .${prefix}-logic-toggle{display:inline-flex;border:1px solid rgba(148,163,184,.4);border-radius:.375rem;overflow:hidden;}
      .${prefix}-logic-option{background:#1f2937;color:#e6f0ff;border:none;padding:.25rem .5rem;font-size:12px;cursor:pointer;transition:all 0.2s ease;line-height:1;display:flex;align-items:center;position:relative;}
      .${prefix}-logic-option:hover{background:#374151;}
      .${prefix}-logic-option.active{background:#3b82f6;color:#fff;border-left:1px solid #3b82f6;border-right:1px solid #3b82f6;margin-left:-1px;margin-right:-1px;z-index:1;}
      .${prefix}-logic-option:focus{outline:1px solid rgba(59,130,246,.5);}
      .${prefix}-help-icon{background:#1f2937;color:#e6f0ff;border:1px solid rgba(148,163,184,.4);border-radius:50%;width:16px;height:16px;font-size:11px;cursor:help;margin-left:.25rem;display:inline-flex;align-items:center;justify-content:center;font-weight:bold;}
      .${prefix}-help-icon:hover{background:#374151;}
      /* DMM Fixes below */
      h2.line-clamp-2{display:block!important;-webkit-line-clamp:unset!important;-webkit-box-orient:unset!important;overflow:visible!important;text-overflow:unset!important;white-space:normal!important;} /* show full title without truncation */
      .max-w-2xl{max-width: min-content;} /* media info better fit to width */
    `;
  };

  (function injectStyles() {
    const style = document.createElement('style');
    style.textContent = generateStyles();
    document.head.appendChild(style);
  })();

  class QualityManager {
    constructor() {
      this.state = {
        selectedOptions: [],
        qualityPolarity: new Map(),
        useAndLogic: false
      };
      this.container = null;
      this.buttons = new Map();
      this.logicSelect = null;
    }

    async initialize(container) {
      this.container = container;
      this.createQualitySection();
      await this.loadPersistedSettings();
      this.restoreStates();

      // Auto-apply quality options if any are selected
      if (this.state.selectedOptions.length > 0) {
        setTimeout(() => this.updateInputWithQualityOptions(), 50);
      }
    }

    async loadPersistedSettings() {
      try {
        const stored = GM_getValue(CONFIG.QUALITY_OPTIONS_KEY, null);
        this.state.selectedOptions = stored ? JSON.parse(stored) : [];

        const polarityStored = GM_getValue(CONFIG.QUALITY_POLARITY_KEY, null);
        const polarityData = polarityStored ? JSON.parse(polarityStored) : {};
        this.state.qualityPolarity = new Map(Object.entries(polarityData));

        const logicStored = GM_getValue(CONFIG.LOGIC_MODE_KEY, null);
        this.state.useAndLogic = logicStored ? JSON.parse(logicStored) : false;
      } catch (error) {
        logger.error('Failed to load quality options:', error);
        this.state = { selectedOptions: [], qualityPolarity: new Map(), useAndLogic: false };
      }
    }

    createQualitySection() {
      if (!this.container) return;

      const existing = this.container.querySelector(`.${CONFIG.CSS_CLASS_PREFIX}-quality-section`);
      if (existing) {
        logger.debug('Quality section already exists');
        return;
      }

      const section = document.createElement('div');
      section.className = `${CONFIG.CSS_CLASS_PREFIX}-quality-section`;

      const logicSelector = document.createElement('div');
      logicSelector.className = `${CONFIG.CSS_CLASS_PREFIX}-logic-selector`;

      const logicSelect = document.createElement('div');
      logicSelect.className = `${CONFIG.CSS_CLASS_PREFIX}-logic-toggle`;
      logicSelect.setAttribute('tabindex', '0');
      logicSelect.innerHTML = `
        <button type="button" class="${CONFIG.CSS_CLASS_PREFIX}-logic-option active" data-mode="or">OR</button>
        <button type="button" class="${CONFIG.CSS_CLASS_PREFIX}-logic-option" data-mode="and">AND</button>
      `;
      logicSelect.addEventListener('click', (event_) => this.onLogicToggle(event_));

      const helpIcon = document.createElement('button');
      helpIcon.type = 'button';
      helpIcon.className = `${CONFIG.CSS_CLASS_PREFIX}-help-icon`;
      helpIcon.textContent = '?';
      helpIcon.title = `Logic Modes:\n\nOR Mode: Match ANY selected quality\nExample: (720p|1080p) - matches files with 720p OR 1080p\n\nAND Mode: Match ALL selected qualities (advanced filtering)\n- Requires EVERY selected quality to be present in the filename\n- Useful for precise filtering, e.g., only 1080p remux files\nExample: (?=.*1080p)(?=.*remux) - matches files with BOTH 1080p AND remux\n\nNegative Matching in AND Mode:\n- Click a quality button twice to exclude it\n- Creates a negative lookahead: (?!.*quality)\nExample: (?=.*1080p)(?!.*720p) - requires 1080p but excludes 720p\n\nTip: AND mode is powerful for complex filters but may match fewer files`;

      logicSelector.appendChild(logicSelect);
      logicSelector.appendChild(helpIcon);
      this.logicSelect = logicSelect;

      const grid = document.createElement('div');
      grid.className = `${CONFIG.CSS_CLASS_PREFIX}-quality-grid`;

      for (const token of QUALITY_TOKENS) {
        const item = document.createElement('div');
        item.className = `${CONFIG.CSS_CLASS_PREFIX}-quality-item`;

        const button = document.createElement('button');
        button.type = 'button';
        button.className = `${CONFIG.CSS_CLASS_PREFIX}-quality-button`;
        button.id = `${CONFIG.CSS_CLASS_PREFIX}-${token.key}`;
        button.textContent = token.name;
        button.addEventListener('click', () => this.onToggleOption(token.key, button));

        item.appendChild(button);
        grid.appendChild(item);

        this.buttons.set(token.key, button);
      }

      section.appendChild(logicSelector);
      section.appendChild(grid);
      this.container.appendChild(section);
    }

    restoreStates() {
      for (const key of this.state.selectedOptions) {
        const button = this.buttons.get(key);
        if (button) {
          button.classList.add('active');
          if (this.state.useAndLogic) {
            const isPositive = this.state.qualityPolarity.get(key) !== false;
            if (!isPositive) {
              button.classList.add('negative');
            }
          }
        }
      }

      if (this.logicSelect) {
        const allOptions = this.logicSelect.querySelectorAll(`.${CONFIG.CSS_CLASS_PREFIX}-logic-option`);
        for (const option of allOptions) {
          option.classList.remove('active');
          if ((option.dataset.mode === 'and' && this.state.useAndLogic) ||
            (option.dataset.mode === 'or' && !this.state.useAndLogic)) {
            option.classList.add('active');
          }
        }
      }
    }

    async onLogicToggle(event_) {
      event_.preventDefault();
      event_.stopPropagation();

      const target = event_.target;
      if (!target.classList.contains(`${CONFIG.CSS_CLASS_PREFIX}-logic-option`)) return;

      const mode = target.dataset.mode;
      const useAndLogic = mode === 'and';

      const allOptions = this.logicSelect.querySelectorAll(`.${CONFIG.CSS_CLASS_PREFIX}-logic-option`);
      for (const option of allOptions) option.classList.remove('active');
      target.classList.add('active');

      await this.onLogicChange(useAndLogic);
    }

    async onLogicChange(useAndLogic) {
      // Clean existing patterns before switching modes to prevent regex conflicts
      const target = findTargetInput(this.container);
      if (target) {
        const currentValue = target.value || '';
        const cleanedValue = removeQualityFromRegex(currentValue);
        setInputValueReactive(target, cleanedValue);
      }

      this.state.useAndLogic = useAndLogic;

      // Update button visual states based on new mode
      for (const key of this.state.selectedOptions) {
        const button = this.buttons.get(key);
        if (button) {
          if (useAndLogic) {
            const isPositive = this.state.qualityPolarity.get(key) !== false;
            if (!isPositive) {
              button.classList.add('negative');
            }
          } else {
            button.classList.remove('negative');
          }
        }
      }

      try {
        GM_setValue(CONFIG.LOGIC_MODE_KEY, JSON.stringify(this.state.useAndLogic));
      } catch (error) {
        logger.error('Failed to save logic mode:', error);
      }

      this.updateInputWithQualityOptions();
    }

    // Toggle behavior differs by mode: OR mode (off->on->off), AND mode (off->positive->negative->off)
    onToggleOption(key, button) {
      const isActive = button.classList.contains('active');
      const isNegative = button.classList.contains('negative');

      if (!isActive && !isNegative) {
        this._activateOption(key, button);
      } else if (isActive && !isNegative) {
        if (this.state.useAndLogic) {
          this._makeNegative(key, button);
        } else {
          this._deactivateOption(key, button);
        }
      } else {
        this._deactivateOption(key, button);
      }

      this._saveOptions();
      this.updateInputWithQualityOptions();
    }

    _activateOption(key, button) {
      button.classList.add('active');
      if (!this.state.selectedOptions.includes(key)) {
        this.state.selectedOptions.push(key);
      }
      if (this.state.useAndLogic) {
        this.state.qualityPolarity.set(key, true);
      }
    }

    _makeNegative(key, button) {
      button.classList.add('negative');
      this.state.qualityPolarity.set(key, false);
    }

    _deactivateOption(key, button) {
      button.classList.remove('active');
      button.classList.remove('negative');
      const index = this.state.selectedOptions.indexOf(key);
      if (index > -1) {
        this.state.selectedOptions.splice(index, 1);
      }
      this.state.qualityPolarity.delete(key);
    }

    async _saveOptions() {
      try {
        GM_setValue(CONFIG.QUALITY_OPTIONS_KEY, JSON.stringify(this.state.selectedOptions));
        GM_setValue(CONFIG.QUALITY_POLARITY_KEY, JSON.stringify(Object.fromEntries(this.state.qualityPolarity)));
      } catch (error) {
        logger.error('Failed to save quality options:', error);
      }
    }

    updateInputWithQualityOptions() {
      const target = findTargetInput(this.container);
      if (!target) return;

      const currentValue = target.value || '';
      const qualityString = buildQualityString(this.state.selectedOptions, this.state.useAndLogic, this.state.qualityPolarity);

      let newValue;
      if (qualityString) {
        const cleanedBase = removeQualityFromRegex(currentValue);
        if (this.state.useAndLogic) {
          newValue = cleanedBase ? `^${qualityString}.*${cleanedBase}` : `^${qualityString}.*`;
        } else {
          newValue = cleanedBase ? `${cleanedBase}|${qualityString}` : qualityString;
        }
      } else {
        newValue = removeQualityFromRegex(currentValue);
      }

      setInputValueReactive(target, newValue);
    }

    applyQualityOptionsToValue(baseValue) {
      const qualityString = buildQualityString(this.state.selectedOptions, this.state.useAndLogic, this.state.qualityPolarity);
      if (!qualityString) return baseValue;

      const cleanedBase = removeQualityFromRegex(baseValue);

      if (this.state.useAndLogic) {
        return cleanedBase ? `^${qualityString}.*${cleanedBase}` : `^${qualityString}.*`;
      } else {
        return cleanedBase ? `${cleanedBase}|${qualityString}` : qualityString;
      }
    }

    cleanup() {
      this.buttons.clear();
      this.state.qualityPolarity.clear();
      if (this.container) {
        const existing = this.container?.querySelector(`.${CONFIG.CSS_CLASS_PREFIX}-quality-section`);
        if (existing) existing.remove();
      }
    }
  }

  class ButtonManager {
    constructor() {
      this.dropdowns = new Map();
      this.container = null;
      this.openMenu = null;
      this.qualityManager = new QualityManager();
      this.listenersAttached = false;
      this.cachedTargetInput = null;

      this.documentClickHandler = this.onDocumentClick.bind(this);
      this.resizeHandler = this.onWindowResize.bind(this);
      this.keydownHandler = this.onDocumentKeydown.bind(this);
    }

    cleanup() {
      for (const { button, menu } of this.dropdowns.values()) {
        button.remove();
        menu.remove();
      }
      this.dropdowns.clear();
      this.qualityManager.cleanup();
      this.container = null;
      this.openMenu = null;

      if (this.listenersAttached) {
        document.removeEventListener('click', this.documentClickHandler, true);
        document.removeEventListener('keydown', this.keydownHandler);
        window.removeEventListener('resize', this.resizeHandler);
        this.listenersAttached = false;
      }
    }

    async initialize(container) {
      if (!container) return;
      logger.debug('ButtonManager initialized', { container: !!container, sameContainer: this.container === container });

      // Check if buttons are already present to avoid re-adding during content re-renders
      const existingButtons = container.querySelectorAll(`.${CONFIG.CSS_CLASS_PREFIX}-btn`);
      if (existingButtons.length > 0) {
        logger.debug('Buttons already exist, skipping initialization');
        this.container = container;
        this.cachedContainer = container;
        return;
      }

      this.cleanup();
      this.container = container;
      this.cachedContainer = container;

      for (const spec of BUTTON_DATA) {
        const name = String(spec.name || 'Pattern');
        if (this.dropdowns.has(name)) continue;

        const button = this._createButton(name);
        const menu = this._createMenu(spec.buttonData || [], name);

        document.body.appendChild(menu);
        this.container.appendChild(button);
        this.dropdowns.set(name, { button: button, menu });

        button.addEventListener('click', (event_) => {
          event_.stopPropagation();
          this.toggleMenu(name);
        });
      }

      await this.qualityManager.initialize(container);
      logger.debug('Created dropdown buttons:', { count: this.dropdowns.size });

      await this.detectExternalLinksForCurrentPage();

      if (!this.listenersAttached) {
        document.addEventListener('click', this.documentClickHandler, true);
        document.addEventListener('keydown', this.keydownHandler);
        window.addEventListener('resize', this.resizeHandler);
        this.listenersAttached = true;
      }
    }

    onDocumentKeydown(event_) {
      if (!this.openMenu) return;
      if (event_.key === 'Escape' || event_.key === 'Esc') {
        event_.preventDefault();
        this.closeOpenMenu();
      }
    }

    _createButton(name) {
      const button = document.createElement('button');
      button.type = 'button';
      button.className = `${CONFIG.CSS_CLASS_PREFIX}-btn`;
      button.appendChild(document.createTextNode(name));

      const svgNs = 'http://www.w3.org/2000/svg';
      const chevron = document.createElementNS(svgNs, 'svg');
      chevron.setAttribute('viewBox', '0 0 20 20');
      chevron.setAttribute('aria-hidden', 'true');
      chevron.setAttribute('class', `${CONFIG.CSS_CLASS_PREFIX}-chev`);
      chevron.innerHTML = '<path d="M6 8l4 4 4-4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />';
      button.appendChild(chevron);

      button.setAttribute('aria-haspopup', 'true');
      button.setAttribute('aria-expanded', 'false');
      button.tabIndex = 0;
      return button;
    }

    _createMenu(items = [], name) {
      const menu = document.createElement('div');
      menu.className = `${CONFIG.CSS_CLASS_PREFIX}-menu`;
      menu.dataset.owner = name;

      for (const item of items) {
        const menuItem = document.createElement('div');
        menuItem.className = `${CONFIG.CSS_CLASS_PREFIX}-item`;
        menuItem.textContent = item.name || item.value || 'apply';
        menuItem.addEventListener('click', (event_) => {
          event_.stopPropagation();
          this.onSelectPattern(item.value, item.name);
          this.closeOpenMenu();
        });
        menu.appendChild(menuItem);
      }

      return menu;
    }

    toggleMenu(name) {
      const entry = this.dropdowns.get(name);
      if (!entry) return;
      const { button, menu } = entry;

      if (this.openMenu && this.openMenu !== menu) this.openMenu.style.display = 'none';

      if (menu.style.display === 'block') {
        menu.style.display = 'none';
        button.setAttribute('aria-expanded', 'false');
        this.openMenu = null;
      } else {
        this.positionMenuUnderButton(menu, button);
        menu.style.display = 'block';
        button.setAttribute('aria-expanded', 'true');
        this.openMenu = menu;
      }
    }

    positionMenuUnderButton(menu, button) {
      const rect = button.getBoundingClientRect();
      const left = Math.max(8, rect.left);
      const top = window.scrollY + rect.bottom + 6;
      menu.style.left = `${left}px`;
      menu.style.top = `${top}px`;
    }

    onDocumentClick(event_) {
      if (!this.openMenu) return;
      const target = event_.target;
      const matchingButton = [...this.dropdowns.values()].find((value) => value.menu === this.openMenu)?.button;
      if (matchingButton && (matchingButton.contains(target) || this.openMenu.contains(target))) return;
      this.closeOpenMenu();
    }

    onWindowResize() {
      if (!this.openMenu) return;
      const owner = this.openMenu.dataset.owner;
      const entry = this.dropdowns.get(owner);
      if (entry) this.positionMenuUnderButton(entry.menu, entry.button);
    }

    closeOpenMenu() {
      if (!this.openMenu) return;
      const owner = this.openMenu.dataset.owner;
      const entry = this.dropdowns.get(owner);
      if (entry) entry.button.setAttribute('aria-expanded', 'false');
      this.openMenu.style.display = 'none';
      this.openMenu = null;
    }

    onSelectPattern(value, name) {
      let target = this.cachedTargetInput;
      if (!target || !document.contains(target)) {
        target = findTargetInput(this.cachedContainer || this.container);
        this.cachedTargetInput = target;
      }

      if (!target) {
        logger.error('Could not find target input element:', { name, value });
        return;
      }

      try {
        const finalValue = this.qualityManager.applyQualityOptionsToValue(value || '');
        logger.debug('Applied pattern to input:', { name, value, finalValue, targetId: target.id || null });
        setInputValueReactive(target, finalValue);
      } catch (error) {
        logger.error('Failed to set input value:', error, {
          value,
          name,
          target: target?.id || target?.className || 'unknown'
        });
      }
    }

    async detectExternalLinksForCurrentPage() {
      try {
        const urlMatch = location.pathname.match(/\/(movie|show)\/(tt\d+)/);
        if (!urlMatch) {
          logger.debug('Could not extract IMDB ID from URL:', location.pathname);
          return;
        }
        const mediaType = urlMatch[1]; // 'movie' or 'show'
        const imdbId = urlMatch[2]; // IMDB ID like 'tt0111161'

        this.createTraktButton(imdbId, mediaType);
        await this.detectAnimeForCurrentPage(imdbId);
      } catch (error) {
        logger.error(`External links detection failed for ${location.href}:`, error);
      }
    }

    async detectAnimeForCurrentPage(imdbId) {
      try {
        const cachedData = await getCachedAnimeData(imdbId);
        if (cachedData) {
          logger.debug(`Anime cache hit for ${imdbId}`);
          this.handleAnimeResult(cachedData);
          return;
        }

        logger.debug(`Anime cache miss for ${imdbId}, fetching from APIs`);

        const result = await fetchAnimeData(imdbId);
        if (result.isAnime && result.anilistId) {
          const releasesExists = await checkReleasesMoeExists(result.anilistId);
          const fullResult = { ...result, releasesExists };
          await updateCache(imdbId, fullResult);

          if (releasesExists) {
            this.createReleasesMoeButton(result.anilistId);
          }
        } else {
          const fullResult = { ...result, releasesExists: false };
          await updateCache(imdbId, fullResult);
        }
      } catch (error) {
        logger.error(`Anime detection failed for ${imdbId}:`, error);
        this.handleAnimeResult({ isAnime: false, anilistId: null, releasesExists: false });
      }
    }

    handleAnimeResult(result) {
      const { isAnime, anilistId, releasesExists } = result;
      if (isAnime && anilistId && releasesExists !== false) {
        logger.debug('Anime detected with Releases.moe availability', { anilistId, releasesExists });
        this.createReleasesMoeButton(anilistId);
      } else if (isAnime && anilistId && releasesExists === false) {
        logger.debug('Anime detected but not available on Releases.moe', { anilistId });
      } else if (isAnime && !anilistId) {
        logger.debug('Anime detected but no AniList ID found');
      } else {
        logger.debug('Non-anime content detected');
      }
    }

    createExternalLinkButton({ link, iconUrl, iconAlt, label, className, existingSelector, debugName }) {
      const existingButton = qs(existingSelector);
      if (existingButton) {
        logger.debug(`${debugName} button already exists, skipping creation`);
        return existingButton;
      }

      logger.debug(`Created ${debugName} button:`, { link });
      const button = document.createElement('button');
      button.type = 'button';
      button.className = `${className}`;
      button.setAttribute('data-url', link);
      button.innerHTML = `<b class="flex items-center justify-center"><img src="${iconUrl}" class="mr-1 h-3 w-3" alt="${iconAlt}">${label}</b>`;
      button.addEventListener('click', () => {
        window.open(link, '_blank', 'noopener,noreferrer');
      });

      const buttonContainer = qs('.grid > div:last-child');
      if (buttonContainer) {
        buttonContainer.appendChild(button);
        logger.debug(`${debugName} button added to container`);
        return button;
      } else {
        logger.warn(`${debugName} button container not found`);
        return null;
      }
    }

    createReleasesMoeButton(anilistId) {
      const link = `https://releases.moe/${anilistId}/`;
      return this.createExternalLinkButton({
        link,
        iconUrl: 'https://www.google.com/s2/favicons?sz=64&domain=releases.moe',
        iconAlt: 'SeaDex icon',
        label: 'SeaDex',
        className: 'mb-1 mr-2 mt-0 rounded border-2 border-pink-500 bg-pink-900/30 p-1 text-xs text-pink-100 transition-colors hover:bg-pink-800/50',
        existingSelector: `button[data-url="${link}"]`,
        debugName: 'Releases.moe'
      });
    }

    createTraktButton(imdbId, mediaType) {
      const link = `https://trakt.tv/${mediaType}s/${imdbId}`;
      return this.createExternalLinkButton({
        link,
        iconUrl: 'https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/trakt.svg',
        iconAlt: 'Trakt icon',
        label: 'Trakt',
        className: 'mb-1 mr-2 mt-0 rounded border-2 border-red-500 bg-red-900/30 p-1 text-xs text-red-100 transition-colors hover:bg-red-800/50',
        existingSelector: `button[data-url="${link}"]`,
        debugName: 'Trakt.tv'
      });
    }
  }

  class PageManager {
    constructor() {
      this.buttonManager = new ButtonManager();
      this.lastUrl = location.href;
      this.retry = 0;
      this.mutationObserver = null;
      this.lastProcessedUrl = null;
      this.cachedContainer = null;
      this.pollingInterval = null;
      this.initializedForUrl = null;
      this.initializing = false;

      this.debouncedCheck = debounce(this.checkPage.bind(this), CONFIG.DEBOUNCE_DELAY);

      this.setupNavigationDetection();
      this.setupMutationObserver();
      this.checkPage();
    }

    isRelevantPage(url) {
      return CONFIG.RELEVANT_PAGE_RX.test(url);
    }

    getContainer() {
      let container = this.cachedContainer;
      if (!container || !document.contains(container)) {
        container = qs(CONFIG.CONTAINER_SELECTOR);
        this.cachedContainer = container;
      }
      return container;
    }

    handleRetry() {
      if (this.retry < CONFIG.MAX_RETRIES) {
        this.retry++;
        this.debouncedCheck();
      } else {
        this.retry = 0;
      }
    }

    // Sets up navigation detection using event listeners and polling for SPA navigation
    setupNavigationDetection() {
      window.addEventListener('popstate', () => {
        this.buttonManager.cleanup();
        this.lastProcessedUrl = null;
        this.initializedForUrl = null;
        this.initializing = false;
        this.debouncedCheck();
      });
      window.addEventListener('hashchange', () => {
        this.buttonManager.cleanup();
        this.lastProcessedUrl = null;
        this.initializedForUrl = null;
        this.initializing = false;
        this.debouncedCheck();
      });

      // Poll for URL changes to detect SPA navigation that doesn't trigger events
      this.pollingInterval = setInterval(() => {
        if (location.href !== this.lastUrl) {
          this.buttonManager.cleanup();
          this.lastProcessedUrl = null;
          this.initializedForUrl = null;
          this.initializing = false;
          this.debouncedCheck();
          this.lastUrl = location.href;
        }
      }, CONFIG.POLLING_INTERVAL);
    }

    setupMutationObserver() {
      if (this.mutationObserver) this.mutationObserver.disconnect();
      this.mutationObserver = new MutationObserver((mutations) => {
        for (const mutation of mutations) {
          if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
            this.debouncedCheck();
            break;
          }
        }
      });
      this.mutationObserver.observe(document.body, { childList: true, subtree: true, attributes: false });
    }

    async checkPage() {
      const url = location.href;

      if (this.initializing || this.initializedForUrl === url) return;
      this.initializing = true;

      if (!this.isRelevantPage(url)) {
        this.buttonManager.cleanup();
        this.lastUrl = url;
        this.initializing = false;
        return;
      }

      const container = this.getContainer();
      if (!container) {
        this.handleRetry();
        this.initializing = false;
        return;
      }

      this.retry = 0;

      await this.buttonManager.initialize(container);
      this.initializing = false;
      this.initializedForUrl = url;
      this.lastProcessedUrl = url;
      this.lastUrl = url;
    }

    cleanup() {
      if (this.mutationObserver) {
        this.mutationObserver.disconnect();
        this.mutationObserver = null;
      }
      if (this.pollingInterval) {
        clearInterval(this.pollingInterval);
        this.pollingInterval = null;
      }
      this.buttonManager.cleanup();
    }
  }

  ready(() => {
    try {
      if (!BUTTON_DATA.length) return;
      new PageManager();
    } catch (error) {
      logger.error('Load error:', error);
    }
  });
})();