Empornium, Bunkr, SimpCity (prefill + focus), and Coomer (with OnlyFans/Fansly username detection) performer search for Stash. Handles SPA + hard-refresh fallback and a minimal settings panel.
// ==UserScript== // @name Stash Universal Performer Search // @version 1.0.13 // @description Empornium, Bunkr, SimpCity (prefill + focus), and Coomer (with OnlyFans/Fansly username detection) performer search for Stash. Handles SPA + hard-refresh fallback and a minimal settings panel. // @license MIT // @author [email protected] // @match http*://*/performers/* // @run-at document-idle // @grant none // @namespace https://stashapp.example/rob // ==/UserScript== (function () { 'use strict'; /* ---------- SimpCity Prefill Helper ---------- */ if ( location.hostname.includes('simpcity.cr') && location.pathname.startsWith('/search') && new URLSearchParams(location.search).has('sus_auto') ) { window.addEventListener('DOMContentLoaded', () => { const params = new URLSearchParams(location.search); const qParam = params.get('q') || ''; if (!qParam) return; const term = decodeURIComponent(qParam.replace(/\+/g, ' ')); let attempts = 0; const tryFill = () => { const input = document.querySelector('input[name="q"]'); if (input) { input.value = term; input.focus(); } else if (attempts < 20) { attempts++; setTimeout(tryFill, 250); } }; tryFill(); }); return; } /* ---------- Settings ---------- */ const LS_KEY = 'stash-universal-search:settings'; const DEFAULT_SETTINGS = { openInNewTabs: true, enabledSites: ['empornium', 'bunkr_archive', 'bunkr_albums', 'simpcity', 'coomer'], }; const loadSettings = () => { try { return Object.assign({}, DEFAULT_SETTINGS, JSON.parse(localStorage.getItem(LS_KEY) || '{}')); } catch { return { ...DEFAULT_SETTINGS }; } }; const saveSettings = (s) => localStorage.setItem(LS_KEY, JSON.stringify(s)); let SETTINGS = loadSettings(); /* ---------- Site Definitions ---------- */ const SITES = { empornium: { label: 'Empornium', tooltip: 'Empornium: taglist uses firstname.lastname (lowercase); mononyms plain.', buildUrls: (name) => { const parts = name.trim().split(/\s+/); const tag = parts.length === 1 ? parts[0].toLowerCase() : `${parts[0].toLowerCase()}.${parts.pop().toLowerCase()}`; return [`https://www.empornium.sx/torrents.php?&taglist=${encodeURIComponent(tag)}`]; }, }, bunkr_archive: { label: 'Bunkr Archive', tooltip: 'Bunkr Archive: spaces become + (literal, not encoded).', buildUrls: (name) => { const q = name.trim().replace(/\s+/g, '+'); return [`https://bunkrarchive.pythonanywhere.com/search?q=${q}`]; }, }, bunkr_albums: { label: 'Bunkr Albums', tooltip: 'Bunkr-Albums.io: uses %20 for spaces.', buildUrls: (name) => { const q = name.trim().replace(/\s+/g, '%20'); return [`https://bunkr-albums.io/?search=${q}`]; }, }, simpcity: { label: 'SimpCity', tooltip: 'SimpCity: opens search page (title-only), prefilled and focused. MUST HIT ENTER OR CLICK SEARCH.', buildUrls: (name) => [ `https://simpcity.cr/search/?t=post&c[title_only]=1&q=${encodeURIComponent(name)}&sus_auto=1`, ], }, // --- Coomer with OnlyFans/Fansly username extraction --- coomer: { label: 'Coomer', tooltip: 'Coomer: prefers OnlyFans/Fansly handle if performer URLs contain one.', buildUrls: (name, _aliases, _inc, _cap, performer) => { const extractHandle = (urls) => { if (!urls) return null; const all = Array.isArray(urls) ? urls.join('\n') : String(urls); const m = all.match(/(?:onlyfans\.com|fansly\.com)\/([A-Za-z0-9._-]+)/i); return m ? m[1] : null; }; const handle = performer?.urls ? extractHandle(performer.urls) : null; let term; if (handle) { term = handle; console.log(`[SUS] Using ${handle} from performer URLs for Coomer search`); } else { term = name.trim().replace(/\s+/g, ''); } return [`https://coomer.st/artists?q=${encodeURIComponent(term)}`]; }, }, }; /* ---------- GraphQL ---------- */ const gql = async (query, variables) => { const res = await fetch('/graphql', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query, variables }), credentials: 'same-origin', }); const j = await res.json(); if (j.errors) throw new Error(JSON.stringify(j.errors)); return j.data; }; const fetchPerformer = async (id) => { const q = ` query FindPerformer($id: ID!) { findPerformer(id: $id) { id name alias_list urls } }`; const d = await gql(q, { id }); return d?.findPerformer || null; }; /* ---------- Helpers ---------- */ const isPerf = () => /\/performers\/\d+/.test(location.pathname); const getPerfId = () => location.pathname.match(/\/performers\/(\d+)/)?.[1]; const addStyles = () => { if (document.getElementById('sus-styles')) return; const css = ` .sus-group{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px} .sus-btn{border:1px solid #4b5563;background:#1f2937;color:#e5e7eb; padding:6px 10px;border-radius:6px;font-size:12px;cursor:pointer} .sus-btn:hover{filter:brightness(1.15)} #sus-panel{position:fixed;top:20px;right:20px;z-index:99999; background:#111827;color:#e5e7eb;border:1px solid #6b7280; border-radius:8px;padding:12px;max-width:360px;} #sus-panel input[type="checkbox"]{accent-color:#93c5fd;} #sus-panel label{display:block;margin:4px 0;} #sus-panel button{margin-top:6px;} `; const s = document.createElement('style'); s.id = 'sus-styles'; s.textContent = css; document.head.appendChild(s); }; const openUrls = (urls) => { urls.forEach((u, i) => setTimeout(() => window.open(u, SETTINGS.openInNewTabs ? '_blank' : '_self'), i * 150) ); }; /* ---------- Settings Panel ---------- */ function showSettingsPanel() { const existing = document.getElementById('sus-panel'); if (existing) existing.remove(); const p = document.createElement('div'); p.id = 'sus-panel'; const sites = Object.keys(SITES); p.innerHTML = ` <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;"> <b>Universal Search Settings</b> <span style="cursor:pointer;" id="sus-close">✕</span> </div> <label><input type="checkbox" id="sus-tabs" ${SETTINGS.openInNewTabs ? 'checked' : ''}> Open results in new tabs</label> <hr> <b>Enabled sites:</b> ${sites.map((k) => `<label><input type="checkbox" data-site="${k}" ${SETTINGS.enabledSites.includes(k) ? 'checked' : ''}> ${SITES[k].label}</label>`).join('')} <div style="text-align:right;"> <button id="sus-save" class="sus-btn">Save</button> </div> `; document.body.appendChild(p); p.querySelector('#sus-close').onclick = () => p.remove(); p.querySelector('#sus-save').onclick = () => { SETTINGS.openInNewTabs = p.querySelector('#sus-tabs').checked; SETTINGS.enabledSites = Array.from(p.querySelectorAll('input[data-site]:checked')) .map((i) => i.getAttribute('data-site')); saveSettings(SETTINGS); p.remove(); alert('Settings saved. Refresh performer page to apply.'); }; } /* ---------- Build Buttons ---------- */ const buildButtons = (perf) => { const anchor = Array.from(document.querySelectorAll('button')) .find((b) => /Edit/i.test(b.textContent || ''))?.parentElement; if (!anchor) return; const group = document.createElement('div'); group.className = 'sus-group'; SETTINGS.enabledSites.forEach((k) => { const site = SITES[k]; if (!site) return; const btn = document.createElement('button'); btn.className = 'sus-btn'; btn.textContent = site.label; btn.title = site.tooltip; btn.onclick = () => { const urls = site.buildUrls(perf.name, perf.alias_list, false, 0, perf); openUrls(urls); }; group.appendChild(btn); }); const gear = document.createElement('button'); gear.className = 'sus-btn'; gear.textContent = '⚙️ Settings'; gear.title = 'Configure Universal Search'; gear.onclick = showSettingsPanel; group.appendChild(gear); anchor.parentElement.insertBefore(group, anchor.nextSibling); }; /* ---------- SPA / Hard Refresh Fallback ---------- */ let lastPath = ''; let isInitializing = false; async function init() { if (isInitializing) return; isInitializing = true; if (!isPerf()) { isInitializing = false; return; } const id = getPerfId(); if (!id) { isInitializing = false; return; } try { const perf = await fetchPerformer(id); if (perf) { if (!document.querySelector('.sus-group')) { addStyles(); buildButtons(perf); } } } catch (e) { console.error('[SUS] GraphQL fail', e); } isInitializing = false; } function waitForPerformerPage() { const editButton = Array.from(document.querySelectorAll('button')).find( (b) => /Edit/i.test(b.textContent || '') ); if (editButton) { init(); } else { setTimeout(waitForPerformerPage, 500); } } waitForPerformerPage(); const observer = new MutationObserver(() => { if (location.pathname !== lastPath) { lastPath = location.pathname; document.querySelectorAll('.sus-group').forEach((e) => e.remove()); waitForPerformerPage(); } }); observer.observe(document, { childList: true, subtree: true }); })();