// ==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);
}
});
})();