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.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

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