DMM - Add Trash Guide Regex Buttons

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

נכון ליום 31-08-2025. ראה הגרסה האחרונה.

// ==UserScript==
// @name          DMM - Add Trash Guide Regex Buttons
// @version       2.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@af0d3fcda72bded872240b37fac343160cc6dfd1/libs/dmm/button-data.min.js
// @require       https://cdn.jsdelivr.net/gh/StylusThemes/Userscripts@5f2cbff53b0158ca07c86917994df0ed349eb96c/libs/gm/gmcompat.js
// @grant         GM.getValue
// @grant         GM.setValue
// @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';

  /* ===========================
      Configuration
      - selectors, timeouts and shared CSS class prefix
      =========================== */
  const CONFIG = {
    CONTAINER_SELECTOR: '.mb-2',
    RELEVANT_PAGE_RX: /debridmediamanager\.com\/(movie|show)\/[^\/]+/,
    DEBOUNCE_MS: 120,
    MAX_RETRIES: 20,
    CSS_CLASS_PREFIX: 'dmm-tg',
  };

  // external data expected to be an array on window.DMM_BUTTON_DATA
  const BUTTON_DATA = Array.isArray(window?.DMM_BUTTON_DATA) ? window.DMM_BUTTON_DATA : [];

  /* ===========================
      Quality tokens
      Map of checkbox key -> display name and one-or-more token strings.
      Selected keys are persisted in GM storage and rendered as a single
      parenthesized group like "(1080p|4k|2160p)" appended to the input.
      =========================== */
  // quality tokens map: { key, name, values[] }
  const QUALITY_TOKENS = [
    { key: '720p', name: '720p', values: ['720p'] },
    { key: '1080p', name: '1080p', values: ['1080p'] },
    { key: '4k', name: '4k', values: ['4k', '2160p'] },
    { key: 'dv', name: 'Dolby Vision', values: ['dovi', 'dv', 'dolby', 'vision'] },
    { key: 'x264', name: 'x264', values: ['[xh][\\s._-]?264'] },
    { key: 'x265', name: 'x265', values: ['[xh][\\s._-]?265', '\\bHEVC\\b'] },
    { key: 'hdr', name: 'HDR', values: ['hdr'] },
    { key: 'remux', name: 'Remux', values: ['remux'] }
  ];

  const STORAGE_KEY = 'dmm_tg_selected_qualities';

  /* ===========================
      Small DOM & input helpers
      - qs/qsa: query helpers
      - isVisible: small visibility check
      - setInputValueReactive: set an input value and emit events so frameworks pick it up
      =========================== */
  const qs = (sel, root = document) => root.querySelector(sel);
  const qsa = (sel, root = document) => Array.from(root.querySelectorAll(sel));
  const isVisible = el => !!(el && el.offsetParent !== null && getComputedStyle(el).visibility !== 'hidden');

  const getNativeValueSetter = (el) => {
    const proto = el instanceof HTMLInputElement ? HTMLInputElement.prototype : HTMLTextAreaElement.prototype;
    const desc = Object.getOwnPropertyDescriptor(proto, 'value');
    return desc && desc.set;
  };

  const setInputValueReactive = (el, value) => {
    const nativeSetter = getNativeValueSetter(el);
    if (nativeSetter) {
      nativeSetter.call(el, value);
    } else {
      el.value = value;
    }
    // focus + move cursor to end
    try {
      el.focus();
    } catch (e) {}
    try {
      if (typeof el.setSelectionRange === 'function') el.setSelectionRange(value.length, value.length);
    } catch (e) {}

    // events
    el.dispatchEvent(new Event('input', {
      bubbles: true
    }));
    el.dispatchEvent(new Event('change', {
      bubbles: true
    }));
    // React internals fallback
    try {
      if (el._valueTracker && typeof el._valueTracker.setValue === 'function') {
        el._valueTracker.setValue(value);
      }
    } catch (e) {}
  };

  // debounce helper
  const debounce = (fn, ms) => {
    let t;
    return (...args) => {
      clearTimeout(t);
      t = setTimeout(() => fn(...args), ms);
    };
  };

  /* ===========================
      Inject styles for buttons, menus and the quality bar
      =========================== */
  (function injectStyles() {
    const p = CONFIG.CSS_CLASS_PREFIX;
    const css = `
      /* Button style aligned with site's small chips/buttons */
      .${p}-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;}
      .${p}-btn:hover{background:rgba(59,130,246,.08);}
      .${p}-btn:focus{outline:2px solid rgba(59,130,246,.18);outline-offset:2px;}
      .${p}-chev{width:12px;height:12px;color:rgba(226,240,255,.95);margin-left:.15rem;display:inline-block;transition:transform 160ms ease;transform-origin:center;}
      .${p}-btn[aria-expanded="true"] .${p}-chev{transform:rotate(180deg);}
      .${p}-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;}
      .${p}-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;}
      .${p}-item{padding:.45rem .75rem;cursor:pointer;font-size:13px;white-space:nowrap;border-bottom:1px solid rgba(255,255,255,.03);}
      .${p}-item:last-child{border-bottom:none;}
      .${p}-item:hover{background:#1f2937;}
      /* quality toggle area */
      .${p}-quality{display:flex;flex-wrap:wrap;gap:.35rem;padding:.35rem .5rem;border-top:1px solid rgba(255,255,255,.03);}
      .${p}-quality .${p}-qual{display:inline-flex;align-items:center;gap:.4rem;padding:.25rem .4rem;background:transparent;border-radius:.25rem;cursor:pointer;font-size:12px;color:rgba(226,240,255,.9);}
      .${p}-qual input{width:14px;height:14px;vertical-align:middle}
      .${p}-qual:hover{background:rgba(255,255,255,.02)}
      /* wrapper layout */
      .${p}-container{display:flex;align-items:center;justify-content:space-between;gap:0.75rem;flex-wrap:wrap}
      .${p}-container > button{margin:0.25rem 0}
      .${p}-buttons{display:flex;align-items:center;gap:0.5rem;flex-wrap:wrap}
      .${p}-quality{margin-left:auto}
      @media (max-width:720px){
        .${p}-container{flex-direction:column;align-items:stretch}
        .${p}-quality{margin-left:0}
      }
    `;
    const style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);
  })();

  /* ===========================
      ButtonManager
      Creates pattern buttons + dropdowns, a single shared quality bar, and
      wires all interaction logic (positioning, apply/remove tokens, sync).
      =========================== */
  class ButtonManager {
    constructor() {
      /** Map<string, {button:HTMLElement, menu:HTMLElement}> */
      this.dropdowns = new Map();
      this.container = null;
      this.openMenu = null;
      this.qualityBar = null;
      this.wrapper = null;
      this.documentClickHandler = this.onDocumentClick.bind(this);
      this.resizeHandler = this.onWindowResize.bind(this);
      this.keydownHandler = this.onDocumentKeydown.bind(this);
    }

    cleanup() {
      // remove elements & listeners
      for (const { button, menu } of this.dropdowns.values()) {
        button.remove();
        menu.remove();
      }
      this.dropdowns.clear();
      this.container = null;
      this.openMenu = null;
      if (this.qualityBar) {
        this.qualityBar.remove();
        this.qualityBar = null;
      }
      if (this.wrapper) {
        this.wrapper.remove();
        this.wrapper = null;
      }
      document.removeEventListener('click', this.documentClickHandler, true);
      document.removeEventListener('keydown', this.keydownHandler);
      window.removeEventListener('resize', this.resizeHandler);
    }

    initialize(container) {
      if (!container || this.container === container) return;
      this.cleanup();
      this.container = container;

      BUTTON_DATA.forEach(spec => {
        const name = String(spec.name || 'Pattern');
        if (this.dropdowns.has(name)) return;
        const btn = this._createButton(name);
        const menu = this._createMenu(spec.buttonData || [], name);
        document.body.appendChild(menu);
        // ensure wrapper exists and append buttons into it
        if (!this.wrapper) {
          const w = document.createElement('div');
          w.className = `${CONFIG.CSS_CLASS_PREFIX}-container`;
          // inner left button group
          const btns = document.createElement('div');
          btns.className = `${CONFIG.CSS_CLASS_PREFIX}-buttons`;
          w.appendChild(btns);
          this.wrapper = w;
          try {
            this.container.appendChild(this.wrapper);
          } catch (e) {
            document.body.appendChild(this.wrapper);
          }
        }
        // append into the left button group
        const btnGroup = this.wrapper.querySelector(`.${CONFIG.CSS_CLASS_PREFIX}-buttons`) || this.wrapper;
        btnGroup.appendChild(btn);
        this.dropdowns.set(name, {
          button: btn,
          menu
        });
        // attach button click
        btn.addEventListener('click', (ev) => {
          ev.stopPropagation();
          this.toggleMenu(name);
        });
      });

      // build a single shared quality checkbox bar; state is loaded from GM storage
      if (!this.qualityBar) {
        const qualWrap = document.createElement('div');
        qualWrap.className = `${CONFIG.CSS_CLASS_PREFIX}-quality`;
        // create checkboxes, then load stored selection
        QUALITY_TOKENS.forEach(tok => {
          const lab = document.createElement('label');
          lab.className = `${CONFIG.CSS_CLASS_PREFIX}-qual`;
          const cb = document.createElement('input');
          cb.type = 'checkbox';
          cb.dataset.key = tok.key;
          cb.addEventListener('change', (ev) => {
            ev.stopPropagation();
            // update stored selection
            try {
              GMC.getValue(STORAGE_KEY, []).then(cur => {
                const ks = new Set(Array.isArray(cur) ? cur : []);
                if (cb.checked) ks.add(tok.key);
                else ks.delete(tok.key);
                const updated = Array.from(ks);
                GMC.setValue(STORAGE_KEY, updated).catch(() => {});
                // apply group to current input
                this.applySelectedQualityToInput();
              }).catch(() => {});
            } catch (e) {}
          });
          const span = document.createElement('span');
          span.textContent = tok.name;
          lab.appendChild(cb);
          lab.appendChild(span);
          qualWrap.appendChild(lab);
        });
        this.qualityBar = qualWrap;
        try {
          if (this.wrapper) this.wrapper.appendChild(this.qualityBar);
          else this.container.appendChild(this.qualityBar);
        } catch (e) {
          document.body.appendChild(this.qualityBar);
        }
        // async: load stored selection, set initial checkbox states, then apply
        try {
          GMC.getValue(STORAGE_KEY, []).then(stored => {
            try {
              const boxes = this.qualityBar.querySelectorAll(`.${CONFIG.CSS_CLASS_PREFIX}-qual input`);
              boxes.forEach(b => {
                const k = String(b.dataset.key || '');
                b.checked = Array.isArray(stored) && stored.includes(k);
              });
            } catch (e) {}
            this.applySelectedQualityToInput();
          }).catch(() => {});
        } catch (e) {}
      }

      // global handlers for closing
      document.addEventListener('click', this.documentClickHandler, true);
      document.addEventListener('keydown', this.keydownHandler);
      window.addEventListener('resize', this.resizeHandler);
    }

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

    _createButton(name) {
      const btn = document.createElement('button');
      btn.type = 'button';
      btn.className = `${CONFIG.CSS_CLASS_PREFIX}-btn`;
      // label text node for accessibility
      const label = document.createTextNode(name);
      btn.appendChild(label);
      // append small chevron svg for dropdown affordance
      const svgNs = 'http://www.w3.org/2000/svg';
      const chev = document.createElementNS(svgNs, 'svg');
      chev.setAttribute('viewBox', '0 0 20 20');
      chev.setAttribute('aria-hidden', 'true');
      chev.setAttribute('class', `${CONFIG.CSS_CLASS_PREFIX}-chev`);
      chev.innerHTML = '<path d="M6 8l4 4 4-4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />';
      btn.appendChild(chev);
      btn.setAttribute('aria-haspopup', 'true');
      btn.setAttribute('aria-expanded', 'false');
      btn.tabIndex = 0;
      return btn;
    }

    _createMenu(items = [], name) {
      const menu = document.createElement('div');
      menu.className = `${CONFIG.CSS_CLASS_PREFIX}-menu`;
      menu.dataset.owner = name;
      items.forEach(it => {
        const item = document.createElement('div');
        item.className = `${CONFIG.CSS_CLASS_PREFIX}-item`;
        item.textContent = it.name || it.value || 'apply';
        item.addEventListener('click', (ev) => {
          ev.stopPropagation();
          this.onSelectPattern(it.value, it.name);
          this.closeOpenMenu();
        });
        menu.appendChild(item);
      });

      // quality toggles are provided as a single shared bar created elsewhere
      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');
        // when opening, sync the shared quality checkboxes with the current input value
        this.syncQualityCheckboxes();
        this.openMenu = menu;
      }
    }

    // Toggle a single token in the visible text input without overwriting other tokens.
    onToggleQualityToken(token, enabled) {
      // find a target text-like input similar to onSelectPattern
      const TEXT_SELECTOR = 'input[type="text"], input[type="search"], input[type="url"], input[type="tel"], input[type="email"], textarea';
      let target = null;
      if (this.container) {
        target = this.container.querySelector(TEXT_SELECTOR);
        if (target && !isVisible(target)) target = null;
      }
      if (!target) {
        const cand = qsa(TEXT_SELECTOR);
        target = cand.find(isVisible) || null;
      }
      if (!target) return;

      try {
        const cur = String(target.value || '').trim();
        const tokens = cur ? cur.split(/\s+/) : [];
        const lower = token.toLowerCase();
        const has = tokens.some(t => t.toLowerCase() === lower);
        if (enabled && !has) {
          tokens.push(token);
        } else if (!enabled && has) {
          const idx = tokens.findIndex(t => t.toLowerCase() === lower);
          if (idx > -1) tokens.splice(idx, 1);
        }
        const next = tokens.join(' ');
        setInputValueReactive(target, next);
      } catch (err) {
        console.error('dmm-tg: failed to toggle quality token', err, { token, enabled });
      }
    }

    // Build the parenthesized pipe-separated group from selected keys and
    // insert/replace it in the visible input. If no keys selected, remove any
    // recognized group.
    async applySelectedQualityToInput() {
      const selectedKeys = Array.isArray(await GMC.getValue(STORAGE_KEY, [])) ? await GMC.getValue(STORAGE_KEY, []) : [];
      // build flat list of token strings
      const vals = [];
      selectedKeys.forEach(k => {
        const found = QUALITY_TOKENS.find(t => t.key === k);
        if (found && Array.isArray(found.values)) found.values.forEach(v => vals.push(v));
      });

      // remove duplicates and normalize order
      const uniq = Array.from(new Set(vals));
      const group = uniq.length ? `(${uniq.join('|')})` : '';

      // find text-like target
      const TEXT_SELECTOR = 'input[type="text"], input[type="search"], input[type="url"], input[type="tel"], input[type="email"], textarea';
      let target = null;
      if (this.container) {
        target = this.container.querySelector(TEXT_SELECTOR);
        if (target && !isVisible(target)) target = null;
      }
      if (!target) {
        const cand = qsa(TEXT_SELECTOR);
        target = cand.find(isVisible) || null;
      }
      if (!target) return;

      try {
        let cur = String(target.value || '').trim();
        // remove any existing parenthesis group that looks like a quality group
        // we'll match the last parenthesized group in the string (common for appended groups)
        const lastParenIdx = cur.lastIndexOf('(');
        if (lastParenIdx > -1) {
          const closing = cur.indexOf(')', lastParenIdx);
          if (closing > lastParenIdx) {
            // extract inner and see if it contains any of known tokens
            const inner = cur.slice(lastParenIdx + 1, closing);
            const innerParts = inner.split(/\|/).map(s => s.trim().toLowerCase()).filter(Boolean);
            const known = QUALITY_TOKENS.flatMap(t => t.values.map(v => v.toLowerCase()));
            const intersects = innerParts.some(p => known.includes(p));
            if (intersects) {
              // remove that group including any whitespace before it
              cur = (cur.slice(0, lastParenIdx).trimEnd());
            }
          }
        }

        // append new group if present
        const next = group ? (cur ? `${cur} ${group}` : group) : cur;
        setInputValueReactive(target, next);
      } catch (err) {
        console.error('dmm-tg: failed to apply quality group', err);
      }
    }

    // Sync checkbox states from GM storage (fallback: parse current input).
    async syncQualityCheckboxes() {
      if (!this.qualityBar) return;
      try {
        const stored = Array.isArray(await GMC.getValue(STORAGE_KEY, [])) ? await GMC.getValue(STORAGE_KEY, []) : null;
        const boxes = this.qualityBar.querySelectorAll(`.${CONFIG.CSS_CLASS_PREFIX}-qual input`);
        if (stored) {
          boxes.forEach(b => {
            const k = String(b.dataset.key || '');
            b.checked = stored.includes(k);
          });
        } else {
          // fallback: parse current input value
          let target = null;
          const TEXT_SELECTOR = 'input[type="text"], input[type="search"], input[type="url"], input[type="tel"], input[type="email"], textarea';
          if (this.container) {
            target = this.container.querySelector(TEXT_SELECTOR);
            if (target && !isVisible(target)) target = null;
          }
          if (!target) {
            const cand = qsa(TEXT_SELECTOR);
            target = cand.find(isVisible) || null;
          }
          const cur = String((target && target.value) || '').toLowerCase().trim();
          boxes.forEach(b => {
            const tok = String(b.dataset.key || '').toLowerCase();
            b.checked = cur.includes(tok);
          });
        }
      } catch (err) {
        // sync failures are non-fatal
      }
    }

    positionMenuUnderButton(menu, button) {
      const rect = button.getBoundingClientRect();
      // Keep menu within viewport horizontally if possible
      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(e) {
      if (!this.openMenu) return;
      const target = e.target;
      // if clicked inside openMenu or the corresponding button, ignore
      const matchingButton = Array.from(this.dropdowns.values()).find(v => v.menu === this.openMenu)?.button;
      if (matchingButton && (matchingButton.contains(target) || this.openMenu.contains(target))) return;
      this.closeOpenMenu();
    }

    onWindowResize() {
      if (!this.openMenu) return;
      // reposition if open
      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) {
      // Try target in container first, then visible text-like input/textarea
      const TEXT_SELECTOR = 'input[type="text"], input[type="search"], input[type="url"], input[type="tel"], input[type="email"], textarea';
      let target = null;
      if (this.container) {
        target = this.container.querySelector(TEXT_SELECTOR);
        if (target && !isVisible(target)) target = null;
      }
      if (!target) {
        const cand = qsa(TEXT_SELECTOR);
        target = cand.find(isVisible) || null;
      }
      if (!target) return;
      try {
        setInputValueReactive(target, value || '');
        // re-append any selected quality tokens as the parenthesized group
        this.applySelectedQualityToInput();
      } catch (err) {
        console.error('dmm-tg: failed to set input value', err, {
          value,
          name
        });
      }
    }
  }

  /* ===========================
      PageManager - watches for SPA navigations / DOM changes
      =========================== */
  class PageManager {
    constructor() {
      this.buttonManager = new ButtonManager();
      this.lastUrl = location.href;
      this.mutationObserver = null;
      this.retry = 0;
      this.debouncedCheck = debounce(this.checkPage.bind(this), CONFIG.DEBOUNCE_MS);
      this.setupHistoryHooks();
      this.setupMutationObserver();
      this.checkPage(); // initial
    }

    setupHistoryHooks() {
      const push = history.pushState;
      const replace = history.replaceState;
      history.pushState = function pushState(...args) {
        push.apply(this, args);
        window.dispatchEvent(new Event('dmm:nav'));
      };
      history.replaceState = function replaceState(...args) {
        replace.apply(this, args);
        window.dispatchEvent(new Event('dmm:nav'));
      };
      window.addEventListener('popstate', () => window.dispatchEvent(new Event('dmm:nav')));
      window.addEventListener('hashchange', () => window.dispatchEvent(new Event('dmm:nav')));
      window.addEventListener('dmm:nav', () => {
        this.buttonManager.cleanup();
        this.debouncedCheck();
      });
    }

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

    checkPage() {
      const url = location.href;
      // Only operate on target pages
      if (!CONFIG.RELEVANT_PAGE_RX.test(url)) {
        this.buttonManager.cleanup();
        this.lastUrl = url;
        return;
      }

      // find container
      const container = qs(CONFIG.CONTAINER_SELECTOR);
      if (!container) {
        // limited retries to catch late-loading DOM (e.g. framework renders)
        if (this.retry < CONFIG.MAX_RETRIES) {
          this.retry++;
          setTimeout(() => this.debouncedCheck(), 150);
        } else {
          this.retry = 0;
        }
        return;
      }
      this.retry = 0;
      // init manager
      this.buttonManager.initialize(container);
      this.lastUrl = url;
    }
  }

  /* ===========================
      Boot
      =========================== */
  function ready(fn) {
    if (document.readyState !== 'loading') fn();
    else document.addEventListener('DOMContentLoaded', fn);
  }

  ready(() => {
    try {
      // only continue if we have patterns to show
      if (!BUTTON_DATA.length) return;
      // start page manager
      new PageManager();
    } catch (err) {
      console.error('dmm-tg boot error', err);
    }
  });
})();