您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Adds buttons to Debrid Media Manager for applying Trash Guide regex patterns.
当前为
// ==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); } }); })();