Booru Enhancer

Modular enhancement suite for Danbooru/Gelbooru/Moebooru-family booru sites — original-quality media, smart downloads, fullscreen viewer, tag tools, blacklist filtering, infinite scroll, reverse image search, and more.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Booru Enhancer
// @namespace    https://sleazyfork.org/en/scripts/587810-booru-enhancer
// @version      1.0.1
// @description  Modular enhancement suite for Danbooru/Gelbooru/Moebooru-family booru sites — original-quality media, smart downloads, fullscreen viewer, tag tools, blacklist filtering, infinite scroll, reverse image search, and more.
// @author       itachi-re
// @license      MIT
// @match        *://danbooru.donmai.us/*
// @match        *://*.donmai.us/*
// @match        *://atfbooru.ninja/*
// @match        *://gelbooru.com/*
// @match        *://safebooru.org/*
// @match        *://rule34.xxx/*
// @match        *://rule34.us/*
// @match        *://realbooru.com/*
// @match        *://tbib.org/*
// @match        *://xbooru.com/*
// @match        *://hypnohub.net/*
// @match        *://konachan.com/*
// @match        *://konachan.net/*
// @match        *://yande.re/*
// @match        *://lolibooru.moe/*
// @match        *://e621.net/*
// @match        *://e926.net/*
// @match        *://chan.sankakucomplex.com/*
// @match        *://idol.sankakucomplex.com/*
// @match        *://beta.sankakucomplex.com/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// @grant        GM_listValues
// @grant        GM_xmlhttpRequest
// @grant        GM_download
// @grant        GM_addStyle
// @grant        GM_registerMenuCommand
// @grant        GM_notification
// @grant        GM_info
// @connect      donmai.us
// @connect      atfbooru.ninja
// @connect      gelbooru.com
// @connect      safebooru.org
// @connect      rule34.xxx
// @connect      rule34.us
// @connect      realbooru.com
// @connect      tbib.org
// @connect      xbooru.com
// @connect      hypnohub.net
// @connect      konachan.com
// @connect      konachan.net
// @connect      yande.re
// @connect      lolibooru.moe
// @connect      e621.net
// @connect      e926.net
// @connect      sankakucomplex.com
// @connect      sankakuapi.com
// @connect      saucenao.com
// @connect      iqdb.org
// @run-at       document-end
// @noframes
// ==/UserScript==

/**
 * =============================================================================
 *  BOORU ENHANCER
 * =============================================================================
 *  A modular, adapter-based enhancement layer for booru-style imageboards.
 *
 *  ARCHITECTURE
 *  ------------
 *  BE.core        - bootstrap, module registry, lifecycle
 *  BE.log         - namespaced logger, toggled by settings.debug
 *  BE.store       - GM_* storage wrapper (sync-cached, async-persisted)
 *  BE.settings    - typed, categorized configuration schema + accessors
 *  BE.bus         - tiny pub/sub event bus so modules never call each other
 *                   directly; this is the seam future features (plugins,
 *                   cloud sync, analytics) hook into without touching core.
 *  BE.dom         - DOM helpers (qs/qsa/create/observe/debounce...)
 *  BE.net         - GM_xmlhttpRequest wrapper returning Promises, with
 *                   per-host rate limiting and retry/backoff
 *  BE.adapters.*  - one adapter per booru "engine family" (Danbooru,
 *                   Gelbooru, Moebooru, e621, generic DOM fallback).
 *                   Adapters normalize every post into a single `Post`
 *                   shape so every downstream module is engine-agnostic.
 *  BE.modules.*   - feature modules (mediaLoader, downloader, viewer,
 *                   tagTools, filters, favorites, infiniteScroll,
 *                   reverseSearch, sourceLinks, keybinds, ui, settingsPanel)
 *
 *  Every module is independent, reads only BE.settings + BE.bus, and can be
 *  disabled without breaking the others. New adapters/modules register
 *  themselves with BE.core.registerAdapter()/registerModule() — nothing
 *  else needs to change to extend the script.
 *
 *  FUTURE EXTENSION POINTS (deliberately NOT implemented, per spec — the
 *  architecture above is designed so these can be dropped in later):
 *    - Cloud sync            -> new module publishing/subscribing on BE.bus,
 *                                swapping BE.store's backend.
 *    - Tag analytics         -> module subscribing to 'post:seen' events.
 *    - ML tag suggestions    -> module hooking 'search:input' events.
 *    - Duplicate finder      -> module comparing md5 across BE.store cache.
 *    - Local download DB     -> BE.store already namespaces keys for this.
 *    - Plugin system         -> BE.core.registerModule() IS the plugin API;
 *                                just needs an external loader (@require).
 *    - Themes / i18n         -> CSS custom properties + a strings table are
 *                                already centralized in BE.ui and BE.settings.
 * =============================================================================
 */
(function booruEnhancer() {
	'use strict';

	if (window.top !== window.self) return; // never run in iframes
	if (window.__BOORU_ENHANCER_LOADED__) return;
	window.__BOORU_ENHANCER_LOADED__ = true;

	const BE = (window.BE = window.BE || {});
	BE.VERSION = '1.0.0';

	/* ============================================================ *
	 *  EVENT BUS
	 * ============================================================ */
	BE.bus = (() => {
		const listeners = new Map();
		return {
			/** @param {string} evt @param {(payload:any)=>void} fn */
			on(evt, fn) {
				if (!listeners.has(evt)) listeners.set(evt, new Set());
				listeners.get(evt).add(fn);
				return () => listeners.get(evt)?.delete(fn);
			},
			once(evt, fn) {
				const off = this.on(evt, (p) => { off(); fn(p); });
				return off;
			},
			/** @param {string} evt @param {any} [payload] */
			emit(evt, payload) {
				const set = listeners.get(evt);
				if (!set) return;
				for (const fn of [...set]) {
					try { fn(payload); } catch (err) { BE.log.error(`[bus:${evt}]`, err); }
				}
			},
		};
	})();

	/* ============================================================ *
	 *  LOGGER
	 * ============================================================ */
	BE.log = (() => {
		const PREFIX = '%c[Booru Enhancer]';
		const STYLE = 'color:#ff8ac6;font-weight:bold';
		const enabled = () => BE.settings?.get('debug.verboseLogging') ?? false;
		return {
			debug: (...a) => enabled() && console.debug(PREFIX, STYLE, ...a),
			info: (...a) => console.info(PREFIX, STYLE, ...a),
			warn: (...a) => console.warn(PREFIX, STYLE, ...a),
			error: (...a) => console.error(PREFIX, STYLE, ...a),
		};
	})();

	/* ============================================================ *
	 *  GM SHIM  (classic GM_* vs. namespaced GM.* — support both so the
	 *  script behaves identically on Violentmonkey/Tampermonkey/ScriptCat)
	 * ============================================================ */
	const _GM = {
		getValue: (typeof GM_getValue === 'function') ? GM_getValue : (k, d) => GM.getValue(k, d),
		setValue: (typeof GM_setValue === 'function') ? GM_setValue : (k, v) => GM.setValue(k, v),
		deleteValue: (typeof GM_deleteValue === 'function') ? GM_deleteValue : (k) => GM.deleteValue(k),
		listValues: (typeof GM_listValues === 'function') ? GM_listValues : () => GM.listValues(),
		xhr: (typeof GM_xmlhttpRequest === 'function') ? GM_xmlhttpRequest : (opts) => GM.xmlHttpRequest(opts),
		download: (typeof GM_download === 'function') ? GM_download : (opts) => GM.download(opts),
		addStyle: (typeof GM_addStyle === 'function') ? GM_addStyle : (css) => {
			const s = document.createElement('style');
			s.textContent = css;
			document.head.appendChild(s);
			return s;
		},
		notification: (typeof GM_notification === 'function') ? GM_notification : (opts) => {
			try { new Notification(opts.title || 'Booru Enhancer', { body: opts.text }); } catch { /* noop */ }
		},
		registerMenuCommand: (typeof GM_registerMenuCommand === 'function') ? GM_registerMenuCommand : () => {},
	};
	BE.gm = _GM;

	/* ============================================================ *
	 *  DOM UTILITIES
	 * ============================================================ */
	BE.dom = {
		qs: (sel, root = document) => root.querySelector(sel),
		qsa: (sel, root = document) => Array.from(root.querySelectorAll(sel)),

		/**
		 * Create an element with attributes/props/children in one call.
		 * @param {string} tag
		 * @param {Object} [attrs]
		 * @param {(Node|string)[]} [children]
		 */
		create(tag, attrs = {}, children = []) {
			const el = document.createElement(tag);
			for (const [k, v] of Object.entries(attrs)) {
				if (k === 'class') el.className = v;
				else if (k === 'html') el.innerHTML = v;
				else if (k === 'text') el.textContent = v;
				else if (k === 'dataset') Object.assign(el.dataset, v);
				else if (k.startsWith('on') && typeof v === 'function') el.addEventListener(k.slice(2).toLowerCase(), v);
				else if (v !== null && v !== undefined) el.setAttribute(k, v);
			}
			for (const c of [].concat(children)) {
				if (c === null || c === undefined) continue;
				el.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
			}
			return el;
		},

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

		throttle(fn, ms = 150) {
			let last = 0, timer = null;
			return (...args) => {
				const now = Date.now();
				if (now - last >= ms) { last = now; fn(...args); }
				else {
					clearTimeout(timer);
					timer = setTimeout(() => { last = Date.now(); fn(...args); }, ms - (now - last));
				}
			};
		},

		/** Runs fn once the DOM is interactive/complete. */
		ready(fn) {
			if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fn, { once: true });
			else fn();
		},

		/** IntersectionObserver-backed lazy visibility callback. */
		onVisible(el, fn, opts = { rootMargin: '600px 0px' }) {
			const io = new IntersectionObserver((entries) => {
				for (const e of entries) if (e.isIntersecting) { fn(el); io.unobserve(el); }
			}, opts);
			io.observe(el);
			return () => io.unobserve(el);
		},

		sanitizeFilename(name, maxLen = 180) {
			return String(name)
				.replace(/[/\\?%*:|"<>]/g, '_')
				.replace(/\s+/g, ' ')
				.trim()
				.slice(0, maxLen) || 'untitled';
		},

		formatBytes(bytes) {
			if (!bytes && bytes !== 0) return '?';
			const units = ['B', 'KB', 'MB', 'GB'];
			let i = 0, n = bytes;
			while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; }
			return `${n.toFixed(n < 10 && i > 0 ? 1 : 0)} ${units[i]}`;
		},
	};

	/* ============================================================ *
	 *  STORAGE  (sync in-memory cache, async persisted via GM_*)
	 * ============================================================ */
	BE.store = (() => {
		const NS = 'be:';
		const cache = new Map();
		let ready = false;
		const readyCallbacks = [];

		async function init() {
			try {
				const keys = await _GM.listValues();
				await Promise.all(keys.filter((k) => k.startsWith(NS)).map(async (k) => {
					const raw = await _GM.getValue(k);
					try { cache.set(k.slice(NS.length), JSON.parse(raw)); } catch { /* ignore corrupt entry */ }
				}));
			} catch (err) { BE.log.error('store init failed', err); }
			ready = true;
			readyCallbacks.splice(0).forEach((cb) => cb());
		}

		return {
			whenReady(cb) { ready ? cb() : readyCallbacks.push(cb); },
			get(key, fallback) { return cache.has(key) ? cache.get(key) : fallback; },
			set(key, value) {
				cache.set(key, value);
				_GM.setValue(NS + key, JSON.stringify(value)).catch((e) => BE.log.error('store.set', e));
			},
			delete(key) {
				cache.delete(key);
				_GM.deleteValue(NS + key).catch(() => {});
			},
			keys(prefix = '') { return [...cache.keys()].filter((k) => k.startsWith(prefix)); },
			_init: init,
		};
	})();
	BE.store._init();

	/* ============================================================ *
	 *  SETTINGS SCHEMA
	 *  Every option declares: category, type, default, label, and an
	 *  optional `choices` array for select-type options. The settings
	 *  panel (BE.modules.settingsPanel) renders itself entirely from
	 *  this schema — adding an option here is enough to expose it in UI.
	 * ============================================================ */
	BE.settings = (() => {
		const SCHEMA = {
			// ---- General ----
			'general.enabled': { cat: 'General', type: 'bool', def: true, label: 'Enable Booru Enhancer on this site' },
			'general.theme': { cat: 'General', type: 'select', def: 'dark', choices: ['dark', 'light'], label: 'Theme' },
			'general.accentColor': { cat: 'General', type: 'color', def: '#ff8ac6', label: 'Accent color' },
			'general.toolbarPosition': { cat: 'General', type: 'select', def: 'bottom-right', choices: ['bottom-right', 'bottom-left', 'top-right', 'top-left'], label: 'Toolbar position' },

			// ---- Media Loader ----
			'media.mode': { cat: 'Media', type: 'select', def: 'post-page', choices: ['always', 'post-page', 'on-click', 'disabled'], label: 'Load original media' },
			'media.neverUpscale': { cat: 'Media', type: 'bool', def: true, label: 'Never upscale beyond native resolution' },
			'media.hoverPreview': { cat: 'Media', type: 'bool', def: true, label: 'Hover preview on thumbnails' },
			'media.thumbQuality': { cat: 'Media', type: 'select', def: 'sample', choices: ['preview', 'sample', 'original'], label: 'Grid thumbnail quality' },

			// ---- Downloads ----
			'download.filenameTemplate': { cat: 'Downloads', type: 'text', def: '{character} - {artist} ({id})', label: 'Filename template' },
			'download.maxCharacters': { cat: 'Downloads', type: 'number', def: 3, min: 1, max: 10, label: 'Max characters in filename' },
			'download.tagDelimiter': { cat: 'Downloads', type: 'text', def: ', ', label: 'Tag delimiter' },
			'download.retries': { cat: 'Downloads', type: 'number', def: 3, min: 0, max: 10, label: 'Retry attempts on failure' },
			'download.openMode': { cat: 'Downloads', type: 'select', def: 'new-tab', choices: ['new-tab', 'background-tab', 'popup', 'browser-viewer'], label: '"Open original" behavior' },

			// ---- Viewer ----
			'viewer.enabled': { cat: 'Viewer', type: 'bool', def: true, label: 'Enable fullscreen viewer' },
			'viewer.autoplayVideo': { cat: 'Viewer', type: 'bool', def: true, label: 'Autoplay videos/animations' },
			'viewer.loopVideo': { cat: 'Viewer', type: 'bool', def: true, label: 'Loop videos' },
			'viewer.muteVideo': { cat: 'Viewer', type: 'bool', def: true, label: 'Start videos muted' },
			'viewer.rememberVolume': { cat: 'Viewer', type: 'bool', def: true, label: 'Remember volume between videos' },
			'viewer.fitMode': { cat: 'Viewer', type: 'select', def: 'fit-both', choices: ['fit-both', 'fit-width', 'fit-height', 'original-size'], label: 'Default fit mode' },

			// ---- Gallery / Infinite Scroll ----
			'gallery.infiniteScroll': { cat: 'Gallery', type: 'bool', def: true, label: 'Infinite scrolling (replaces pagination)' },
			'gallery.gridDensity': { cat: 'Gallery', type: 'range', def: 6, min: 3, max: 10, label: 'Grid columns' },
			'gallery.compactMode': { cat: 'Gallery', type: 'bool', def: false, label: 'Compact mode' },

			// ---- Tags ----
			'tags.colorize': { cat: 'Tags', type: 'bool', def: true, label: 'Colorize tags by category' },
			'tags.showCounts': { cat: 'Tags', type: 'bool', def: true, label: 'Show tag post counts' },
			'tags.collapseThreshold': { cat: 'Tags', type: 'number', def: 25, min: 5, max: 200, label: 'Collapse tag list above N tags' },

			// ---- Filters / Blacklist ----
			'filter.blacklist': { cat: 'Filters', type: 'textarea', def: '', label: 'Blacklisted tags (one per line, supports rating:*, -tag)' },
			'filter.whitelist': { cat: 'Filters', type: 'textarea', def: '', label: 'Whitelist overrides (one per line)' },
			'filter.hideVideos': { cat: 'Filters', type: 'bool', def: false, label: 'Hide videos' },
			'filter.hideAnimations': { cat: 'Filters', type: 'bool', def: false, label: 'Hide animated images/GIFs' },
			'filter.minResolution': { cat: 'Filters', type: 'number', def: 0, min: 0, max: 10000, label: 'Hide posts below width (px), 0 = off' },
			'filter.hideAIGenerated': { cat: 'Filters', type: 'bool', def: false, label: 'Hide posts tagged ai-generated' },

			// ---- Keybinds ----
			'keys.download': { cat: 'Keybinds', type: 'text', def: 'd', label: 'Download' },
			'keys.favorite': { cat: 'Keybinds', type: 'text', def: 'f', label: 'Favorite' },
			'keys.openOriginal': { cat: 'Keybinds', type: 'text', def: 'o', label: 'Open original (new tab)' },
			'keys.viewOriginal': { cat: 'Keybinds', type: 'text', def: 'v', label: 'Toggle viewer' },
			'keys.next': { cat: 'Keybinds', type: 'text', def: 'ArrowRight', label: 'Next post' },
			'keys.prev': { cat: 'Keybinds', type: 'text', def: 'ArrowLeft', label: 'Previous post' },
			'keys.close': { cat: 'Keybinds', type: 'text', def: 'Escape', label: 'Close viewer' },
			'keys.playPause': { cat: 'Keybinds', type: 'text', def: ' ', label: 'Play/pause video' },
			'keys.commandPalette': { cat: 'Keybinds', type: 'text', def: 'k', label: 'Command palette (needs Ctrl)' },

			// ---- Debug ----
			'debug.verboseLogging': { cat: 'Debug', type: 'bool', def: false, label: 'Verbose console logging' },
		};

		const values = new Map();

		function keyDefault(key) { return SCHEMA[key]?.def; }

		function load() {
			for (const key of Object.keys(SCHEMA)) {
				const stored = BE.store.get('setting:' + key);
				values.set(key, stored !== undefined ? stored : keyDefault(key));
			}
		}

		return {
			SCHEMA,
			get(key) { return values.has(key) ? values.get(key) : keyDefault(key); },
			set(key, val) {
				values.set(key, val);
				BE.store.set('setting:' + key, val);
				BE.bus.emit('settings:changed', { key, val });
			},
			reset(key) {
				const def = keyDefault(key);
				this.set(key, def);
				return def;
			},
			resetAll() { for (const key of Object.keys(SCHEMA)) this.reset(key); },
			categories() {
				const set = [];
				for (const def of Object.values(SCHEMA)) if (!set.includes(def.cat)) set.push(def.cat);
				return set;
			},
			byCategory(cat) { return Object.entries(SCHEMA).filter(([, d]) => d.cat === cat).map(([k]) => k); },
			exportJSON() {
				const out = {};
				for (const key of Object.keys(SCHEMA)) out[key] = this.get(key);
				return JSON.stringify({ __booruEnhancerSettings: true, version: BE.VERSION, values: out }, null, 2);
			},
			importJSON(json) {
				try {
					const parsed = JSON.parse(json);
					const values_ = parsed.values || parsed; // tolerate raw {key:val} dumps too
					for (const [key, val] of Object.entries(values_)) {
						if (SCHEMA[key] !== undefined) this.set(key, val);
					}
					return true;
				} catch (err) {
					BE.log.error('settings import failed', err);
					return false;
				}
			},
			blacklistTags() {
				return String(this.get('filter.blacklist')).split('\n').map((s) => s.trim().toLowerCase()).filter(Boolean);
			},
			whitelistTags() {
				return String(this.get('filter.whitelist')).split('\n').map((s) => s.trim().toLowerCase()).filter(Boolean);
			},
			_load: load,
		};
	})();
	BE.settings._load();

	/* ============================================================ *
	 *  NETWORK  (Promise wrapper around GM_xmlhttpRequest w/ retry)
	 * ============================================================ */
	BE.net = {
		/**
		 * @param {Object} opts GM_xmlhttpRequest-compatible options
		 * @param {number} [retries]
		 */
		request(opts, retries = 0) {
			return new Promise((resolve, reject) => {
				const attempt = (n) => {
					_GM.xhr({
						method: 'GET',
						...opts,
						onload: (res) => {
							if (res.status >= 200 && res.status < 300) resolve(res);
							else if (n > 0) setTimeout(() => attempt(n - 1), 400 * (retries - n + 1));
							else reject(new Error(`HTTP ${res.status} for ${opts.url}`));
						},
						onerror: (err) => (n > 0 ? setTimeout(() => attempt(n - 1), 400 * (retries - n + 1)) : reject(err)),
						ontimeout: (err) => (n > 0 ? setTimeout(() => attempt(n - 1), 400 * (retries - n + 1)) : reject(err)),
					});
				};
				attempt(retries);
			});
		},
		async json(url, opts = {}) {
			const res = await this.request({ url, headers: { Accept: 'application/json', ...(opts.headers || {}) }, ...opts }, opts.retries ?? 2);
			return JSON.parse(res.responseText);
		},
	};

	/* ============================================================ *
	 *  POST MODEL
	 *  Every adapter normalizes data into this shape so downstream
	 *  modules never branch on site/engine.
	 * ============================================================ */
	/**
	 * @typedef {Object} Post
	 * @property {string} id
	 * @property {string} originalUrl   - full-resolution file
	 * @property {string} sampleUrl     - resized/compressed version
	 * @property {string} previewUrl    - thumbnail
	 * @property {'image'|'gif'|'video'|'unknown'} mediaType
	 * @property {number} width
	 * @property {number} height
	 * @property {number} fileSize      - bytes, 0 if unknown
	 * @property {string} md5
	 * @property {string} rating        - 'safe'|'questionable'|'explicit'|'unknown'
	 * @property {number} score
	 * @property {number} favCount
	 * @property {string[]} artists
	 * @property {string[]} characters
	 * @property {string[]} copyrights
	 * @property {string[]} generalTags
	 * @property {string[]} metaTags
	 * @property {string[]} allTags
	 * @property {string} source
	 * @property {string} postUrl
	 * @property {string} createdAt
	 * @property {string} siteId        - adapter id that produced this
	 */

	function emptyPost(overrides = {}) {
		return {
			id: '', originalUrl: '', sampleUrl: '', previewUrl: '', mediaType: 'unknown',
			width: 0, height: 0, fileSize: 0, md5: '', rating: 'unknown', score: 0, favCount: 0,
			artists: [], characters: [], copyrights: [], generalTags: [], metaTags: [], allTags: [],
			source: '', postUrl: location.href, createdAt: '', siteId: '', ...overrides,
		};
	}

	function guessMediaType(url) {
		if (!url) return 'unknown';
		const ext = (url.split('?')[0].split('.').pop() || '').toLowerCase();
		if (['mp4', 'webm', 'mov'].includes(ext)) return 'video';
		if (ext === 'gif') return 'gif';
		if (['jpg', 'jpeg', 'png', 'webp', 'bmp'].includes(ext)) return 'image';
		return 'unknown';
	}

	/* ============================================================ *
	 *  ADAPTER REGISTRY
	 * ============================================================ */
	BE.adapters = { registry: [], active: null };

	/**
	 * @param {Object} adapter
	 * @param {string} adapter.id
	 * @param {RegExp} adapter.hostPattern
	 * @param {number} [adapter.priority] higher wins when multiple match
	 */
	BE.core = BE.core || {};
	BE.core.registerAdapter = function registerAdapter(adapter) {
		BE.adapters.registry.push({ priority: 0, ...adapter });
	};

	BE.core.detectAdapter = function detectAdapter() {
		const candidates = BE.adapters.registry
			.filter((a) => a.hostPattern.test(location.hostname))
			.sort((a, b) => b.priority - a.priority);
		return candidates[0] || BE.adapters.registry.find((a) => a.id === 'generic');
	};

	/* ---------------------------------------------------------------
	 * Danbooru-family (danbooru.donmai.us, atfbooru.ninja)
	 * API: /posts/:id.json  and  /posts.json?tags=...&limit=
	 * Docs-stable, so this adapter is almost entirely API-driven.
	 * ------------------------------------------------------------- */
	BE.core.registerAdapter({
		id: 'danbooru',
		hostPattern: /donmai\.us$|atfbooru\.ninja$/,
		priority: 10,
		isPostPage: () => /\/posts\/\d+/.test(location.pathname),
		getPostId: () => (location.pathname.match(/\/posts\/(\d+)/) || [])[1] || null,
		getThumbElements: () => BE.dom.qsa('article[id^="post_"] img, .post-preview img'),
		getThumbPostId: (img) => {
			const art = img.closest('[id^="post_"]') || img.closest('a[href*="/posts/"]');
			const idAttr = art?.id?.match(/post_(\d+)/)?.[1];
			if (idAttr) return idAttr;
			const href = art?.getAttribute?.('href') || img.closest('a')?.getAttribute('href') || '';
			return (href.match(/\/posts\/(\d+)/) || [])[1] || null;
		},
		async fetchPost(id) {
			const data = await BE.net.json(`${location.origin}/posts/${id}.json`);
			return normalizeDanbooru(data);
		},
		async fetchThumbBatch(ids) {
			if (!ids.length) return [];
			const tags = `id:${ids.join(',')}`;
			const data = await BE.net.json(`${location.origin}/posts.json?tags=${encodeURIComponent(tags)}&limit=${ids.length}`);
			return data.map(normalizeDanbooru);
		},
		favoriteSelector: 'a#post-favorite-link, button[data-post-id] .favorite, .post-vote-favorite-count',
		gridContainerSelector: '#posts-container, .posts-container',
	});
	function normalizeDanbooru(d) {
		const original = d.file_url || d.large_file_url || '';
		return emptyPost({
			id: String(d.id),
			originalUrl: original,
			sampleUrl: d.large_file_url || original,
			previewUrl: d.preview_file_url || d.large_file_url || original,
			mediaType: d.is_video ? 'video' : (guessMediaType(original) === 'unknown' ? guessMediaType(d.preview_file_url) : guessMediaType(original)),
			width: d.image_width || 0,
			height: d.image_height || 0,
			fileSize: d.file_size || 0,
			md5: d.md5 || '',
			rating: { s: 'safe', q: 'questionable', e: 'explicit', g: 'safe' }[d.rating] || d.rating || 'unknown',
			score: d.score || 0,
			favCount: d.fav_count || 0,
			artists: (d.tag_string_artist || '').split(' ').filter(Boolean),
			characters: (d.tag_string_character || '').split(' ').filter(Boolean),
			copyrights: (d.tag_string_copyright || '').split(' ').filter(Boolean),
			generalTags: (d.tag_string_general || '').split(' ').filter(Boolean),
			metaTags: (d.tag_string_meta || '').split(' ').filter(Boolean),
			allTags: (d.tag_string || '').split(' ').filter(Boolean),
			source: d.source || '',
			postUrl: `${location.origin}/posts/${d.id}`,
			createdAt: d.created_at || '',
			siteId: 'danbooru',
		});
	}

	/* ---------------------------------------------------------------
	 * Gelbooru-engine family (Gelbooru 0.2 XML/JSON API is widely cloned
	 * by Safebooru, Rule34.xxx, Realbooru, TBIB, Xbooru, HypnoHub).
	 * API: /index.php?page=dapi&s=post&q=index&json=1&id=ID
	 * ------------------------------------------------------------- */
	BE.core.registerAdapter({
		id: 'gelbooru-family',
		hostPattern: /gelbooru\.com$|safebooru\.org$|rule34\.xxx$|realbooru\.com$|tbib\.org$|xbooru\.com$|hypnohub\.net$/,
		priority: 10,
		isPostPage: () => {
			const p = new URLSearchParams(location.search);
			return p.get('page') === 'post' && p.get('s') === 'view';
		},
		getPostId: () => new URLSearchParams(location.search).get('id'),
		getThumbElements: () => BE.dom.qsa('.thumbnail-preview img, span.thumb img, #post-list img.preview'),
		getThumbPostId: (img) => {
			const a = img.closest('a');
			const href = a?.getAttribute('href') || '';
			const m = href.match(/id=(\d+)/);
			return m ? m[1] : (img.id?.match(/\d+/) || [])[0] || null;
		},
		async fetchPost(id) {
			const url = `${location.origin}/index.php?page=dapi&s=post&q=index&json=1&id=${id}`;
			const data = await BE.net.json(url);
			const post = Array.isArray(data) ? data[0] : (data.post ? data.post[0] : null);
			return post ? normalizeGelbooru(post) : null;
		},
		async fetchThumbBatch(ids) {
			if (!ids.length) return [];
			const url = `${location.origin}/index.php?page=dapi&s=post&q=index&json=1&tags=${encodeURIComponent('id:' + ids.join(','))}&limit=${ids.length}`;
			const data = await BE.net.json(url);
			const list = Array.isArray(data) ? data : (data.post || []);
			return list.map(normalizeGelbooru);
		},
		favoriteSelector: 'a[href*="s=favorite"], #favorite-button',
		gridContainerSelector: '.content, #post-list',
	});
	function normalizeGelbooru(d) {
		const original = d.file_url || '';
		return emptyPost({
			id: String(d.id),
			originalUrl: original,
			sampleUrl: d.sample_url || original,
			previewUrl: d.preview_url || d.sample_url || original,
			mediaType: guessMediaType(original),
			width: d.width || 0,
			height: d.height || 0,
			fileSize: d.file_size || 0,
			md5: d.md5 || '',
			rating: d.rating || 'unknown',
			score: Number(d.score) || 0,
			favCount: 0,
			artists: [], characters: [], copyrights: [],
			generalTags: String(d.tags || '').split(' ').filter(Boolean),
			metaTags: [],
			allTags: String(d.tags || '').split(' ').filter(Boolean),
			source: d.source || '',
			postUrl: `${location.origin}/index.php?page=post&s=view&id=${d.id}`,
			createdAt: d.created_at || '',
			siteId: 'gelbooru-family',
		});
	}

	/* ---------------------------------------------------------------
	 * Moebooru-engine family (yande.re, konachan.com/.net, lolibooru.moe)
	 * API: /post.json?tags=id:ID   |  /post.json?limit=N&page=P
	 * ------------------------------------------------------------- */
	BE.core.registerAdapter({
		id: 'moebooru',
		hostPattern: /yande\.re$|konachan\.(com|net)$|lolibooru\.moe$/,
		priority: 10,
		isPostPage: () => /\/post\/show\/\d+/.test(location.pathname),
		getPostId: () => (location.pathname.match(/\/post\/show\/(\d+)/) || [])[1] || null,
		getThumbElements: () => BE.dom.qsa('a.thumb img, ul#post-list-posts img.preview, .post-preview img'),
		getThumbPostId: (img) => {
			const a = img.closest('a');
			const href = a?.getAttribute('href') || '';
			return (href.match(/\/post\/show\/(\d+)/) || [])[1] || null;
		},
		async fetchPost(id) {
			const data = await BE.net.json(`${location.origin}/post.json?tags=id:${id}`);
			return data[0] ? normalizeMoebooru(data[0]) : null;
		},
		async fetchThumbBatch(ids) {
			if (!ids.length) return [];
			const tags = `id:${ids.join(',')}`;
			const data = await BE.net.json(`${location.origin}/post.json?tags=${encodeURIComponent(tags)}&limit=${ids.length}`);
			return data.map(normalizeMoebooru);
		},
		favoriteSelector: 'a.favorite-button-fav, #post-vote-fav-link',
		gridContainerSelector: '#post-list, .content',
	});
	function normalizeMoebooru(d) {
		const original = d.file_url || '';
		return emptyPost({
			id: String(d.id),
			originalUrl: original,
			sampleUrl: d.sample_url || original,
			previewUrl: d.preview_url || original,
			mediaType: guessMediaType(original),
			width: d.width || 0,
			height: d.height || 0,
			fileSize: d.file_size || 0,
			md5: d.md5 || '',
			rating: { s: 'safe', q: 'questionable', e: 'explicit' }[d.rating] || 'unknown',
			score: d.score || 0,
			favCount: d.fav_count || 0,
			artists: [], characters: [], copyrights: [],
			generalTags: String(d.tags || '').split(' ').filter(Boolean),
			metaTags: [],
			allTags: String(d.tags || '').split(' ').filter(Boolean),
			source: d.source || '',
			postUrl: `${location.origin}/post/show/${d.id}`,
			createdAt: d.created_at ? new Date(d.created_at * 1000).toISOString() : '',
			siteId: 'moebooru',
		});
	}

	/* ---------------------------------------------------------------
	 * e621 / e926 — Danbooru-derived but with its own richer JSON shape
	 * and a mandatory descriptive User-Agent for API calls.
	 * ------------------------------------------------------------- */
	BE.core.registerAdapter({
		id: 'e621',
		hostPattern: /e621\.net$|e926\.net$/,
		priority: 10,
		isPostPage: () => /\/posts\/\d+/.test(location.pathname),
		getPostId: () => (location.pathname.match(/\/posts\/(\d+)/) || [])[1] || null,
		getThumbElements: () => BE.dom.qsa('article.post-preview img, #posts-container img'),
		getThumbPostId: (img) => {
			const art = img.closest('article[id^="post_"]');
			return art?.id?.match(/post_(\d+)/)?.[1] || (img.closest('a')?.getAttribute('href')?.match(/\/posts\/(\d+)/) || [])[1] || null;
		},
		async fetchPost(id) {
			const data = await BE.net.json(`${location.origin}/posts/${id}.json`, {
				headers: { 'User-Agent': `BooruEnhancer/${BE.VERSION} (userscript)` },
			});
			return data.post ? normalizeE621(data.post) : null;
		},
		async fetchThumbBatch(ids) {
			if (!ids.length) return [];
			const tags = `id:${ids.join(',')}`;
			const data = await BE.net.json(`${location.origin}/posts.json?tags=${encodeURIComponent(tags)}&limit=${ids.length}`, {
				headers: { 'User-Agent': `BooruEnhancer/${BE.VERSION} (userscript)` },
			});
			return (data.posts || []).map(normalizeE621);
		},
		favoriteSelector: '#add-to-favorites, #remove-from-favorites',
		gridContainerSelector: '#posts-container',
	});
	function normalizeE621(p) {
		const original = p.file?.url || '';
		return emptyPost({
			id: String(p.id),
			originalUrl: original,
			sampleUrl: p.sample?.url || original,
			previewUrl: p.preview?.url || original,
			mediaType: p.file?.ext === 'webm' || p.file?.ext === 'mp4' ? 'video' : (p.file?.ext === 'gif' ? 'gif' : 'image'),
			width: p.file?.width || 0,
			height: p.file?.height || 0,
			fileSize: p.file?.size || 0,
			md5: p.file?.md5 || '',
			rating: { s: 'safe', q: 'questionable', e: 'explicit' }[p.rating] || 'unknown',
			score: p.score?.total ?? 0,
			favCount: p.fav_count || 0,
			artists: p.tags?.artist || [],
			characters: p.tags?.character || [],
			copyrights: p.tags?.copyright || [],
			generalTags: p.tags?.general || [],
			metaTags: p.tags?.meta || [],
			allTags: Object.values(p.tags || {}).flat(),
			source: (p.sources || [])[0] || '',
			postUrl: `${location.origin}/posts/${p.id}`,
			createdAt: p.created_at || '',
			siteId: 'e621',
		});
	}

	/* ---------------------------------------------------------------
	 * Generic DOM-heuristic fallback adapter — used for Sankaku,
	 * rule34.us, and any unlisted booru engine. No stable public API
	 * to rely on, so this reads directly off the rendered page using
	 * a handful of common patterns, degrading gracefully when a given
	 * piece of metadata isn't found.
	 * ------------------------------------------------------------- */
	BE.core.registerAdapter({
		id: 'generic',
		hostPattern: /.*/,
		priority: -1, // always the fallback
		isPostPage: () => !!BE.dom.qs('#image, #main_image, .image-container img, main img.post-image'),
		getPostId: () => (location.pathname.match(/(\d{3,})/) || [])[1] || location.href,
		getThumbElements: () => BE.dom.qsa('img.preview, img.thumb, .thumbnail img, .post-thumbnail img'),
		getThumbPostId: (img) => {
			const a = img.closest('a');
			const href = a?.getAttribute('href') || '';
			return (href.match(/(\d{3,})/) || [])[0] || href || null;
		},
		async fetchPost() {
			// No API — build a best-effort Post straight from the DOM of the
			// current post page. Thumb-grid enrichment simply won't have
			// full metadata on generic sites, which downstream modules
			// already treat as optional.
			const img = BE.dom.qs('#image, #main_image, .image-container img, main img.post-image');
			const video = BE.dom.qs('video source, video');
			const original = video?.src || video?.currentSrc || img?.src || '';
			return emptyPost({
				id: this.getPostId(),
				originalUrl: original,
				sampleUrl: original,
				previewUrl: img?.src || original,
				mediaType: video ? 'video' : guessMediaType(original),
				width: img?.naturalWidth || 0,
				height: img?.naturalHeight || 0,
				allTags: BE.dom.qsa('a[href*="tags="], .tag-link, li.tag a').map((a) => a.textContent.trim()).filter(Boolean),
				postUrl: location.href,
				siteId: 'generic',
			});
		},
		async fetchThumbBatch() { return []; }, // best-effort only; no bulk API
		favoriteSelector: 'a[href*="favorite" i], button[class*="favorite" i]',
		gridContainerSelector: 'body',
	});

	/* ============================================================ *
	 *  NAMING  — filename template engine
	 * ============================================================ */
	BE.naming = {
		/** @param {Post} post @returns {string} filename without extension */
		build(post) {
			const tpl = BE.settings.get('download.filenameTemplate') || '{id}';
			const delim = BE.settings.get('download.tagDelimiter') || ', ';
			const maxChars = BE.settings.get('download.maxCharacters') || 3;

			const charList = post.characters.length
				? post.characters.slice(0, maxChars).map(prettyTag)
				: ['Unknown Character'];

			const fields = {
				character: charList.join(delim),
				artist: (post.artists.length ? post.artists.map(prettyTag) : ['Unknown Artist']).join(delim),
				copyright: (post.copyrights.length ? post.copyrights.map(prettyTag) : ['Unknown Copyright']).join(delim),
				id: post.id,
				md5: post.md5 || post.id,
				rating: post.rating,
				score: String(post.score),
				resolution: post.width && post.height ? `${post.width}x${post.height}` : '',
				date: post.createdAt ? post.createdAt.slice(0, 10) : '',
			};

			let name = tpl.replace(/\{(\w+)\}/g, (_, key) => (fields[key] !== undefined ? fields[key] : ''));
			name = name.replace(/\s{2,}/g, ' ').replace(/^[\s\-,]+|[\s\-,]+$/g, '');
			if (!name) name = post.id || 'download';
			return BE.dom.sanitizeFilename(name);
		},
		extensionFor(post) {
			const url = post.originalUrl || post.sampleUrl || '';
			const m = url.split('?')[0].match(/\.([a-z0-9]+)$/i);
			return m ? m[1] : (post.mediaType === 'video' ? 'mp4' : 'jpg');
		},
	};
	function prettyTag(tag) {
		return tag.replace(/_/g, ' ').replace(/\s+\(.*?\)$/, '').replace(/\b\w/g, (c) => c.toUpperCase());
	}

	/* ============================================================ *
	 *  DOWNLOADER
	 *  Always downloads original quality. Uses GM_download when the
	 *  URL is directly fetchable by the manager; falls back to a
	 *  blob-based anchor-click download built from a GM_xmlhttpRequest
	 *  response, so cross-origin CDN hosts still work.
	 * ============================================================ */
	BE.modules = BE.modules || {};
	BE.modules.downloader = (() => {
		const queue = new Map(); // postId -> {status, progress}

		function statusFor(id) { return queue.get(id) || { status: 'idle', progress: 0 }; }

		async function downloadPost(post) {
			if (!post || !post.originalUrl) { BE.log.warn('downloader: post has no original URL', post); return false; }
			const name = `${BE.naming.build(post)}.${BE.naming.extensionFor(post)}`;
			queue.set(post.id, { status: 'downloading', progress: 0 });
			BE.bus.emit('download:start', { post, name });

			const retries = BE.settings.get('download.retries') ?? 3;
			for (let attempt = 0; attempt <= retries; attempt++) {
				try {
					await tryDownload(post, name, (pct) => {
						queue.set(post.id, { status: 'downloading', progress: pct });
						BE.bus.emit('download:progress', { post, name, progress: pct });
					});
					queue.set(post.id, { status: 'done', progress: 100 });
					BE.bus.emit('download:done', { post, name });
					return true;
				} catch (err) {
					BE.log.warn(`downloader attempt ${attempt + 1}/${retries + 1} failed`, err);
					if (attempt === retries) {
						queue.set(post.id, { status: 'error', progress: 0 });
						BE.bus.emit('download:error', { post, name, error: err });
						_GM.notification({ title: 'Download failed', text: `${name} — ${err.message || err}` });
						return false;
					}
					await new Promise((r) => setTimeout(r, 500 * (attempt + 1)));
				}
			}
			return false;
		}

		function tryDownload(post, name, onProgress) {
			return new Promise((resolve, reject) => {
				// Prefer GM_download: it streams straight to disk without
				// pulling the (potentially huge) file into page memory.
				try {
					_GM.download({
						url: post.originalUrl,
						name,
						saveAs: false,
						onload: () => resolve(),
						onerror: (e) => reject(new Error(e?.error || 'GM_download error')),
						onprogress: (p) => { if (p.lengthComputable) onProgress(Math.round((p.loaded / p.total) * 100)); },
					});
				} catch (err) {
					// Fallback: XHR blob + anchor click (covers managers/hosts
					// where GM_download is unavailable or blocked).
					_GM.xhr({
						url: post.originalUrl,
						method: 'GET',
						responseType: 'blob',
						onprogress: (p) => { if (p.lengthComputable) onProgress(Math.round((p.loaded / p.total) * 100)); },
						onload: (res) => {
							if (res.status < 200 || res.status >= 300) return reject(new Error(`HTTP ${res.status}`));
							const blobUrl = URL.createObjectURL(res.response);
							const a = BE.dom.create('a', { href: blobUrl, download: name });
							document.body.appendChild(a);
							a.click();
							a.remove();
							setTimeout(() => URL.revokeObjectURL(blobUrl), 4000);
							resolve();
						},
						onerror: () => reject(new Error('network error')),
					});
				}
			});
		}

		function bulkDownload(posts) {
			BE.bus.emit('download:bulk-start', { count: posts.length });
			let i = 0;
			const next = () => {
				if (i >= posts.length) { BE.bus.emit('download:bulk-done', {}); return; }
				const p = posts[i++];
				downloadPost(p).finally(() => setTimeout(next, 250)); // gentle stagger, avoid hammering CDN
			};
			next();
		}

		return { downloadPost, bulkDownload, statusFor };
	})();

	/* ============================================================ *
	 *  MEDIA LOADER
	 *  Swaps preview/sample media for the original, per the configured
	 *  mode. Never upscales: if the rendered box is already >= native
	 *  resolution we skip the swap.
	 * ============================================================ */
	BE.modules.mediaLoader = (() => {
		function shouldSwap(imgEl, post) {
			if (BE.settings.get('media.neverUpscale') && post.width && imgEl) {
				const displayW = imgEl.getBoundingClientRect().width || imgEl.width;
				if (displayW && post.width < displayW) return false;
			}
			return true;
		}

		function swapImage(imgEl, post) {
			if (!post?.originalUrl || !shouldSwap(imgEl, post)) return;
			if (post.mediaType === 'video') {
				const video = BE.dom.create('video', {
					class: imgEl.className, controls: '', loop: BE.settings.get('viewer.loopVideo') ? '' : null,
					muted: BE.settings.get('viewer.muteVideo') ? '' : null,
					autoplay: BE.settings.get('viewer.autoplayVideo') ? '' : null,
					style: imgEl.getAttribute('style') || '',
				}, [BE.dom.create('source', { src: post.originalUrl })]);
				imgEl.replaceWith(video);
				BE.bus.emit('media:swapped', { post, el: video });
			} else {
				imgEl.dataset.beOriginalSwapped = 'true';
				imgEl.src = post.originalUrl;
				BE.bus.emit('media:swapped', { post, el: imgEl });
			}
		}

		function onPostPage(post) {
			const mode = BE.settings.get('media.mode');
			if (mode === 'disabled') return;
			const mainImg = BE.dom.qs('#image, #main_image, .image-container img, main img.post-image, article img.image');
			if (!mainImg) return;
			if (mode === 'always' || mode === 'post-page') {
				swapImage(mainImg, post);
			} else if (mode === 'on-click') {
				mainImg.style.cursor = 'zoom-in';
				mainImg.title = 'Click to load original';
				mainImg.addEventListener('click', function handler(e) {
					e.preventDefault();
					swapImage(mainImg, post);
					mainImg.removeEventListener('click', handler);
				}, { once: false });
			}
		}

		function applyThumbQuality(imgEl, post) {
			const q = BE.settings.get('media.thumbQuality');
			if (q === 'original' && post.originalUrl) imgEl.src = post.originalUrl;
			else if (q === 'sample' && post.sampleUrl) imgEl.src = post.sampleUrl;
			// 'preview' = leave native thumbnail alone
		}

		return { swapImage, onPostPage, applyThumbQuality };
	})();

	/* ============================================================ *
	 *  FAVORITES  (quick, out-of-page favoriting)
	 *  Real favoriting requires the site's own authenticated session;
	 *  rather than reimplementing every engine's CSRF-protected API,
	 *  we trigger the site's own favorite control (native auth reused)
	 *  and mirror the state into a local "quick list" for instant UI
	 *  feedback + a personal cross-session favorites index.
	 * ============================================================ */
	BE.modules.favorites = (() => {
		const LOCAL_KEY = 'favorites:' + location.hostname;

		function localList() { return BE.store.get(LOCAL_KEY, []); }
		function isFavorited(id) { return localList().includes(id); }

		function toggleLocal(post) {
			const list = localList();
			const idx = list.indexOf(post.id);
			if (idx === -1) list.push(post.id); else list.splice(idx, 1);
			BE.store.set(LOCAL_KEY, list);
			BE.bus.emit('favorite:changed', { post, favorited: idx === -1 });
			return idx === -1;
		}

		/** Best-effort: click the site's native favorite control if present. */
		function triggerNativeFavorite(container) {
			const adapter = BE.adapters.active;
			const btn = container ? BE.dom.qs(adapter.favoriteSelector, container) : BE.dom.qs(adapter.favoriteSelector);
			if (btn) { btn.click(); return true; }
			return false;
		}

		function favoritePost(post, container) {
			const favorited = toggleLocal(post);
			triggerNativeFavorite(container);
			return favorited;
		}

		return { isFavorited, favoritePost, localList };
	})();

	/* ============================================================ *
	 *  FILTERS / BLACKLIST
	 *  Supports plain tags, "-tag" whitelist overrides (handled via
	 *  the separate whitelist box, mirroring Danbooru convention of
	 *  "-tag" meaning "never hide this"), and "rating:x" pseudo-tags.
	 * ============================================================ */
	BE.modules.filters = (() => {
		function postMatchesBlacklist(post) {
			const blacklist = BE.settings.get('filter.blacklist') ? BE.settings.blacklistTags() : [];
			const whitelist = new Set(BE.settings.whitelistTags());
			const tagSet = new Set(post.allTags.map((t) => t.toLowerCase()));

			if (BE.settings.get('filter.hideVideos') && post.mediaType === 'video') return true;
			if (BE.settings.get('filter.hideAnimations') && post.mediaType === 'gif') return true;
			if (BE.settings.get('filter.hideAIGenerated') && (tagSet.has('ai-generated') || tagSet.has('ai_generated'))) return true;
			const minRes = BE.settings.get('filter.minResolution');
			if (minRes && post.width && post.width < minRes) return true;

			for (const rule of blacklist) {
				if (whitelist.has(rule)) continue;
				if (rule.startsWith('rating:')) {
					if (post.rating === rule.slice(7)) return true;
				} else if (tagSet.has(rule)) {
					return true;
				}
			}
			return false;
		}

		function applyToThumb(el, post) {
			const hidden = postMatchesBlacklist(post);
			el.closest('article, .thumbnail-preview, span.thumb, .post-preview')?.classList.toggle('be-filtered-hidden', hidden);
			return hidden;
		}

		return { postMatchesBlacklist, applyToThumb };
	})();

	/* ============================================================ *
	 *  TAG TOOLS
	 * ============================================================ */
	const TAG_CATEGORY_COLORS = {
		artist: '#f2ac08', character: '#00aa00', copyright: '#d0d', general: '#0073ff',
		meta: '#eaa8f5', species: '#ed5d1f', lore: '#228822', invalid: '#ff3d3d',
	};

	BE.modules.tagTools = (() => {
		function colorizeCategory(el, category) {
			if (!BE.settings.get('tags.colorize')) return;
			el.style.setProperty('--be-tag-color', TAG_CATEGORY_COLORS[category] || TAG_CATEGORY_COLORS.general);
			el.classList.add('be-tag-colored');
		}

		function copyTagsToClipboard(post, opts = {}) {
			const tags = opts.selected || post.allTags;
			const text = tags.join(' ');
			navigator.clipboard?.writeText(text).then(
				() => _GM.notification({ title: 'Tags copied', text: `${tags.length} tags copied to clipboard` }),
				() => BE.log.warn('clipboard write failed'),
			);
		}

		function searchSelectedTags(tags) {
			const adapter = BE.adapters.active;
			const q = tags.join(' ');
			let url;
			if (adapter.id === 'gelbooru-family') url = `${location.origin}/index.php?page=post&s=list&tags=${encodeURIComponent(q)}`;
			else if (adapter.id === 'moebooru' || adapter.id === 'danbooru' || adapter.id === 'e621') url = `${location.origin}/posts?tags=${encodeURIComponent(q)}`;
			else url = `${location.origin}/?tags=${encodeURIComponent(q)}`;
			window.open(url, '_blank', 'noopener');
		}

		function buildCollapsible(listEl, threshold) {
			const items = BE.dom.qsa('li, .tag-item', listEl);
			if (items.length <= threshold) return;
			items.slice(threshold).forEach((li) => li.classList.add('be-tag-collapsed'));
			const toggle = BE.dom.create('button', {
				class: 'be-tag-expand-btn',
				text: `Show ${items.length - threshold} more tags`,
				onclick: () => {
					items.slice(threshold).forEach((li) => li.classList.remove('be-tag-collapsed'));
					toggle.remove();
				},
			});
			listEl.appendChild(toggle);
		}

		return { colorizeCategory, copyTagsToClipboard, searchSelectedTags, buildCollapsible, TAG_CATEGORY_COLORS };
	})();

	/* ============================================================ *
	 *  IMAGE INFO PANEL
	 * ============================================================ */
	BE.modules.imageInfo = {
		render(post) {
			const aspect = post.width && post.height ? (post.width / post.height).toFixed(3) : '?';
			const rows = [
				['Resolution', post.width && post.height ? `${post.width} × ${post.height}` : 'Unknown'],
				['Aspect ratio', aspect],
				['File size', BE.dom.formatBytes(post.fileSize)],
				['Format', BE.naming.extensionFor(post).toUpperCase()],
				['Uploaded', post.createdAt ? post.createdAt.slice(0, 10) : 'Unknown'],
				['Score', post.score],
				['Favorites', post.favCount],
				['Rating', post.rating],
				['MD5', post.md5 || 'Unknown'],
				['Artists', post.artists.length ? post.artists.map(prettyTag).join(', ') : 'None'],
				['Characters', post.characters.length ? String(post.characters.length) : '0'],
			];
			return BE.dom.create('table', { class: 'be-info-table' },
				rows.map(([k, v]) => BE.dom.create('tr', {}, [
					BE.dom.create('td', { class: 'be-info-key', text: k }),
					BE.dom.create('td', { class: 'be-info-val', text: String(v) }),
				])));
		},
	};

	/* ============================================================ *
	 *  REVERSE IMAGE SEARCH
	 * ============================================================ */
	BE.modules.reverseSearch = {
		ENGINES: {
			saucenao: (url) => `https://saucenao.com/search.php?url=${encodeURIComponent(url)}`,
			iqdb: (url) => `https://iqdb.org/?url=${encodeURIComponent(url)}`,
			googleLens: (url) => `https://lens.google.com/uploadbyurl?url=${encodeURIComponent(url)}`,
			yandex: (url) => `https://yandex.com/images/search?rpt=imageview&url=${encodeURIComponent(url)}`,
			bing: (url) => `https://www.bing.com/images/search?view=detailv2&iss=sbi&q=imgurl:${encodeURIComponent(url)}`,
		},
		open(engine, imageUrl) {
			const build = this.ENGINES[engine];
			if (!build) return BE.log.warn('unknown reverse search engine', engine);
			window.open(build(imageUrl), '_blank', 'noopener');
		},
	};

	/* ============================================================ *
	 *  SOURCE LINK DETECTION
	 * ============================================================ */
	BE.modules.sourceLinks = (() => {
		const PLATFORMS = [
			{ id: 'pixiv', re: /pixiv\.net/i, icon: '🎨' },
			{ id: 'twitter', re: /twitter\.com|x\.com/i, icon: '🐦' },
			{ id: 'fanbox', re: /fanbox\.cc/i, icon: '📦' },
			{ id: 'fantia', re: /fantia\.jp/i, icon: '💠' },
			{ id: 'skeb', re: /skeb\.jp/i, icon: '✏️' },
			{ id: 'deviantart', re: /deviantart\.com/i, icon: '🖌️' },
			{ id: 'artstation', re: /artstation\.com/i, icon: '🚀' },
			{ id: 'nijie', re: /nijie\.info/i, icon: '🈲' },
		];
		function detect(sourceUrl) {
			if (!sourceUrl) return null;
			return PLATFORMS.find((p) => p.re.test(sourceUrl)) || null;
		}
		function archiveUrlFor(sourceUrl) {
			return `https://web.archive.org/web/2/${sourceUrl}`;
		}
		return { detect, archiveUrlFor, PLATFORMS };
	})();

	/* ============================================================ *
	 *  VIEWER  — fullscreen lightbox
	 *  Works over an array of Post objects supplied by the gallery
	 *  module (BE.modules.gallery keeps this list in sync). Zoom/pan
	 *  are done with a CSS transform on the media element; state resets
	 *  on navigation.
	 * ============================================================ */
	BE.modules.viewer = (() => {
		let root, mediaHost, currentIndex = -1, posts = [];
		let zoom = 1, panX = 0, panY = 0, rotation = 0, flipX = 1, flipY = 1;
		let dragging = false, dragStart = null;
		let rememberedVolume = BE.store.get('viewer:volume', 1);

		function ensureRoot() {
			if (root) return root;
			root = BE.dom.create('div', { class: 'be-viewer be-hidden' }, [
				BE.dom.create('div', { class: 'be-viewer-toolbar' }, [
					iconBtn('be-v-close', '✕', 'Close (Esc)', close),
					iconBtn('be-v-prev', '‹', 'Previous (←)', () => go(-1)),
					iconBtn('be-v-next', '›', 'Next (→)', () => go(1)),
					iconBtn('be-v-zoomin', '+', 'Zoom in', () => applyZoom(0.2)),
					iconBtn('be-v-zoomout', '−', 'Zoom out', () => applyZoom(-0.2)),
					iconBtn('be-v-fit', '⤢', 'Fit to window', resetTransform),
					iconBtn('be-v-rotate', '⟳', 'Rotate', () => { rotation += 90; renderTransform(); }),
					iconBtn('be-v-fliph', '⇋', 'Flip horizontal', () => { flipX *= -1; renderTransform(); }),
					iconBtn('be-v-flipv', '⇵', 'Flip vertical', () => { flipY *= -1; renderTransform(); }),
					iconBtn('be-v-download', '⬇', 'Download', () => posts[currentIndex] && BE.modules.downloader.downloadPost(posts[currentIndex])),
					iconBtn('be-v-fav', '♥', 'Favorite (F)', () => posts[currentIndex] && BE.modules.favorites.favoritePost(posts[currentIndex])),
					iconBtn('be-v-info', 'ℹ', 'Toggle info', toggleInfo),
					BE.dom.create('span', { class: 'be-v-counter' }),
				]),
				(mediaHost = BE.dom.create('div', { class: 'be-viewer-media' })),
				BE.dom.create('div', { class: 'be-viewer-info-panel be-hidden' }),
			]);
			root.addEventListener('wheel', onWheel, { passive: false });
			root.addEventListener('mousedown', onDragStart);
			window.addEventListener('mousemove', onDragMove);
			window.addEventListener('mouseup', onDragEnd);
			root.addEventListener('touchstart', onTouchStart, { passive: true });
			root.addEventListener('touchmove', onTouchMove, { passive: false });
			root.addEventListener('touchend', onTouchEnd);
			root.addEventListener('click', (e) => { if (e.target === root || e.target === mediaHost) close(); });
			document.body.appendChild(root);
			return root;
		}

		function iconBtn(cls, label, title, onClick) {
			return BE.dom.create('button', { class: `be-v-btn ${cls}`, title, text: label, onclick: onClick });
		}

		function toggleInfo() {
			const panel = BE.dom.qs('.be-viewer-info-panel', root);
			panel.classList.toggle('be-hidden');
			if (!panel.classList.contains('be-hidden')) {
				panel.innerHTML = '';
				panel.appendChild(BE.modules.imageInfo.render(posts[currentIndex]));
			}
		}

		/**
		 * @param {Post[]} postList
		 * @param {number} startIndex
		 */
		function open(postList, startIndex = 0) {
			if (!BE.settings.get('viewer.enabled')) return;
			posts = postList;
			ensureRoot();
			root.classList.remove('be-hidden');
			document.documentElement.classList.add('be-viewer-open');
			renderIndex(startIndex);
			BE.bus.emit('viewer:open', { index: startIndex });
		}

		function close() {
			if (!root) return;
			root.classList.add('be-hidden');
			document.documentElement.classList.remove('be-viewer-open');
			mediaHost.innerHTML = '';
			BE.bus.emit('viewer:close', {});
		}

		function isOpen() { return root && !root.classList.contains('be-hidden'); }

		function go(delta) { renderIndex((currentIndex + delta + posts.length) % posts.length); }

		function resetTransform() {
			zoom = 1; panX = 0; panY = 0; rotation = 0; flipX = 1; flipY = 1;
			renderTransform();
		}

		function renderTransform() {
			const el = mediaHost.firstElementChild;
			if (!el) return;
			el.style.transform = `translate(${panX}px, ${panY}px) rotate(${rotation}deg) scale(${zoom * flipX}, ${zoom * flipY})`;
		}

		function applyZoom(delta, centerX, centerY) {
			zoom = Math.max(0.1, Math.min(8, zoom + delta));
			renderTransform();
		}

		function onWheel(e) {
			if (!mediaHost.contains(e.target) && e.target !== mediaHost) return;
			e.preventDefault();
			applyZoom(e.deltaY < 0 ? 0.15 : -0.15);
		}

		function onDragStart(e) {
			if (e.target.closest('.be-viewer-toolbar')) return;
			dragging = true;
			dragStart = { x: e.clientX - panX, y: e.clientY - panY };
		}
		function onDragMove(e) {
			if (!dragging) return;
			panX = e.clientX - dragStart.x;
			panY = e.clientY - dragStart.y;
			renderTransform();
		}
		function onDragEnd() { dragging = false; }

		let touchState = null;
		function onTouchStart(e) {
			if (e.touches.length === 2) {
				touchState = { type: 'pinch', dist: touchDist(e.touches), zoom };
			} else if (e.touches.length === 1) {
				touchState = { type: 'pan', x: e.touches[0].clientX - panX, y: e.touches[0].clientY - panY, startX: e.touches[0].clientX, t: Date.now() };
			}
		}
		function onTouchMove(e) {
			if (!touchState) return;
			e.preventDefault();
			if (touchState.type === 'pinch' && e.touches.length === 2) {
				const d = touchDist(e.touches);
				zoom = Math.max(0.1, Math.min(8, touchState.zoom * (d / touchState.dist)));
				renderTransform();
			} else if (touchState.type === 'pan' && e.touches.length === 1) {
				panX = e.touches[0].clientX - touchState.x;
				panY = e.touches[0].clientY - touchState.y;
				renderTransform();
			}
		}
		function onTouchEnd(e) {
			if (touchState?.type === 'pan' && zoom <= 1.05) {
				const dx = (e.changedTouches[0]?.clientX ?? 0) - touchState.startX;
				const dt = Date.now() - touchState.t;
				if (dt < 400 && Math.abs(dx) > 60) go(dx < 0 ? 1 : -1); // swipe navigation
			}
			touchState = null;
		}
		function touchDist(touches) {
			const [a, b] = touches;
			return Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
		}

		function renderIndex(idx) {
			currentIndex = idx;
			resetTransform();
			const post = posts[idx];
			mediaHost.innerHTML = '';
			if (!post) return;

			let el;
			if (post.mediaType === 'video') {
				el = BE.dom.create('video', {
					src: post.originalUrl, controls: '', autoplay: BE.settings.get('viewer.autoplayVideo') ? '' : null,
					loop: BE.settings.get('viewer.loopVideo') ? '' : null, playsinline: '',
				});
				el.muted = BE.settings.get('viewer.muteVideo');
				if (BE.settings.get('viewer.rememberVolume')) el.volume = rememberedVolume;
				el.addEventListener('volumechange', () => { rememberedVolume = el.volume; BE.store.set('viewer:volume', el.volume); });
			} else {
				el = BE.dom.create('img', { src: post.originalUrl, alt: post.id, draggable: 'false' });
			}
			el.className = `be-viewer-fit-${BE.settings.get('viewer.fitMode')}`;
			mediaHost.appendChild(el);

			BE.dom.qs('.be-v-counter', root).textContent = `${idx + 1} / ${posts.length}`;
			const info = BE.dom.qs('.be-viewer-info-panel', root);
			if (info && !info.classList.contains('be-hidden')) { info.innerHTML = ''; info.appendChild(BE.modules.imageInfo.render(post)); }
			BE.bus.emit('viewer:navigate', { post, index: idx });

			// preload neighbors
			[idx + 1, idx - 1].forEach((n) => {
				const p = posts[(n + posts.length) % posts.length];
				if (p?.mediaType !== 'video' && p?.originalUrl) { const pre = new Image(); pre.src = p.originalUrl; }
			});
		}

		function activeVideo() { return mediaHost?.querySelector('video') || null; }
		function togglePlayPause() { const v = activeVideo(); if (v) v.paused ? v.play() : v.pause(); }
		function screenshotCurrentFrame() {
			const v = activeVideo();
			if (!v) return null;
			const canvas = document.createElement('canvas');
			canvas.width = v.videoWidth; canvas.height = v.videoHeight;
			canvas.getContext('2d').drawImage(v, 0, 0);
			return canvas.toDataURL('image/png');
		}
		function stepFrame(dir) {
			const v = activeVideo();
			if (!v) return;
			v.pause();
			v.currentTime = Math.max(0, v.currentTime + dir * (1 / 30));
		}
		function setPlaybackRate(rate) { const v = activeVideo(); if (v) v.playbackRate = rate; }
		function requestPiP() { const v = activeVideo(); v?.requestPictureInPicture?.().catch((e) => BE.log.warn('PiP failed', e)); }

		return {
			open, close, isOpen, go, applyZoom, resetTransform,
			togglePlayPause, screenshotCurrentFrame, stepFrame, setPlaybackRate, requestPiP,
			get currentPost() { return posts[currentIndex]; },
		};
	})();

	/* ============================================================ *
	 *  GALLERY  — thumbnail grid enrichment, infinite scroll, bulk ops
	 *  Uses IntersectionObserver + MutationObserver so it scales to
	 *  thousands of posts without ever walking the whole DOM per frame,
	 *  and batches metadata fetches per adapter's fetchThumbBatch().
	 * ============================================================ */
	BE.modules.gallery = (() => {
		const postCache = new Map(); // id -> Post
		const thumbToPost = new WeakMap(); // <img> -> Post
		let orderedPosts = []; // for viewer navigation, in DOM order
		let selectionMode = false;
		const selected = new Set();
		let pendingIds = new Set();
		const flushBatch = BE.dom.debounce(async () => {
			const ids = [...pendingIds];
			pendingIds.clear();
			if (!ids.length) return;
			try {
				const results = await BE.adapters.active.fetchThumbBatch(ids);
				for (const post of results) postCache.set(post.id, post);
				BE.bus.emit('gallery:batch-loaded', { ids });
				refreshEnrichedThumbs();
			} catch (err) { BE.log.warn('thumb batch fetch failed', err); }
		}, 120);

		function collectThumbs() {
			return BE.adapters.active.getThumbElements().filter((img) => !img.dataset.beEnriched);
		}

		function queueForFetch(img) {
			const id = BE.adapters.active.getThumbPostId(img);
			if (!id) return;
			img.dataset.bePostId = id;
			img.dataset.beEnriched = 'pending';
			if (postCache.has(id)) { enrichThumb(img, postCache.get(id)); return; }
			pendingIds.add(id);
			flushBatch();
		}

		function refreshEnrichedThumbs() {
			BE.dom.qsa('img[data-be-enriched="pending"]').forEach((img) => {
				const post = postCache.get(img.dataset.bePostId);
				if (post) enrichThumb(img, post);
			});
		}

		function enrichThumb(img, post) {
			img.dataset.beEnriched = 'done';
			thumbToPost.set(img, post);
			orderedPosts.push(post);

			BE.modules.mediaLoader.applyThumbQuality(img, post);
			const hidden = BE.modules.filters.applyToThumb(img, post);
			if (hidden) return;

			const wrap = img.closest('article, .thumbnail-preview, span.thumb, .post-preview') || img.parentElement;
			if (!wrap || wrap.dataset.beWired) return;
			wrap.dataset.beWired = '1';
			wrap.classList.add('be-thumb-wrap');

			// Overlay: quick actions on hover
			const overlay = BE.dom.create('div', { class: 'be-thumb-overlay' }, [
				BE.dom.create('button', {
					class: `be-thumb-fav ${BE.modules.favorites.isFavorited(post.id) ? 'be-active' : ''}`,
					title: 'Favorite', text: '♥',
					onclick: (e) => { e.preventDefault(); e.stopPropagation(); const on = BE.modules.favorites.favoritePost(post, wrap); e.currentTarget.classList.toggle('be-active', on); },
				}),
				BE.dom.create('button', {
					class: 'be-thumb-dl', title: 'Download original', text: '⬇',
					onclick: (e) => { e.preventDefault(); e.stopPropagation(); BE.modules.downloader.downloadPost(post); },
				}),
				post.mediaType === 'video' ? BE.dom.create('span', { class: 'be-thumb-badge', text: 'VIDEO' }) : null,
				post.mediaType === 'gif' ? BE.dom.create('span', { class: 'be-thumb-badge', text: 'GIF' }) : null,
			]);
			wrap.appendChild(overlay);

			if (BE.settings.get('media.hoverPreview')) {
				let hoverTimer;
				wrap.addEventListener('mouseenter', () => {
					hoverTimer = setTimeout(() => showHoverPreview(wrap, post), 350);
				});
				wrap.addEventListener('mouseleave', () => { clearTimeout(hoverTimer); hideHoverPreview(); });
			}

			img.addEventListener('click', (e) => {
				if (selectionMode) { e.preventDefault(); toggleSelect(wrap, post); return; }
				if (!BE.settings.get('viewer.enabled')) return;
				if (e.button === 1 || e.ctrlKey || e.metaKey) return; // let middle/ctrl-click open natively
				e.preventDefault();
				BE.modules.viewer.open(orderedPosts, orderedPosts.indexOf(post));
			});
			img.addEventListener('auxclick', (e) => {
				if (e.button === 1) { e.preventDefault(); window.open(post.originalUrl, '_blank', 'noopener'); }
			});
		}

		let hoverEl = null;
		function showHoverPreview(wrap, post) {
			hideHoverPreview();
			hoverEl = BE.dom.create('div', { class: 'be-hover-preview' }, [
				BE.dom.create('img', { src: post.sampleUrl || post.originalUrl }),
			]);
			document.body.appendChild(hoverEl);
			const rect = wrap.getBoundingClientRect();
			hoverEl.style.left = `${Math.min(rect.right + 12, window.innerWidth - 340)}px`;
			hoverEl.style.top = `${Math.max(8, rect.top)}px`;
		}
		function hideHoverPreview() { hoverEl?.remove(); hoverEl = null; }

		function toggleSelect(wrap, post) {
			if (selected.has(post.id)) { selected.delete(post.id); wrap.classList.remove('be-selected'); }
			else { selected.add(post.id); wrap.classList.add('be-selected'); }
			BE.bus.emit('gallery:selection-changed', { count: selected.size });
		}
		function setSelectionMode(on) {
			selectionMode = on;
			document.documentElement.classList.toggle('be-selection-mode', on);
			if (!on) { selected.clear(); BE.dom.qsa('.be-selected').forEach((el) => el.classList.remove('be-selected')); }
		}
		function selectedPosts() { return [...selected].map((id) => postCache.get(id)).filter(Boolean); }

		function scan() {
			collectThumbs().forEach((img) => BE.dom.onVisible(img, queueForFetch));
		}

		function initInfiniteScroll() {
			if (!BE.settings.get('gallery.infiniteScroll')) return;
			const container = BE.dom.qs(BE.adapters.active.gridContainerSelector) || document.body;
			const paginator = BE.dom.qs('.pagination, #paginator, .pagination-controls');
			if (!paginator) return;
			const nextLink = BE.dom.qs('a[rel="next"], a.next, a[href*="pid="]:last-child', paginator);
			let loading = false, exhausted = false;
			const sentinel = BE.dom.create('div', { class: 'be-scroll-sentinel', text: 'Loading more…' });
			container.appendChild(sentinel);
			paginator.style.display = 'none';

			const io = new IntersectionObserver((entries) => {
				if (exhausted || loading) return;
				if (entries.some((e) => e.isIntersecting)) loadNextPage();
			}, { rootMargin: '1200px 0px' });
			io.observe(sentinel);

			async function loadNextPage() {
				const link = BE.dom.qs('a[rel="next"], a.next', BE.dom.qs('.pagination, #paginator, .pagination-controls') || document);
				if (!link) { exhausted = true; sentinel.textContent = 'No more posts'; return; }
				loading = true;
				sentinel.textContent = 'Loading more…';
				try {
					const res = await BE.net.request({ url: link.href });
					const doc = new DOMParser().parseFromString(res.responseText, 'text/html');
					const newContainer = doc.querySelector(BE.adapters.active.gridContainerSelector);
					const newPaginator = doc.querySelector('.pagination, #paginator, .pagination-controls');
					if (newContainer) {
						[...newContainer.children].forEach((child) => {
							if (!child.classList?.contains('be-scroll-sentinel')) container.insertBefore(child, sentinel);
						});
					}
					if (newPaginator) {
						const nn = newPaginator.querySelector('a[rel="next"], a.next');
						const holder = BE.dom.qs('.pagination, #paginator, .pagination-controls');
						if (holder && nn) { holder.innerHTML = newPaginator.innerHTML; }
						else exhausted = true;
					} else exhausted = true;
					scan(); // enrich the freshly injected thumbnails
				} catch (err) {
					BE.log.error('infinite scroll fetch failed', err);
				} finally {
					loading = false;
				}
			}
		}

		return {
			scan, initInfiniteScroll, setSelectionMode, toggleSelect, selectedPosts,
			get selectionMode() { return selectionMode; },
			get orderedPosts() { return orderedPosts; },
			postFor(img) { return thumbToPost.get(img); },
		};
	})();

	/* ============================================================ *
	 *  KEYBINDS
	 * ============================================================ */
	BE.modules.keybinds = (() => {
		function normalize(k) { return k.length === 1 ? k.toLowerCase() : k; }

		function eventKey(e) { return normalize(e.key); }

		function handler(e) {
			// never hijack typing in inputs/textareas/contenteditable
			const tag = document.activeElement?.tagName;
			if (tag === 'INPUT' || tag === 'TEXTAREA' || document.activeElement?.isContentEditable) return;

			const key = eventKey(e);
			const binds = {
				[BE.settings.get('keys.close')]: () => BE.modules.viewer.isOpen() && BE.modules.viewer.close(),
				[BE.settings.get('keys.next')]: () => BE.modules.viewer.isOpen() && BE.modules.viewer.go(1),
				[BE.settings.get('keys.prev')]: () => BE.modules.viewer.isOpen() && BE.modules.viewer.go(-1),
				[BE.settings.get('keys.playPause')]: () => { if (BE.modules.viewer.isOpen()) { e.preventDefault(); BE.modules.viewer.togglePlayPause(); } },
				[BE.settings.get('keys.download')]: () => currentContextPost() && BE.modules.downloader.downloadPost(currentContextPost()),
				[BE.settings.get('keys.favorite')]: () => currentContextPost() && BE.modules.favorites.favoritePost(currentContextPost()),
				[BE.settings.get('keys.openOriginal')]: () => currentContextPost() && window.open(currentContextPost().originalUrl, '_blank', 'noopener'),
				[BE.settings.get('keys.viewOriginal')]: () => toggleViewerFromContext(),
			};

			if (e.ctrlKey && key === BE.settings.get('keys.commandPalette')) {
				e.preventDefault();
				BE.bus.emit('ui:command-palette-toggle', {});
				return;
			}
			const fn = binds[key];
			if (fn) fn();
		}

		function currentContextPost() {
			if (BE.modules.viewer.isOpen()) return BE.modules.viewer.currentPost;
			return BE.core.currentPost || null;
		}

		function toggleViewerFromContext() {
			if (BE.modules.viewer.isOpen()) { BE.modules.viewer.close(); return; }
			if (BE.core.currentPost) BE.modules.viewer.open([BE.core.currentPost], 0);
		}

		function init() { window.addEventListener('keydown', handler); }
		return { init };
	})();

	/* ============================================================ *
	 *  UI — floating toolbar + settings panel
	 * ============================================================ */
	BE.modules.ui = (() => {
		let toolbar;

		function buildToolbar() {
			toolbar = BE.dom.create('div', { class: `be-toolbar be-toolbar-${BE.settings.get('general.toolbarPosition')}` }, [
				toolbarBtn('⚙', 'Settings', () => BE.modules.settingsPanel.open()),
				toolbarBtn('☰', 'Select posts', () => {
					BE.modules.gallery.setSelectionMode(!document.documentElement.classList.contains('be-selection-mode'));
				}),
				toolbarBtn('⬇', 'Bulk download selected', () => {
					const posts = BE.modules.gallery.selectedPosts();
					if (posts.length) BE.modules.downloader.bulkDownload(posts);
				}),
				toolbarBtn('🔍', 'Reverse search current', () => {
					const post = BE.core.currentPost;
					if (post) BE.modules.reverseSearch.open('saucenao', post.originalUrl);
				}),
			]);
			document.body.appendChild(toolbar);
		}
		function toolbarBtn(icon, title, onClick) {
			return BE.dom.create('button', { class: 'be-toolbar-btn', title, text: icon, onclick: onClick });
		}

		function init() {
			buildToolbar();
			applyTheme();
			BE.bus.on('settings:changed', ({ key }) => {
				if (key === 'general.theme' || key === 'general.accentColor') applyTheme();
			});
		}

		function applyTheme() {
			document.documentElement.style.setProperty('--be-accent', BE.settings.get('general.accentColor'));
			document.documentElement.dataset.beTheme = BE.settings.get('general.theme');
		}

		return { init, get toolbar() { return toolbar; } };
	})();

	BE.modules.settingsPanel = (() => {
		let overlay;

		function fieldControl(key, def) {
			const val = BE.settings.get(key);
			const onChange = (v) => BE.settings.set(key, v);
			switch (def.type) {
				case 'bool':
					return BE.dom.create('input', { type: 'checkbox', checked: val ? 'checked' : null, onchange: (e) => onChange(e.target.checked) });
				case 'select':
					return BE.dom.create('select', { onchange: (e) => onChange(e.target.value) },
						def.choices.map((c) => BE.dom.create('option', { value: c, selected: c === val ? 'selected' : null, text: c })));
				case 'color':
					return BE.dom.create('input', { type: 'color', value: val, onchange: (e) => onChange(e.target.value) });
				case 'range':
					return BE.dom.create('input', { type: 'range', min: def.min, max: def.max, value: val, oninput: (e) => onChange(Number(e.target.value)) });
				case 'number':
					return BE.dom.create('input', { type: 'number', min: def.min, max: def.max, value: val, onchange: (e) => onChange(Number(e.target.value)) });
				case 'textarea':
					return BE.dom.create('textarea', { rows: 4, onchange: (e) => onChange(e.target.value) }, [val]);
				default:
					return BE.dom.create('input', { type: 'text', value: val, onchange: (e) => onChange(e.target.value) });
			}
		}

		function buildContent(filterText = '') {
			const content = BE.dom.create('div', { class: 'be-settings-content' });
			const cats = BE.settings.categories();
			for (const cat of cats) {
				const keys = BE.settings.byCategory(cat).filter((k) => {
					if (!filterText) return true;
					const def = BE.settings.SCHEMA[k];
					return def.label.toLowerCase().includes(filterText) || k.toLowerCase().includes(filterText);
				});
				if (!keys.length) continue;
				const section = BE.dom.create('div', { class: 'be-settings-section' }, [
					BE.dom.create('h3', { text: cat }),
				]);
				for (const key of keys) {
					const def = BE.settings.SCHEMA[key];
					const row = BE.dom.create('div', { class: 'be-settings-row' }, [
						BE.dom.create('label', { text: def.label }),
						fieldControl(key, def),
					]);
					section.appendChild(row);
				}
				content.appendChild(section);
			}
			return content;
		}

		function rebuild(filterText) {
			const content = BE.dom.qs('.be-settings-content', overlay);
			content.replaceWith(buildContent(filterText));
		}

		function open() {
			if (!overlay) {
				const search = BE.dom.create('input', {
					type: 'text', placeholder: 'Search settings…', class: 'be-settings-search',
					oninput: BE.dom.debounce((e) => rebuild(e.target.value.toLowerCase()), 120),
				});
				overlay = BE.dom.create('div', { class: 'be-settings-overlay be-hidden' }, [
					BE.dom.create('div', { class: 'be-settings-modal' }, [
						BE.dom.create('div', { class: 'be-settings-header' }, [
							BE.dom.create('h2', { text: `Booru Enhancer — Settings (v${BE.VERSION})` }),
							search,
							BE.dom.create('button', { class: 'be-settings-close', text: '✕', onclick: close }),
						]),
						buildContent(),
						BE.dom.create('div', { class: 'be-settings-footer' }, [
							BE.dom.create('button', { text: 'Export JSON', onclick: exportSettings }),
							BE.dom.create('button', { text: 'Import JSON', onclick: importSettings }),
							BE.dom.create('button', { text: 'Reset all to defaults', class: 'be-danger', onclick: () => { if (confirm('Reset ALL settings to defaults?')) { BE.settings.resetAll(); rebuild(''); } } }),
						]),
					]),
				]);
				overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
				document.body.appendChild(overlay);
			}
			overlay.classList.remove('be-hidden');
		}
		function close() { overlay?.classList.add('be-hidden'); }

		function exportSettings() {
			const blob = new Blob([BE.settings.exportJSON()], { type: 'application/json' });
			const a = BE.dom.create('a', { href: URL.createObjectURL(blob), download: 'booru-enhancer-settings.json' });
			document.body.appendChild(a); a.click(); a.remove();
		}
		function importSettings() {
			const input = BE.dom.create('input', { type: 'file', accept: 'application/json' });
			input.addEventListener('change', () => {
				const file = input.files[0];
				if (!file) return;
				const reader = new FileReader();
				reader.onload = () => { if (BE.settings.importJSON(reader.result)) rebuild(''); };
				reader.readAsText(file);
			});
			input.click();
		}

		return { open, close };
	})();

	/* ============================================================ *
	 *  STYLES
	 * ============================================================ */
	_GM.addStyle(`
		:root {
			--be-accent: #ff8ac6;
			--be-bg: #16161c;
			--be-bg-elevated: #202028;
			--be-fg: #eaeaf0;
			--be-fg-dim: #9a9aa8;
			--be-radius: 10px;
			--be-shadow: 0 8px 30px rgba(0,0,0,.45);
		}
		[data-be-theme="light"] {
			--be-bg: #f4f4f8; --be-bg-elevated: #ffffff; --be-fg: #1b1b22; --be-fg-dim: #666676;
		}
		.be-hidden { display: none !important; }

		/* Toolbar */
		.be-toolbar { position: fixed; z-index: 999997; display: flex; gap: 6px; padding: 6px;
			background: var(--be-bg-elevated); border-radius: var(--be-radius); box-shadow: var(--be-shadow); }
		.be-toolbar-bottom-right { right: 16px; bottom: 16px; }
		.be-toolbar-bottom-left { left: 16px; bottom: 16px; }
		.be-toolbar-top-right { right: 16px; top: 16px; }
		.be-toolbar-top-left { left: 16px; top: 16px; }
		.be-toolbar-btn { width: 38px; height: 38px; border-radius: 8px; border: none; cursor: pointer;
			background: transparent; color: var(--be-fg); font-size: 16px; transition: background .15s; }
		.be-toolbar-btn:hover { background: var(--be-accent); color: #111; }

		/* Thumbnail overlay */
		.be-thumb-wrap { position: relative; }
		.be-thumb-overlay { position: absolute; top: 4px; right: 4px; display: flex; gap: 4px; opacity: 0;
			transition: opacity .12s; z-index: 5; }
		.be-thumb-wrap:hover .be-thumb-overlay { opacity: 1; }
		.be-thumb-fav, .be-thumb-dl { width: 26px; height: 26px; border-radius: 50%; border: none; cursor: pointer;
			background: rgba(0,0,0,.65); color: #fff; font-size: 13px; line-height: 26px; padding: 0; }
		.be-thumb-fav.be-active { background: var(--be-accent); color: #111; }
		.be-thumb-badge { position: absolute; left: 4px; bottom: 4px; background: rgba(0,0,0,.7); color: #fff;
			font-size: 10px; padding: 2px 5px; border-radius: 4px; letter-spacing: .04em; }
		.be-filtered-hidden { display: none !important; }
		.be-selection-mode .be-thumb-wrap { outline: 2px dashed transparent; cursor: pointer; }
		.be-selected { outline: 2px solid var(--be-accent) !important; }

		/* Hover preview */
		.be-hover-preview { position: fixed; z-index: 999996; max-width: 320px; max-height: 420px;
			border-radius: var(--be-radius); overflow: hidden; box-shadow: var(--be-shadow); pointer-events: none; }
		.be-hover-preview img { display: block; width: 100%; height: auto; }

		/* Infinite scroll sentinel */
		.be-scroll-sentinel { width: 100%; text-align: center; padding: 24px; color: var(--be-fg-dim); grid-column: 1/-1; }

		/* Viewer */
		.be-viewer { position: fixed; inset: 0; z-index: 999999; background: rgba(6,6,10,.97);
			display: flex; flex-direction: column; }
		html.be-viewer-open { overflow: hidden; }
		.be-viewer-toolbar { display: flex; align-items: center; gap: 4px; padding: 8px 12px;
			background: rgba(0,0,0,.4); }
		.be-v-btn { width: 36px; height: 36px; border-radius: 8px; border: none; background: transparent;
			color: #eee; font-size: 16px; cursor: pointer; }
		.be-v-btn:hover { background: var(--be-accent); color: #111; }
		.be-v-counter { margin-left: auto; color: #ccc; font-variant-numeric: tabular-nums; padding-right: 8px; }
		.be-viewer-media { flex: 1; display: flex; align-items: center; justify-content: center; overflow: hidden;
			position: relative; cursor: grab; }
		.be-viewer-media img, .be-viewer-media video { transition: transform .05s linear; user-select: none; }
		.be-viewer-fit-fit-both { max-width: 96vw; max-height: 82vh; }
		.be-viewer-fit-fit-width { width: 96vw; height: auto; }
		.be-viewer-fit-fit-height { height: 82vh; width: auto; }
		.be-viewer-fit-original-size { max-width: none; max-height: none; }
		.be-viewer-info-panel { position: absolute; right: 16px; top: 60px; background: rgba(20,20,26,.92);
			border-radius: var(--be-radius); padding: 12px 16px; box-shadow: var(--be-shadow); }
		.be-info-table td { padding: 3px 10px 3px 0; font-size: 13px; color: #ddd; }
		.be-info-key { color: var(--be-fg-dim); }

		/* Tags */
		.be-tag-colored { color: var(--be-tag-color) !important; }
		.be-tag-collapsed { display: none !important; }
		.be-tag-expand-btn { display: block; margin-top: 6px; background: none; border: none;
			color: var(--be-accent); cursor: pointer; font-size: 12px; }

		/* Settings panel */
		.be-settings-overlay { position: fixed; inset: 0; z-index: 999998; background: rgba(0,0,0,.6);
			display: flex; align-items: center; justify-content: center; }
		.be-settings-modal { width: min(760px, 92vw); max-height: 84vh; display: flex; flex-direction: column;
			background: var(--be-bg); color: var(--be-fg); border-radius: var(--be-radius); box-shadow: var(--be-shadow); overflow: hidden; }
		.be-settings-header { display: flex; align-items: center; gap: 10px; padding: 14px 16px;
			background: var(--be-bg-elevated); }
		.be-settings-header h2 { font-size: 15px; margin: 0; white-space: nowrap; }
		.be-settings-search { flex: 1; padding: 6px 10px; border-radius: 6px; border: 1px solid #3a3a46;
			background: var(--be-bg); color: var(--be-fg); }
		.be-settings-close { background: none; border: none; color: var(--be-fg); font-size: 16px; cursor: pointer; }
		.be-settings-content { flex: 1; overflow-y: auto; padding: 8px 16px; }
		.be-settings-section h3 { font-size: 12px; text-transform: uppercase; letter-spacing: .06em;
			color: var(--be-accent); margin: 16px 0 6px; }
		.be-settings-row { display: flex; align-items: center; justify-content: space-between; gap: 12px;
			padding: 6px 0; border-bottom: 1px solid rgba(128,128,128,.12); }
		.be-settings-row label { font-size: 13px; color: var(--be-fg); flex: 1; }
		.be-settings-row input[type="text"], .be-settings-row textarea, .be-settings-row select, .be-settings-row input[type="number"] {
			background: var(--be-bg-elevated); color: var(--be-fg); border: 1px solid #3a3a46; border-radius: 6px; padding: 4px 8px; min-width: 160px; }
		.be-settings-footer { display: flex; gap: 8px; padding: 12px 16px; background: var(--be-bg-elevated); }
		.be-settings-footer button { padding: 7px 12px; border-radius: 6px; border: none; cursor: pointer;
			background: var(--be-accent); color: #111; font-weight: 600; }
		.be-settings-footer button.be-danger { background: #c0392b; color: #fff; }
	`);

	/* ============================================================ *
	 *  BOOTSTRAP
	 * ============================================================ */
	BE.core.currentPost = null;

	async function initCurrentPostPage(adapter) {
		if (!adapter.isPostPage()) return;
		const id = adapter.getPostId();
		if (!id) return;
		try {
			const post = await adapter.fetchPost(id);
			if (!post) return;
			BE.core.currentPost = post;
			BE.bus.emit('post:loaded', post);
			BE.modules.mediaLoader.onPostPage(post);

			// wire favorite button proxy, tag colorization, image info dock,
			// reverse-search + source-link buttons into the native post page.
			decoratePostPage(post, adapter);
		} catch (err) {
			BE.log.error('failed to load post metadata', err);
		}
	}

	function decoratePostPage(post, adapter) {
		// Tag colorization: color any tag links whose category we know.
		if (BE.settings.get('tags.colorize')) {
			const categoryMap = {
				artist: post.artists, character: post.characters, copyright: post.copyrights,
				general: post.generalTags, meta: post.metaTags,
			};
			for (const [cat, tags] of Object.entries(categoryMap)) {
				const set = new Set(tags);
				BE.dom.qsa('a[href*="tags="], .tag-link, li[class*="tag-type-"] a, li[class*="category-"] a').forEach((a) => {
					const t = (a.textContent || '').trim().replace(/\s+/g, '_').toLowerCase();
					if (set.has(t)) BE.modules.tagTools.colorizeCategory(a.closest('li') || a, cat);
				});
			}
		}
		// Collapse very long tag lists.
		const tagList = BE.dom.qs('#tag-list, ul#tag-sidebar, .tag-list');
		if (tagList) BE.modules.tagTools.buildCollapsible(tagList, BE.settings.get('tags.collapseThreshold'));

		// Info panel dock + reverse search + source link, appended near the image.
		const dock = BE.dom.create('div', { class: 'be-post-dock' });
		const infoToggle = BE.dom.create('button', { class: 'be-toolbar-btn', text: 'ℹ', title: 'Image info', onclick: () => {
			panel.classList.toggle('be-hidden');
		} });
		const panel = BE.dom.create('div', { class: 'be-hidden' }, [BE.modules.imageInfo.render(post)]);
		const reverseBtns = Object.keys(BE.modules.reverseSearch.ENGINES).map((engine) =>
			BE.dom.create('button', { class: 'be-toolbar-btn', text: engine[0].toUpperCase(), title: `Reverse search: ${engine}`, onclick: () => BE.modules.reverseSearch.open(engine, post.originalUrl) }));
		dock.append(infoToggle, ...reverseBtns, panel);

		if (post.source) {
			const platform = BE.modules.sourceLinks.detect(post.source);
			const srcRow = BE.dom.create('div', {}, [
				BE.dom.create('a', { href: post.source, target: '_blank', rel: 'noopener', text: `${platform ? platform.icon + ' ' : ''}Source` }),
			]);
			dock.appendChild(srcRow);
		}
		const insertTarget = BE.dom.qs(adapter.gridContainerSelector) || document.body;
		(BE.dom.qs('#image, #main_image')?.closest('div') || insertTarget).appendChild(dock);
	}

	function initGalleryPage(adapter) {
		BE.modules.gallery.scan();
		BE.modules.gallery.initInfiniteScroll();
		const observer = new MutationObserver(BE.dom.debounce(() => BE.modules.gallery.scan(), 200));
		const container = BE.dom.qs(adapter.gridContainerSelector) || document.body;
		observer.observe(container, { childList: true, subtree: true });
	}

	function init() {
		if (!BE.settings.get('general.enabled')) { BE.log.info('disabled for this site, skipping init'); return; }

		BE.adapters.active = BE.core.detectAdapter();
		BE.log.info(`v${BE.VERSION} initializing with adapter "${BE.adapters.active.id}" on ${location.hostname}`);

		BE.modules.ui.init();
		BE.modules.keybinds.init();

		initCurrentPostPage(BE.adapters.active);
		initGalleryPage(BE.adapters.active);

		_GM.registerMenuCommand('Booru Enhancer: Settings', () => BE.modules.settingsPanel.open());
		_GM.registerMenuCommand('Booru Enhancer: Toggle enabled', () => {
			BE.settings.set('general.enabled', !BE.settings.get('general.enabled'));
			location.reload();
		});

		BE.bus.emit('core:ready', {});
	}

	BE.store.whenReady(() => BE.dom.ready(init));
})();