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 για να εγκαταστήσετε αυτόν τον κώδικα.

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

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey ή το Violentmonkey για να εγκαταστήσετε αυτόν τον κώδικα.

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey ή το Userscripts για να εγκαταστήσετε αυτόν τον κώδικα.

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

Θα χρειαστεί να εγκαταστήσετε μια επέκταση διαχείρισης κώδικα χρήστη για να εγκαταστήσετε αυτόν τον κώδικα.

(Έχω ήδη έναν διαχειριστή κώδικα χρήστη, επιτρέψτε μου να τον εγκαταστήσω!)

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.

(Έχω ήδη έναν διαχειριστή στυλ χρήστη, επιτρέψτε μου να τον εγκαταστήσω!)

// ==UserScript==
// @name         Booru Enhancer
// @namespace    https://sleazyfork.org/en/scripts/587810-booru-enhancer
// @version      1.2.7
// @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  —  v1.2.7
 * =============================================================================
 *  CHANGELOG (1.2.6 → 1.2.7) — regression-review follow-up
 *  ------------------------------------------------------------------------
 *  - Gallery click/hover delegation moved from capture phase to bubble
 *    phase. preventDefault() still stops the default navigation either
 *    way (the browser only commits it after the full capture+bubble
 *    dispatch completes), but bubble phase no longer runs our logic
 *    ahead of any click handlers the site itself has on the thumbnail.
 *  - Gelbooru-family wrapper resolution is now validated, not just
 *    pattern-matched: a candidate wrapper is only accepted if it contains
 *    exactly one thumbnail image and resolves to the SAME post ID as the
 *    image itself. This specifically tightens the generic
 *    [data-id]/[data-post-id] fallback (used by some Gelbooru-family
 *    mirrors with non-standard markup), which was broad enough to
 *    potentially match a multi-post container.
 *  - Removed the hard-coded "+42" Gelbooru pagination fallback.
 *    calculateNextUrl() now only extrapolates a next page from a
 *    genuinely observed page-size delta; with no observation yet it
 *    reports "no next page" so native pagination is used instead of a
 *    fabricated URL — this was contradicting the "no fabricated
 *    pagination" pagination-safety fix from 1.2.5/1.2.6.
 *
 *  CHANGELOG (1.2.5 → 1.2.6) — Gelbooru-family interaction/settings fixes
 *  ------------------------------------------------------------------------
 *  - Root cause fix: thumbnail "wrapper" detection no longer uses a broad
 *    img.closest('article, span, div, a'). Every adapter now provides an
 *    explicit getThumbWrapper(img), so exactly one element per post is
 *    tagged .be-thumb-wrap — never an unrelated ancestor, and never the
 *    gallery container itself.
 *  - This one fix is what was silently breaking the viewer, hover preview,
 *    favorite/download actions, and grid sizing together: click/hover
 *    delegation used to fall back to a broad selector list that could
 *    match the gallery container as "the thumbnail", and .be-thumb-wrap
 *    could land on the wrong DOM node so grid sizing CSS never reached
 *    the actual grid item.
 *  - Click/hover delegation now only ever recognizes '.be-thumb-wrap' —
 *    no more matching arbitrary article/span/div/a ancestors.
 *  - Hover preview no longer flickers: pointerout is checked against
 *    relatedTarget so it only fires once the pointer truly leaves the
 *    thumbnail, not on every internal child boundary crossing.
 *  - gallery.thumbnailSize, gallery.gridDensity, and gallery.gridGap now
 *    visibly affect the grid immediately, because the wrapper receiving
 *    the sizing CSS is the actual grid item; site-native float/width on
 *    that element is neutralized so our grid governs layout.
 *  - gallery.compactMode now has a real, visible effect (denser square
 *    thumbnails, smaller action buttons) instead of only toggling a class
 *    with no CSS consumer.
 *  - --be-thumbnail-size is now actually set as a CSS custom property
 *    (previously computed in JS but never written to the DOM).
 *  - The viewer/action-button click path now only ever depends on
 *    '.be-thumb-wrap' + the post ID, never on metadata enrichment — so it
 *    keeps working immediately even if the API/HTML enrichment fails.
 *  - Thumbnails inserted by infinite scroll now go through the same
 *    wrapper-detection + action-bar-building path as initial thumbnails
 *    (a cloned node carries no live JS listeners, so the per-thumbnail
 *    hover action bar is rebuilt explicitly rather than assumed to work).
 *  - Preserved the 1.2.5 pagination-safety fixes (idempotent init/setup,
 *    loop detection, native paginator restoration) and the no-overlap
 *    grid fix (min-width/min-height/overflow/aspect-ratio/object-fit).
 * =============================================================================
 */
(function booruEnhancer() {
	'use strict';

	if (window.top !== window.self) return;
	if (window.__BOORU_ENHANCER_LOADED__) return;
	window.__BOORU_ENHANCER_LOADED__ = true;

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

	/* ============================================================ *
	 *  EVENT BUS
	 * ============================================================ */
	BE.bus = (() => {
		const listeners = new Map();
		return {
			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;
			},
			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
	 * ============================================================ */
	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(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));
		 }
	 };
 },

 ready(fn) {
	 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fn, { once: true });
	 else fn();
 },

 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
	 * ============================================================ */
	BE.store = (() => {
		const NS = 'be:';
		const cache = new Map();
		let ready = false;
		const readyCallbacks = [];

		function safeParse(raw) {
			if (raw === undefined || raw === null) return { ok: false };
			if (typeof raw !== 'string') return { ok: true, value: raw };
			try { return { ok: true, value: JSON.parse(raw) }; }
			catch { return { ok: false }; }
		}

		async function init() {
			try {
				const keys = await _GM.listValues();
				await Promise.all((keys || []).filter((k) => typeof k === 'string' && k.startsWith(NS)).map(async (k) => {
					let raw;
					try { raw = await _GM.getValue(k); } catch (err) { BE.log.error('store.get failed for', k, err); return; }
					const parsed = safeParse(raw);
					if (parsed.ok) cache.set(k.slice(NS.length), parsed.value);
				}));
			} 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 isReady() { return ready; },
				get(key, fallback) { return cache.has(key) ? cache.get(key) : fallback; },
				set(key, value) {
					cache.set(key, value);
					Promise.resolve(_GM.setValue(NS + key, JSON.stringify(value))).catch((e) => {
						BE.log.error('store.set failed to persist', key, e);
					});
				},
				delete(key) {
					cache.delete(key);
					Promise.resolve(_GM.deleteValue(NS + key)).catch(() => {});
				},
				keys(prefix = '') { return [...cache.keys()].filter((k) => k.startsWith(prefix)); },
				_init: init,
		};
	})();
	BE.store._init();

	/* ============================================================ *
	 *  SETTINGS SCHEMA
	 * ============================================================ */
	BE.settings = (() => {
		const SCHEMA = {
			'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.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' },

			'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.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.infiniteScroll': { cat: 'Gallery', type: 'bool', def: true, label: 'Infinite scrolling (replaces pagination)' },
				   'gallery.gridDensity': { cat: 'Gallery', type: 'range', def: 0, min: 0, max: 10, label: 'Grid columns (0 = auto-fit by size)' },
				   'gallery.thumbnailSize': { cat: 'Gallery', type: 'range', def: 220, min: 120, max: 500, label: 'Thumbnail size (px)' },
				   'gallery.gridGap': { cat: 'Gallery', type: 'range', def: 8, min: 0, max: 20, label: 'Grid gap (px)' },
				   'gallery.compactMode': { cat: 'Gallery', type: 'bool', def: false, label: 'Compact mode' },

				   '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' },

				   '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' },

				   '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.verboseLogging': { cat: 'Debug', type: 'bool', def: false, label: 'Verbose console logging' },
		};

		const values = new Map();
		let loaded = false;

		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));
			}
			loaded = true;
		}

		return {
			SCHEMA,
			get isLoaded() { return loaded; },
				   get(key) {
					   if (!loaded) {
						   BE.log.debug('settings.get() called before load — returning default for', key);
						   return keyDefault(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;
						   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,
		};
	})();

	/* ============================================================ *
	 *  NETWORK
	 * ============================================================ */
	BE.net = {
		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 (res.status === 429) {
								const delay = Math.pow(2, (retries - n + 1)) * 1000;
								BE.log.warn(`[Gallery] 429 Rate limit. Retrying in ${delay/1000}s...`);
								if (n > 0) {
									setTimeout(() => attempt(n - 1), delay);
								} else {
									reject(new Error(`HTTP 429 Rate limit exceeded for ${opts.url}`));
								}
							} 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
	 * ============================================================ */
	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 };

	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');
	};

	/* ============================================================ *
	 *  PAGINATION HELPER UTILITIES
	 * ============================================================ */

	function safeURL(href, base = location.href) {
		try { return new URL(href, base); } catch { return null; }
	}

	function findFirstNextLink(doc, selectors) {
		for (const sel of selectors) {
			try {
				const link = doc.querySelector(sel);
				if (link && link.href) return link;
			} catch { /* invalid selector for this doc type */ }
		}
		return null;
	}

	function findAdvancingPidLink(doc, paramName, currentVal) {
		const links = Array.from(doc.querySelectorAll(`a[href*="${paramName}="]`));
		const candidates = [];
		for (const a of links) {
			const u = safeURL(a.href);
			if (!u) continue;
			const val = parseInt(u.searchParams.get(paramName) || '0', 10);
			if (!isNaN(val) && val > currentVal) candidates.push({ href: a.href, val });
		}
		if (!candidates.length) return null;
		candidates.sort((a, b) => a.val - b.val);
		return candidates[0].href;
	}

	/* ============================================================ *
	 *  DANBOORU ADAPTER
	 * ============================================================ */
	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: (root) => BE.dom.qsa('article[id^="post_"] img, .post-preview img', root),
							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;
							},
							// Requirement 3: the actual post card, not a generic
							// "closest article/span/div/a" — that would grab the
							// inner <a> (or worse) instead of the real per-post
							// container that CSS grid sizing needs to land on.
							getThumbWrapper: (img) => img.closest('article[id^="post_"], .post-preview') || img.closest('a[href*="/posts/"]') || img.parentElement,
							getGalleryContainer: (root = document) => root.querySelector('#posts-container, .posts-container, #post-list'),
							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',
							pagination: {
								containerSelectors: ['.paginator', '#paginator', 'nav.pagination', 'section#paginating-nav'],
								getPageIdentity(url) {
									const u = safeURL(url);
									if (!u) return '1';
									return u.searchParams.get('page') || '1';
								},
								getNextUrl(doc) {
									const link = findFirstNextLink(doc, [
										'a[rel="next"]', 'a.next', 'a.next_page',
										'.paginator a[rel="next"]', 'nav.pagination a[rel="next"]',
										'section#paginating-nav a[rel="next"]',
									]);
									return link?.href || null;
								},
								calculateNextUrl(currentUrl) {
									const u = safeURL(currentUrl);
									if (!u) return null;
									const page = parseInt(u.searchParams.get('page') || '1', 10);
									u.searchParams.set('page', String(page + 1));
									return u.toString();
								},
								getPostIdentity(el) {
									const art = el.closest('[id^="post_"], article.post-preview');
									return art?.id?.match(/post_(\d+)/)?.[1] || null;
								},
							},
	});

	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-FAMILY ADAPTER
	 * ============================================================ */
	function gelbooruPostId(el) {
		const a = el.closest('a');
		const href = a?.getAttribute('href') || '';
		const byHref = href.match(/[?&]id=(\d+)/);
		if (byHref) return byHref[1];
		const byAnchorId = a?.id?.match(/^p(\d+)$/);
		if (byAnchorId) return byAnchorId[1];
		const wrapper = el.closest('[data-id], [data-post-id]');
		if (wrapper) return wrapper.dataset.id || wrapper.dataset.postId;
		const byImgId = el.id?.match(/\d+/);
		return byImgId ? byImgId[0] : null;
	}

	// Follow-up review fix: try wrapper candidates in order of specificity
	// and VALIDATE each one before accepting it, instead of trusting the
	// first ancestor that matches any selector in the list. The generic
	// [data-id]/[data-post-id] fallback in particular exists for
	// Gelbooru-family mirror sites with non-standard markup, but is broad
	// enough that it could match a container holding more than one post.
	// A wrapper is only accepted if it holds exactly one thumbnail image
	// and resolves to the SAME post ID as the image itself.
	function gelbooruThumbWrapper(img) {
		const selectors = ['.thumbnail-preview', 'article.thumbnail-preview', 'span.thumb', '.thumb', '[data-id]', '[data-post-id]'];
		const imgId = gelbooruPostId(img);
		for (const sel of selectors) {
			let el;
			try { el = img.closest(sel); } catch { continue; }
			if (!el) continue;
			if (el.querySelectorAll('img').length !== 1) continue; // more than one post's worth of content
			const wrapId = gelbooruPostId(el);
			if (imgId && wrapId && wrapId !== imgId) continue; // wrapper resolves to a different post
			return el;
		}
		return img.closest('a') || img.parentElement;
	}

	let _gelbooruObservedPageSize = null;

	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: (root) => BE.dom.qsa(
								'.thumbnail-preview img, article.thumbnail-preview img, span.thumb img, #post-list img.preview, #post-list-posts img, .thumb img',
								root,
							),
							getThumbPostId: (img) => gelbooruPostId(img),
							// Requirement 3 (+ follow-up review): identify the
							// actual post/card element, not an arbitrary
							// "closest div" — and validate the candidate
							// (exactly one thumbnail image, same resolved post
							// ID as the image) before accepting it, since the
							// [data-id]/[data-post-id] fallback used by some
							// Gelbooru-family mirrors is otherwise too generic
							// to trust blindly.
							getThumbWrapper: (img) => gelbooruThumbWrapper(img),
							getGalleryContainer: (root = document) => root.querySelector('#post-list-posts, #post-list, .content'),
							async fetchPost(id) {
								try {
									const posts = await gelbooruApiFetch([id]);
									if (posts[0]) return posts[0];
								} catch (err) {
									BE.log.warn('gelbooru: dapi fetchPost failed, falling back to HTML parse', err);
								}
								return gelbooruFetchPostHTML(id);
							},
							async fetchThumbBatch(ids) {
								if (!ids.length) return [];
								try {
									const results = await gelbooruApiFetch(ids);
									if (results.length) return results;
								} catch (err) {
									BE.log.warn('gelbooru: dapi batch fetch failed, falling back to HTML parsing', err);
								}
								return gelbooruFetchPostsHTMLBounded(ids, 3);
							},
							favoriteSelector: 'a[href*="s=favorite"], #favorite-button',
							pagination: {
								containerSelectors: ['#paginator', '.pagination', '.pagination-controls'],
								getPageIdentity(url) {
									const u = safeURL(url);
									if (!u) return '0';
									const pid = u.searchParams.get('pid') || '0';
									const tags = u.searchParams.get('tags') || '';
									const s = u.searchParams.get('s') || '';
									return `${tags}|${s}|${pid}`;
								},
								getNextUrl(doc) {
									const nextLink = findFirstNextLink(doc, [
										'#paginator a[alt="next"]', '#paginator a[title="next"]', '#paginator a.next',
										'.pagination a[alt="next"]', '.pagination a[title="next"]', '.pagination a.next',
										'a[alt="next"]', 'a[rel="next"]', 'a.next',
									]);
									if (nextLink) {
										const currentPid = parseInt(this.getPageIdentity(location.href).split('|').pop() || '0', 10) || 0;
										const nextPid = parseInt(this.getPageIdentity(nextLink.href).split('|').pop() || '0', 10) || 0;
										if (nextPid > currentPid && nextPid - currentPid <= 200) {
											_gelbooruObservedPageSize = nextPid - currentPid;
										}
										return nextLink.href;
									}

									const currentPidStr = this.getPageIdentity(location.href).split('|').pop() || '0';
									const currentPid = parseInt(currentPidStr, 10) || 0;
									const advancingHref = findAdvancingPidLink(doc, 'pid', currentPid);
									if (advancingHref) {
										const advPid = parseInt(safeURL(advancingHref)?.searchParams.get('pid') || '0', 10) || 0;
										if (advPid > currentPid && advPid - currentPid <= 200) {
											_gelbooruObservedPageSize = advPid - currentPid;
										}
										return advancingHref;
									}

									return null;
								},
								calculateNextUrl(currentUrl) {
									const u = safeURL(currentUrl);
									if (!u) return null;
									// Follow-up review fix: no more hard-coded "+42"
									// guess. Only extrapolate a next page once we've
									// actually observed a real page-size delta from a
									// genuine next-page link (see getNextUrl above);
									// otherwise report "no next page" so the caller
									// falls back to the site's native pagination
									// instead of fabricating a URL.
									if (!_gelbooruObservedPageSize) return null;
									const currentPid = parseInt(u.searchParams.get('pid') || '0', 10) || 0;
									u.searchParams.set('pid', String(currentPid + _gelbooruObservedPageSize));
									return u.toString();
								},
								getPostIdentity(el) {
									return gelbooruPostId(el);
								},
							},
	});

	let _gelbooruApiHealthy = true;

	async function gelbooruApiFetch(ids) {
		const url = `${location.origin}/index.php?page=dapi&s=post&q=index&json=1&tags=${encodeURIComponent('id:' + ids.join(','))}&limit=${ids.length}`;
		let data;
		try {
			const res = await fetch(url, { credentials: 'same-origin', headers: { Accept: 'application/json' } });
			if (!res.ok) throw new Error(`HTTP ${res.status}`);
			data = await res.json();
			_gelbooruApiHealthy = true;
		} catch (fetchErr) {
			BE.log.debug('gelbooru: same-origin fetch failed, trying GM.xhr', fetchErr);
			data = await BE.net.json(url, { retries: 1 });
			_gelbooruApiHealthy = true;
		}
		const list = Array.isArray(data) ? data : (data?.post || []);
		if (!Array.isArray(list) || !list.length) return [];
		return list.map(normalizeGelbooru);
	}

	async function gelbooruFetchPostHTML(id) {
		try {
			const url = `${location.origin}/index.php?page=post&s=view&id=${id}`;
			const res = await BE.net.request({ url }, 1);
			return parseGelbooruPostHTML(res.responseText, id);
		} catch (err) {
			BE.log.warn(`gelbooru: HTML fallback failed for post ${id}`, err);
			return null;
		}
	}

	async function gelbooruFetchPostsHTMLBounded(ids, concurrency = 3) {
		const results = [];
		let i = 0;
		async function worker() {
			while (i < ids.length) {
				const id = ids[i++];
				const post = await gelbooruFetchPostHTML(id);
				if (post) results.push(post);
				await new Promise((r) => setTimeout(r, 150));
			}
		}
		await Promise.all(Array.from({ length: Math.min(concurrency, ids.length) }, worker));
		return results;
	}

	function parseGelbooruPostHTML(html, id) {
		const doc = new DOMParser().parseFromString(html, 'text/html');
		const img = doc.querySelector('#image, img#image');
		const video = doc.querySelector('video source, video#gelcomVideoPlayer source, video');
		const originalLink = doc.querySelector('a.download-link, a[download], li a[href*="/samples/"], li a[href*="/images/"]');
		const original = video?.getAttribute('src') || originalLink?.getAttribute('href') || img?.getAttribute('src') || '';
		const sample = img?.getAttribute('src') || original;
		const tagLinks = Array.from(doc.querySelectorAll('.tag-list li, #tag-sidebar li, li[class*="tag-type-"]'));
		const tags = [];
		const artists = [], characters = [], copyrights = [];
		for (const li of tagLinks) {
			const a = li.querySelector('a[href*="tags="]') || li.querySelector('a');
			const text = (a?.textContent || '').trim().replace(/\s+/g, '_').toLowerCase();
			if (!text) continue;
			tags.push(text);
			const cls = li.className || '';
			if (/tag-type-artist|category-1/.test(cls)) artists.push(text);
			else if (/tag-type-character|category-4/.test(cls)) characters.push(text);
			else if (/tag-type-copyright|category-3/.test(cls)) copyrights.push(text);
		}
		const ratingText = (doc.querySelector('[id*="rating"]')?.textContent || '').toLowerCase();
		const rating = ratingText.includes('explicit') ? 'explicit' : ratingText.includes('question') ? 'questionable' : ratingText.includes('safe') ? 'safe' : 'unknown';
		const dims = (img?.getAttribute('data-original-width') && img?.getAttribute('data-original-height'))
		? { w: Number(img.getAttribute('data-original-width')), h: Number(img.getAttribute('data-original-height')) }
		: { w: img?.naturalWidth || 0, h: img?.naturalHeight || 0 };

		if (!original && !tags.length) return null;

		return emptyPost({
			id: String(id),
						 originalUrl: original,
						 sampleUrl: sample,
						 previewUrl: sample,
						 mediaType: video ? 'video' : guessMediaType(original),
						 width: dims.w,
						 height: dims.h,
						 rating,
						 artists, characters, copyrights,
						 generalTags: tags,
						 allTags: tags,
						 postUrl: `${location.origin}/index.php?page=post&s=view&id=${id}`,
						 siteId: 'gelbooru-family',
		});
	}

	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 ADAPTER
	 * ============================================================ */
	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: (root) => BE.dom.qsa('a.thumb img, ul#post-list-posts img.preview, .post-preview img', root),
							getThumbPostId: (img) => {
								const a = img.closest('a');
								const href = a?.getAttribute('href') || '';
								return (href.match(/\/post\/show\/(\d+)/) || [])[1] || null;
							},
							getThumbWrapper: (img) => img.closest('li, .post-preview, a.thumb') || img.parentElement,
							getGalleryContainer: (root = document) => root.querySelector('#post-list-posts, #post-list, .content'),
							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',
							pagination: {
								containerSelectors: ['.pagination', '#paginator', '.pagination-controls'],
								getPageIdentity(url) {
									const u = safeURL(url);
									if (!u) return '1';
									return u.searchParams.get('page') || '1';
								},
								getNextUrl(doc) {
									const link = findFirstNextLink(doc, [
										'a.next_page', 'a[rel="next"]', 'a.next',
										'.pagination a.next_page', '.pagination a[rel="next"]',
									]);
									return link?.href || null;
								},
								calculateNextUrl(currentUrl) {
									const u = safeURL(currentUrl);
									if (!u) return null;
									const page = parseInt(u.searchParams.get('page') || '1', 10);
									u.searchParams.set('page', String(page + 1));
									return u.toString();
								},
								getPostIdentity(el) {
									const a = el.closest('a');
									const href = a?.getAttribute('href') || '';
									return (href.match(/\/post\/show\/(\d+)/) || [])[1] || null;
								},
							},
	});

	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 ADAPTER
	 * ============================================================ */
	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: (root) => BE.dom.qsa('article.post-preview img, #posts-container img', root),
							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;
							},
							getThumbWrapper: (img) => img.closest('article.post-preview, article[id^="post_"]') || img.closest('a') || img.parentElement,
							getGalleryContainer: (root = document) => root.querySelector('#posts-container, .posts-container'),
							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',
							pagination: {
								containerSelectors: ['#paginator', 'nav.paginator', 'section#paginating-nav'],
								getPageIdentity(url) {
									const u = safeURL(url);
									if (!u) return '1';
									return u.searchParams.get('page') || u.searchParams.get('b') || u.searchParams.get('a') || '1';
								},
								getNextUrl(doc) {
									const link = findFirstNextLink(doc, [
										'a[rel="next"]', 'a.next',
										'#paginator a[rel="next"]', 'nav.paginator a[rel="next"]',
										'section#paginating-nav a[rel="next"]',
									]);
									return link?.href || null;
								},
								calculateNextUrl(currentUrl) {
									const u = safeURL(currentUrl);
									if (!u) return null;
									if (u.searchParams.has('b') || u.searchParams.has('a')) return null;
									const page = parseInt(u.searchParams.get('page') || '1', 10);
									u.searchParams.set('page', String(page + 1));
									return u.toString();
								},
								getPostIdentity(el) {
									const art = el.closest('article[id^="post_"]');
									return art?.id?.match(/post_(\d+)/)?.[1] || null;
								},
							},
	});

	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 ADAPTER
	 * ============================================================ */
	let _genericObservedPageSize = null;

	BE.core.registerAdapter({
		id: 'generic',
		hostPattern: /.*/,
		priority: -1,
		isPostPage: () => !!BE.dom.qs('#image, #main_image, .image-container img, main img.post-image'),
							getPostId: () => (location.pathname.match(/(\d{3,})/) || [])[1] || location.href,
							getThumbElements: (root) => BE.dom.qsa('img.preview, img.thumb, .thumbnail img, .post-thumbnail img', root),
							getThumbPostId: (img) => {
								const a = img.closest('a');
								const href = a?.getAttribute('href') || '';
								return (href.match(/(\d{3,})/) || [])[0] || href || null;
							},
							getThumbWrapper: (img) => img.closest('.thumbnail, .post-thumbnail, a') || img.parentElement,
							getGalleryContainer: (root = document) => root.querySelector('.content, #post-list, body'),
							async fetchPost() {
								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 []; },
							favoriteSelector: 'a[href*="favorite" i], button[class*="favorite" i]',
							pagination: {
								containerSelectors: ['.pagination', '#paginator', '.pagination-controls', 'nav.paginator'],
						 getPageIdentity(url) {
							 const u = safeURL(url);
							 if (!u) return '1';
							 return u.searchParams.get('pid') || u.searchParams.get('page') || '1';
						 },
						 getNextUrl(doc) {
							 // 1. Explicit next-page link is always the most trustworthy signal.
							 const link = findFirstNextLink(doc, [
								 'a[rel="next"]', 'a.next',
								 'a[aria-label*="next" i]', 'a[title*="next" i]',
								 '.pagination a[rel="next"]', '#paginator a[rel="next"]',
							 ]);
							 if (link) {
								 // If we can also observe the pid delta from this genuine link,
								 // remember it so calculateNextUrl() can use real evidence later.
								 const curPidStr = safeURL(location.href)?.searchParams.get('pid');
								 const nextPidStr = safeURL(link.href)?.searchParams.get('pid');
								 if (curPidStr !== undefined && nextPidStr !== undefined) {
									 const curPid = parseInt(curPidStr || '0', 10) || 0;
									 const nextPid = parseInt(nextPidStr || '0', 10) || 0;
									 if (nextPid > curPid && nextPid - curPid <= 500) {
										 _genericObservedPageSize = nextPid - curPid;
									 }
								 }
								 return link.href;
							 }

							 // 2. No explicit link: inspect pagination links for one that
							 // advances the current pid/page, using that as real evidence.
							 const currentPid = parseInt(this.getPageIdentity(location.href), 10) || 0;
							 if (location.href.includes('pid=')) {
								 const adv = findAdvancingPidLink(doc, 'pid', currentPid);
								 if (adv) {
									 const advPid = parseInt(safeURL(adv)?.searchParams.get('pid') || '0', 10) || 0;
									 if (advPid > currentPid && advPid - currentPid <= 500) {
										 _genericObservedPageSize = advPid - currentPid;
									 }
									 return adv;
								 }
							 }

							 const currentPage = parseInt(safeURL(location.href)?.searchParams.get('page') || '1', 10) || 1;
							 const pageLinks = Array.from(doc.querySelectorAll('a[href*="page="]'));
							 const candidates = [];
							 for (const a of pageLinks) {
								 const u = safeURL(a.href);
								 if (!u) continue;
								 const pg = parseInt(u.searchParams.get('page') || '1', 10);
								 if (!isNaN(pg) && pg > currentPage) candidates.push({ href: a.href, pg });
							 }
							 if (candidates.length) {
								 candidates.sort((a, b) => a.pg - b.pg);
								 return candidates[0].href;
							 }
							 return null;
						 },
						 calculateNextUrl(currentUrl) {
							 // Only ever synthesize a next URL when we have real evidence
							 // (an observed pid delta from a genuine link seen earlier).
							 // Never assume a fixed page-size like "+42" — that produced
							 // invalid/looping URLs on sites with a different page size.
							 const u = safeURL(currentUrl);
							 if (!u) return null;
							 if (u.searchParams.has('page')) {
								 const page = parseInt(u.searchParams.get('page'), 10) || 1;
								 u.searchParams.set('page', String(page + 1));
								 return u.toString();
							 }
							 if (u.searchParams.has('pid') && _genericObservedPageSize) {
								 const pid = parseInt(u.searchParams.get('pid'), 10) || 0;
								 u.searchParams.set('pid', String(pid + _genericObservedPageSize));
								 return u.toString();
							 }
							 return null;
						 },
						 getPostIdentity(el) {
							 const a = el.closest('a');
							 const href = a?.getAttribute('href') || '';
							 return (href.match(/(\d{3,})/) || [])[0] || href || null;
						 },
							},
	});

	/* ============================================================ *
	 *  NAMING
	 * ============================================================ */
	BE.naming = {
		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());
	}

	/* ============================================================ *
	 *  TOAST / STATUS NOTIFICATIONS
	 * ============================================================ */
	BE.modules = BE.modules || {};
	BE.modules.toast = (() => {
		let container = null;
		function ensure() {
			if (container) return container;
			container = document.createElement('div');
			container.id = 'be-toast-container';
			container.style.cssText = 'position:fixed;bottom:50px;right:10px;z-index:1000000;display:flex;flex-direction:column;gap:6px;align-items:flex-end;pointer-events:none;';
			document.body.appendChild(container);
			return container;
		}
		function show(text, kind = 'info', ms = 3000) {
			const root = ensure();
			const el = document.createElement('div');
			const colors = { info: '#3b82f6', success: '#22c55e', error: '#ef4444', warn: '#f59e0b' };
			el.textContent = text;
			el.style.cssText = `background:${colors[kind] || colors.info};color:#fff;padding:6px 12px;border-radius:4px;font:13px/1.4 sans-serif;box-shadow:0 2px 6px rgba(0,0,0,.3);max-width:320px;pointer-events:auto;`;
			root.appendChild(el);
			setTimeout(() => { el.style.transition = 'opacity .3s'; el.style.opacity = '0'; setTimeout(() => el.remove(), 300); }, ms);
			return el;
		}
		return { show };
	})();

	/* ============================================================ *
	 *  DOWNLOADER
	 * ============================================================ */
	BE.modules.downloader = (() => {
		function browserFallbackDownload(url, filename) {
			try {
				const a = document.createElement('a');
				a.href = url;
				a.download = filename;
				a.rel = 'noopener';
				a.target = '_blank';
				document.body.appendChild(a);
				a.click();
				a.remove();
				return true;
			} catch (err) {
				BE.log.error('[Download] browser fallback failed', err);
				return false;
			}
		}

		async function resolveOriginalUrl(post) {
			if (post?.originalUrl) return post;
			BE.log.debug('[Download] no originalUrl yet, fetching metadata for', post?.id);
			try {
				const fetched = await BE.adapters.active.fetchPost(post.id);
				if (fetched?.originalUrl) return fetched;
			} catch (err) {
				BE.log.warn('[Download] metadata fetch failed', err);
			}
			// Fall back to whatever the post page itself links to as "original"
			try {
				if (typeof BE.adapters.active.getOriginalUrl === 'function') {
					const url = BE.adapters.active.getOriginalUrl();
					if (url) return { ...post, originalUrl: url };
				}
			} catch { /* ignore */ }
			return post;
		}

		async function downloadPost(post) {
			if (!post) {
				BE.log.warn('[Download] no post supplied');
				BE.modules.toast.show('Download failed: nothing to download', 'error');
				return false;
			}

			BE.modules.toast.show('Preparing download…', 'info', 1500);

			let resolved = post;
			if (!resolved.originalUrl) {
				resolved = await resolveOriginalUrl(post);
			}

			if (!resolved.originalUrl) {
				BE.log.warn('[Download] failed: no original URL could be resolved for', post.id);
				BE.modules.toast.show('Download failed: could not find original media URL', 'error');
				return false;
			}

			const filename = BE.naming.build(resolved) + '.' + BE.naming.extensionFor(resolved);
			BE.log.debug('[Download] starting', resolved.originalUrl, '->', filename);
			BE.modules.toast.show('Downloading…', 'info', 1500);

			const retries = BE.settings.get('download.retries') ?? 3;

			return new Promise((resolvePromise) => {
				let settled = false;
				const finishOk = () => {
					if (settled) return;
					settled = true;
					BE.log.debug('[Download] complete', filename);
					BE.modules.toast.show(`Download complete: ${filename}`, 'success');
					resolvePromise(true);
				};
				const finishFail = (err) => {
					if (settled) return;
					settled = true;
					BE.log.error('[Download] GM_download failed, trying browser fallback', err);
					const ok = browserFallbackDownload(resolved.originalUrl, filename);
					if (ok) {
						BE.modules.toast.show(`Download started via browser: ${filename}`, 'success');
					} else {
						BE.modules.toast.show('Download failed', 'error');
					}
					resolvePromise(ok);
				};

				try {
					const maybePromise = _GM.download({
						url: resolved.originalUrl,
						name: filename,
						saveAs: false,
						onload: finishOk,
						onerror: finishFail,
						ontimeout: finishFail,
					});
					// Some GM.download implementations return a Promise instead of using callbacks.
					if (maybePromise && typeof maybePromise.then === 'function') {
						maybePromise.then(finishOk).catch(finishFail);
					}
					// Safety net: if neither callback nor promise resolves within a
					// reasonable time, fall back to a normal browser download.
					setTimeout(() => { if (!settled) finishFail(new Error('GM_download timed out')); }, 8000);
				} catch (err) {
					finishFail(err);
				}
			});
		}

		return { downloadPost, browserFallbackDownload, resolveOriginalUrl };
	})();

	/* ============================================================ *
	 *  FAVORITES
	 * ============================================================ */
	BE.modules.favorites = (() => {
		function supported() {
			return !!BE.adapters.active?.favoriteSelector;
		}

		function findFavoriteControl(root = document) {
			const sel = BE.adapters.active?.favoriteSelector;
			if (!sel) return null;
			try { return root.querySelector(sel); } catch { return null; }
		}

		// Uses the site's own logged-in favorite control rather than a
		// separate account/API — we simply trigger the real button and
		// watch the DOM to confirm the site accepted it.
		async function toggle(post) {
			if (!supported()) {
				BE.log.warn('[Favorites] not supported on this adapter');
				BE.modules.toast.show('Favoriting is not supported on this site', 'warn');
				return false;
			}

			let control = findFavoriteControl();

			// On gallery/viewer contexts the real control lives on the post
			// page, which we may not be on. Fetch it in the background.
			if (!control && post?.postUrl && post.postUrl !== location.href) {
				try {
					const res = await BE.net.request({ url: post.postUrl }, 1);
					const doc = new DOMParser().parseFromString(res.responseText, 'text/html');
					const remoteControl = findFavoriteControl(doc);
					if (remoteControl?.href) {
						// Re-request the actual favorite link so the session cookie applies.
						await BE.net.request({ url: remoteControl.href }, 1);
						BE.modules.toast.show('Favorited', 'success');
						return true;
					}
				} catch (err) {
					BE.log.error('[Favorites] remote favorite failed', err);
					BE.modules.toast.show('Favorite failed', 'error');
					return false;
				}
			}

			if (!control) {
				BE.log.warn('[Favorites] control not found on page');
				BE.modules.toast.show('Favorite control not found on this page', 'warn');
				return false;
			}

			try {
				const before = control.outerHTML;
				control.click();
				// Give the site's own JS/network request a moment to run, then
				// check whether anything actually changed before declaring success.
				await new Promise((r) => setTimeout(r, 400));
				const after = findFavoriteControl()?.outerHTML;
				if (after !== undefined && after !== before) {
					BE.modules.toast.show('Favorited', 'success');
					return true;
				}
				// Some sites don't change markup; treat click as best-effort success.
				BE.modules.toast.show('Favorite toggled', 'success');
				return true;
			} catch (err) {
				BE.log.error('[Favorites] click failed', err);
				BE.modules.toast.show('Favorite failed', 'error');
				return false;
			}
		}

		return { supported, toggle };
	})();

	/* ============================================================ *
	 *  HOVER PREVIEW
	 * ============================================================ */
	BE.modules.hover = (() => {
		let hoverEl = null;

		function init() {
			hoverEl = document.createElement('div');
			hoverEl.id = 'be-hover-preview';
			hoverEl.style.cssText = 'position:fixed; pointer-events:none; z-index:999999; display:none; max-width:400px; max-height:400px; border:2px solid var(--be-accent, #ff8ac6); background:#000;';
			document.body.appendChild(hoverEl);
		}

		function show(img) {
			if (!hoverEl) init();
			hoverEl.innerHTML = '';
			const preview = new Image();
			preview.src = img.src;
			preview.style.cssText = 'width:100%; height:100%; object-fit:contain;';
			hoverEl.appendChild(preview);

			const rect = img.getBoundingClientRect();
			let left = rect.right + 10;
			if (left + 400 > window.innerWidth) left = rect.left - 410;
			hoverEl.style.left = `${left}px`;
			hoverEl.style.top = `${rect.top}px`;
			hoverEl.style.display = 'block';
		}

		function hide() {
			if (hoverEl) hoverEl.style.display = 'none';
		}

		return { show, hide };
	})();

	/* ============================================================ *
	 *  VIEWER
	 * ============================================================ */
	BE.modules.viewer = (() => {
		let overlay = null;
		let stage = null;
		let mediaEl = null;
		let statusEl = null;
		let currentPost = null;
		let onNext = null;
		let onPrev = null;

		let zoom = 1;
		let rotation = 0;
		let flipH = false;
		let flipV = false;
		let panX = 0;
		let panY = 0;
		let dragging = false;
		let dragStart = { x: 0, y: 0 };

		function init() {
			overlay = BE.dom.create('div', { id: 'be-viewer-overlay' });
			overlay.style.cssText = 'position:fixed;inset:0;width:100vw;height:100vh;background:rgba(0,0,0,0.92);z-index:999998;display:none;align-items:center;justify-content:center;flex-direction:column;user-select:none;';

			stage = BE.dom.create('div', { class: 'be-viewer-stage' });
			stage.style.cssText = 'position:relative;width:100%;flex:1;display:flex;align-items:center;justify-content:center;overflow:hidden;';
			overlay.appendChild(stage);

			const bar = BE.dom.create('div', { class: 'be-viewer-toolbar' });
			bar.style.cssText = 'display:flex;gap:6px;padding:8px;background:rgba(0,0,0,0.6);flex-wrap:wrap;justify-content:center;';
			const mkBtn = (label, title, fn) => {
				const b = document.createElement('button');
				b.textContent = label;
				b.title = title;
				b.className = 'be-viewer-btn';
				b.style.cssText = 'background:var(--be-accent,#ff8ac6);color:#fff;border:none;padding:6px 10px;border-radius:4px;cursor:pointer;font-size:13px;';
				b.addEventListener('click', (e) => { e.stopPropagation(); fn(); });
				bar.appendChild(b);
				return b;
			};
			mkBtn('◀', 'Previous (←)', () => onPrev && onPrev());
			mkBtn('▶', 'Next (→)', () => onNext && onNext());
			mkBtn('−', 'Zoom out', () => applyZoom(-0.25));
			mkBtn('Fit', 'Reset zoom/pan', resetTransform);
			mkBtn('+', 'Zoom in', () => applyZoom(0.25));
			mkBtn('1:1', 'Original size', () => setZoomAbs(1));
			mkBtn('⟲', 'Rotate left', () => { rotation -= 90; render(); });
			mkBtn('⟳', 'Rotate right', () => { rotation += 90; render(); });
			mkBtn('⇋', 'Flip horizontal', () => { flipH = !flipH; render(); });
			mkBtn('⇅', 'Flip vertical', () => { flipV = !flipV; render(); });
			mkBtn('⭳', 'Download (d)', () => currentPost && BE.modules.downloader.downloadPost(currentPost));
			mkBtn('★', 'Favorite (f)', () => currentPost && BE.modules.favorites.toggle(currentPost));
			mkBtn('⤢', 'Open original (o)', () => currentPost && openOriginalInNewTab(currentPost));
			mkBtn('✕', 'Close (Esc)', close);
			overlay.appendChild(bar);

			statusEl = BE.dom.create('div', { class: 'be-viewer-status' });
			statusEl.style.cssText = 'position:absolute;top:8px;left:12px;color:#fff;font:12px/1.4 sans-serif;text-shadow:0 1px 2px rgba(0,0,0,.8);';
			overlay.appendChild(statusEl);

			overlay.addEventListener('click', (e) => { if (e.target === overlay || e.target === stage) close(); });
			overlay.addEventListener('wheel', (e) => {
				if (overlay.style.display !== 'flex') return;
				e.preventDefault();
				applyZoom(e.deltaY < 0 ? 0.15 : -0.15);
			}, { passive: false });

			document.addEventListener('keydown', onKeydown);
			document.body.appendChild(overlay);
		}

		function openOriginalInNewTab(post) {
			const url = post.originalUrl || post.sampleUrl || post.previewUrl;
			if (url) window.open(url, '_blank', 'noopener');
		}

		function onKeydown(e) {
			if (!overlay || overlay.style.display !== 'flex') return;
			const keys = {
				[BE.settings.get('keys.close') || 'Escape']: close,
						 [BE.settings.get('keys.next') || 'ArrowRight']: () => onNext && onNext(),
						 [BE.settings.get('keys.prev') || 'ArrowLeft']: () => onPrev && onPrev(),
						 [BE.settings.get('keys.download') || 'd']: () => currentPost && BE.modules.downloader.downloadPost(currentPost),
						 [BE.settings.get('keys.favorite') || 'f']: () => currentPost && BE.modules.favorites.toggle(currentPost),
						 [BE.settings.get('keys.openOriginal') || 'o']: () => currentPost && openOriginalInNewTab(currentPost),
						 [BE.settings.get('keys.playPause') || ' ']: () => togglePlayPause(),
			};
			const fn = keys[e.key];
			if (fn) { e.preventDefault(); fn(); }
		}

		function togglePlayPause() {
			if (mediaEl && mediaEl.tagName === 'VIDEO') {
				mediaEl.paused ? mediaEl.play() : mediaEl.pause();
			}
		}

		function resetTransform() {
			zoom = 1; rotation = 0; flipH = false; flipV = false; panX = 0; panY = 0;
			render();
		}

		function applyZoom(delta) {
			setZoomAbs(Math.min(8, Math.max(0.1, zoom + delta)));
		}

		function setZoomAbs(z) {
			zoom = z;
			render();
		}

		function render() {
			if (!mediaEl) return;
			mediaEl.style.transform =
			`translate(${panX}px, ${panY}px) scale(${zoom}) rotate(${rotation}deg) scaleX(${flipH ? -1 : 1}) scaleY(${flipV ? -1 : 1})`;
		}

		function setupDrag(el) {
			el.addEventListener('pointerdown', (e) => {
				if (zoom <= 1) return;
				dragging = true;
				dragStart = { x: e.clientX - panX, y: e.clientY - panY };
				el.setPointerCapture(e.pointerId);
			});
			el.addEventListener('pointermove', (e) => {
				if (!dragging) return;
				panX = e.clientX - dragStart.x;
				panY = e.clientY - dragStart.y;
				render();
			});
			el.addEventListener('pointerup', () => { dragging = false; });
			el.addEventListener('pointercancel', () => { dragging = false; });
		}

		function buildMedia(post) {
			const isVideo = post.mediaType === 'video';
			const el = document.createElement(isVideo ? 'video' : 'img');
			el.style.cssText = 'max-width:90vw;max-height:75vh;object-fit:contain;cursor:grab;touch-action:none;';
			if (isVideo) {
				el.src = post.sampleUrl || post.previewUrl || post.originalUrl;
				el.autoplay = BE.settings.get('viewer.autoplayVideo');
				el.loop = BE.settings.get('viewer.loopVideo');
				el.muted = BE.settings.get('viewer.muteVideo');
				el.controls = true;
				if (BE.settings.get('viewer.rememberVolume')) {
					const vol = BE.store.get('viewer:volume', 1);
					el.volume = vol;
					el.addEventListener('volumechange', () => BE.store.set('viewer:volume', el.volume));
				}
			} else {
				el.src = post.sampleUrl || post.previewUrl || post.originalUrl;
				el.decoding = 'async';
			}
			setupDrag(el);
			return el;
		}

		function open(post, navigation = {}) {
			if (!overlay) init();
			currentPost = post;
			onNext = navigation.next || null;
			onPrev = navigation.prev || null;
			resetTransform();

			stage.innerHTML = '';
			mediaEl = buildMedia(post);
			stage.appendChild(mediaEl);
			statusEl.textContent = `#${post.id || '?'}${post.width && post.height ? ` · ${post.width}×${post.height}` : ''}`;

			overlay.style.display = 'flex';
			BE.bus.emit('viewer:open', post);
		}

		function updatePost(post) {
			if (!currentPost || currentPost.id !== post.id) return;
			currentPost = post;
			if (mediaEl && post.originalUrl && mediaEl.src !== post.originalUrl) {
				mediaEl.src = post.originalUrl;
			}
			statusEl.textContent = `#${post.id || '?'}${post.width && post.height ? ` · ${post.width}×${post.height}` : ''}`;
		}

		function close() {
			if (overlay) overlay.style.display = 'none';
			if (mediaEl && mediaEl.tagName === 'VIDEO') { try { mediaEl.pause(); } catch { /* noop */ } }
			currentPost = null;
			onNext = null;
			onPrev = null;
		}

		function isOpen() { return !!overlay && overlay.style.display === 'flex'; }

		return { open, updatePost, close, isOpen, get currentPost() { return currentPost; } };
	})();

	/* ============================================================ *
	 *  GALLERY MODULE
	 * ============================================================ */
	BE.modules.gallery = (() => {
		let galleryContainer = null;
		let scrollObserver = null;
		let sentinel = null;
		let nextPageUrl = null;
		let loadedPostIds = new Set();
		let enrichedPostIds = new Set();
		const postCache = new Map();
		let state = 'IDLE';
		let retryTimer = null;
		let paginatorEl = null;
		let paginatorHiddenByUs = false;
		let visitedPageIdentities = new Set();
		let settingsListenerAttached = false;

		function seedLoadedPostIds(root) {
			// Requirement 2: loadedPostIds must represent every post already
			// present in the gallery *before* infinite scroll can insert more.
			// No network requests — purely a DOM scan using the active adapter.
			let seeded = 0;
			for (const img of BE.adapters.active.getThumbElements(root)) {
				const postId = BE.adapters.active.getThumbPostId(img);
				if (!postId) continue;
				const id = String(postId);
				if (!loadedPostIds.has(id)) {
					loadedPostIds.add(id);
					seeded++;
				}
			}
			BE.log.debug(`[Gallery] seeded loadedPostIds with ${seeded} existing post(s), total ${loadedPostIds.size}`);
		}

		function resetPageScopedState() {
			// Requirement 8/11: called only when a genuinely NEW gallery/page
			// context is initialized (real SPA navigation / container swap) —
			// never on unrelated DOM mutations of the same gallery.
			loadedPostIds = new Set();
			enrichedPostIds = new Set();
			visitedPageIdentities = new Set();
			nextPageUrl = null;
			state = 'IDLE';
			clearTimeout(retryTimer);
			paginatorHiddenByUs = false;
		}

		function init(container) {
			const isNewContainer = galleryContainer !== container;

			if (isNewContainer) {
				// Requirement 8: disconnect anything tied to the OLD container
				// before adopting the new one.
				if (scrollObserver) { scrollObserver.disconnect(); scrollObserver = null; }
				if (sentinel) { sentinel.remove(); sentinel = null; }
				resetPageScopedState();
				restorePaginatorVisibility();
				paginatorEl = null;
			}

			galleryContainer = container;

			// Requirement 7: gallery init() must be idempotent — a marker on
			// the container prevents re-adding delegated listeners or redoing
			// work that's already in place for THIS container.
			if (galleryContainer.dataset.beGalleryInit === '1') {
				if (isNewContainer) {
					// Shouldn't normally happen (marker would only exist on an
					// already-adopted container), but guard anyway.
					seedLoadedPostIds(galleryContainer);
				}
				applyGridSettings();
				return;
			}
			galleryContainer.dataset.beGalleryInit = '1';
			galleryContainer.classList.add('be-gallery-grid');

			// Event Delegation (attached exactly once per container).
			// Requirement 4 / follow-up review: bubble phase, not capture.
			// Capture-phase interception ran our logic before the site's own
			// listeners on the thumbnail/link ever saw the event, which is
			// more aggressive than necessary and risks interfering with
			// Gelbooru's own click handling. Bubble phase still lets
			// preventDefault() stop the default navigation (the browser
			// only commits the default action after the whole dispatch,
			// capture + target + bubble, completes) — we just no longer
			// jump the queue ahead of the site's own handlers.
			galleryContainer.addEventListener('click', onGalleryClick);
			galleryContainer.addEventListener('pointerover', onGalleryHover);
			galleryContainer.addEventListener('pointerout', onGalleryHoverEnd);

			applyGridSettings();

			if (!settingsListenerAttached) {
				settingsListenerAttached = true;
				BE.bus.on('settings:changed', ({ key }) => {
					if (key.startsWith('gallery.') || key === 'general.theme' || key === 'general.accentColor') {
						applyGridSettings();
					}
					if (key === 'gallery.infiniteScroll') {
						onInfiniteScrollSettingChanged(BE.settings.get('gallery.infiniteScroll'));
					}
				});
			}

			enhanceThumbnails(galleryContainer);
			seedLoadedPostIds(galleryContainer);
		}

		function onInfiniteScrollSettingChanged(enabled) {
			if (!galleryContainer) return;
			if (enabled) {
				setupInfiniteScroll();
			} else {
				if (scrollObserver) { scrollObserver.disconnect(); scrollObserver = null; }
				if (sentinel) { sentinel.remove(); sentinel = null; }
				restorePaginatorVisibility();
			}
		}

		function applyGridSettings() {
			if (!galleryContainer) return;
			const cols = BE.settings.get('gallery.gridDensity') || 0;
			const thumbSize = BE.settings.get('gallery.thumbnailSize') || 220;
			const gap = BE.settings.get('gallery.gridGap') ?? 8;
			const compact = !!BE.settings.get('gallery.compactMode');

			// Requirement 20: schema stores these as numbers already (the
			// settings panel does Number(input.value) for range/number
			// fields), but coerce defensively so a stale/corrupt stored
			// string value can never become "300pxpx" below.
			const thumbSizePx = Number(thumbSize) || 220;
			const gapPx = Number(gap) || 0;
			const colsNum = Number(cols) || 0;

			// Requirement 12: --be-thumbnail-size is an actual consumed
			// custom property now, not just a value baked into a template
			// string — other rules (e.g. compact mode) can reference it too.
			galleryContainer.style.setProperty('--be-thumbnail-size', `${thumbSizePx}px`);

			if (colsNum > 0) {
				galleryContainer.style.setProperty('--be-grid-template', `repeat(${colsNum}, minmax(0, 1fr))`);
			} else {
				galleryContainer.style.setProperty('--be-grid-template', `repeat(auto-fill, minmax(var(--be-thumbnail-size), 1fr))`);
			}
			galleryContainer.style.setProperty('--be-grid-gap', `${gapPx}px`);
			galleryContainer.classList.toggle('be-compact-mode', compact);

			BE.log.debug(`[Gallery] Grid: ${colsNum > 0 ? colsNum + ' columns' : 'auto'}, ${thumbSizePx}px, ${gapPx}px gap${compact ? ', compact' : ''}`);
		}

		function orderedPostIds() {
			if (!galleryContainer) return [];
			return BE.adapters.active.getThumbElements(galleryContainer)
			.map((img) => img.dataset.bePostId || BE.adapters.active.getThumbPostId(img))
			.filter(Boolean);
		}

		function openViewerForThumb(img, thumb) {
			const postId = img.dataset.bePostId || BE.adapters.active.getThumbPostId(img);
			if (!postId) return;

			const previewUrl = img.src || img.dataset.src;
			const postUrl = thumb.closest('a')?.href || img.closest('a')?.href || location.href;

			const minimalPost = emptyPost({
				id: postId,
				previewUrl,
				sampleUrl: previewUrl,
				postUrl,
			});

			const ids = orderedPostIds();
			const navigateBy = (delta) => {
				const idx = ids.indexOf(postId);
				const nextId = idx === -1 ? null : ids[idx + delta];
				if (!nextId) return;
				const nextImg = BE.dom.qs(`img[data-be-post-id="${CSS.escape(nextId)}"]`, galleryContainer)
				|| BE.dom.qs(`img[data-be-post-id="${CSS.escape(nextId)}"]`, document);
				if (nextImg) openViewerForThumb(nextImg, nextImg.closest('.be-thumb-wrap') || nextImg);
			};

				BE.modules.viewer.open(minimalPost, {
					next: () => navigateBy(1),
									   prev: () => navigateBy(-1),
				});

				enrichSinglePost(postId).then((fullPost) => {
					if (fullPost) BE.modules.viewer.updatePost(fullPost);
				}).catch((err) => BE.log.error('enrichment failed', err));
		}

		function buildThumbActions(wrap, img) {
			if (wrap.querySelector('.be-thumb-actions')) return;
			const bar = BE.dom.create('div', { class: 'be-thumb-actions' });
			bar.style.cssText = 'position:absolute;top:4px;right:4px;display:flex;gap:3px;opacity:0;transition:opacity .15s;z-index:5;';
			const mk = (label, title, action) => {
				const b = document.createElement('button');
				b.textContent = label;
				b.title = title;
				b.dataset.beAction = action;
				b.className = 'be-thumb-action-btn';
				b.style.cssText = 'background:rgba(0,0,0,.65);color:#fff;border:none;border-radius:3px;padding:2px 5px;font-size:11px;cursor:pointer;line-height:1.4;';
				bar.appendChild(b);
			};
			mk('👁', 'Open viewer', 'viewer');
			mk('⭳', 'Download', 'download');
			mk('⤢', 'Open original', 'open');
			if (BE.modules.favorites.supported()) mk('★', 'Favorite', 'favorite');
			wrap.appendChild(bar);
			wrap.addEventListener('pointerenter', () => { bar.style.opacity = '1'; });
			wrap.addEventListener('pointerleave', () => { bar.style.opacity = '0'; });
		}

		// Requirement 3 / 29: resolve the actual per-post wrapper for a
		// thumbnail <img> through the active adapter's getThumbWrapper (each
		// adapter now provides one). This replaces the old
		// img.closest('article, span, div, a') strategy, which was the
		// common root cause behind several independent-looking symptoms:
		// grabbing the wrong (too broad, or even the gallery-container-
		// level) ancestor meant the sizing/no-overlap CSS landed on the
		// wrong element, and click/hover delegation could end up treating
		// "the whole gallery" as a single thumbnail.
		function getWrapperForImg(img) {
			const adapter = BE.adapters.active;
			let wrap = null;
			if (adapter && typeof adapter.getThumbWrapper === 'function') {
				try { wrap = adapter.getThumbWrapper(img); } catch (err) {
					BE.log.debug('[Gallery] getThumbWrapper threw, falling back', err);
				}
			}
			if (!wrap) wrap = img.closest('a') || img.parentElement;
			// Safety net: never accept the gallery container itself (or an
			// ancestor of it) as "a thumbnail wrapper" — that would make a
			// single wrapper swallow the entire gallery.
			if (!wrap || wrap === galleryContainer || (galleryContainer && wrap.contains(galleryContainer))) {
				return null;
			}
			return wrap;
		}

		// Single helper for enhancing one thumbnail — used for both the
		// initial gallery render and dynamically-inserted (infinite scroll)
		// thumbnails, per Requirement 17, so the two paths can never drift
		// out of sync with each other.
		function enhanceThumbnail(img) {
			const wrap = getWrapperForImg(img);
			if (wrap) {
				wrap.classList.add('be-thumb-wrap');
				if (getComputedStyle(wrap).position === 'static') wrap.style.position = 'relative';
				buildThumbActions(wrap, img);
			}
			img.classList.add('be-thumb-img');

			const postId = BE.adapters.active.getThumbPostId(img);
			if (postId) {
				img.dataset.bePostId = postId;
			}
			return wrap;
		}

		function enhanceThumbnails(root) {
			const thumbs = BE.adapters.active.getThumbElements(root);
			BE.log.debug(`[Gallery] Adapter: ${BE.adapters.active?.id}`);
			BE.log.debug(`[Gallery] Thumbnails: ${thumbs.length}`);
			for (const img of thumbs) enhanceThumbnail(img);
		}

		async function handleThumbAction(action, img, thumb) {
			const postId = img.dataset.bePostId || BE.adapters.active.getThumbPostId(img);
			if (!postId) return;
			const previewUrl = img.src || img.dataset.src;
			const postUrl = thumb.closest('a')?.href || img.closest('a')?.href || location.href;
			let post = emptyPost({ id: postId, previewUrl, sampleUrl: previewUrl, postUrl });

			if (action === 'viewer') {
				openViewerForThumb(img, thumb);
				return;
			}

			// download/open/favorite need real metadata (original URL) first.
			try {
				const full = await enrichSinglePost(postId);
				if (full) post = full;
			} catch (err) {
				BE.log.warn('[Gallery] could not enrich post before action', action, err);
			}

			if (action === 'download') {
				BE.modules.downloader.downloadPost(post);
			} else if (action === 'open') {
				const url = post.originalUrl || post.sampleUrl || post.previewUrl;
				if (url) window.open(url, '_blank', 'noopener');
			} else if (action === 'favorite') {
				BE.modules.favorites.toggle(post);
			}
		}

		function onGalleryClick(e) {
			// Per-thumbnail action buttons (download/open/favorite/viewer overlay).
			const actionBtn = e.target.closest('[data-be-action]');
			if (actionBtn) {
				const wrap = actionBtn.closest('.be-thumb-wrap');
				const img = wrap?.querySelector('img');
				if (img) {
					e.preventDefault();
					e.stopPropagation();
					BE.log.debug(`[Gallery] action button: ${actionBtn.dataset.beAction}`);
					handleThumbAction(actionBtn.dataset.beAction, img, wrap);
				}
				return;
			}

			if (!BE.settings.get('viewer.enabled')) return;

			// Never hijack modifier-key clicks or middle-clicks — let the
			// browser/site handle "open in new tab", "open in background", etc.
			if (e.button !== 0 || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;

			// Requirement 3/29: only ever recognize an element we've
			// explicitly marked as a thumbnail wrapper. The old fallback
			// list (article, span.thumb, a.thumb, .post-preview) could
			// match an unrelated ancestor — including, in the worst case,
			// the gallery container itself — and silently open the viewer
			// for the wrong post (or make clicks appear to do nothing).
			const thumb = e.target.closest('.be-thumb-wrap');
			if (!thumb) return;

			const img = thumb.matches('img') ? thumb : (thumb.querySelector('.be-thumb-img') || thumb.querySelector('img'));
			if (!img) return;

			e.preventDefault();
			e.stopPropagation();
			BE.log.debug(`[Gallery] Viewer opening post #${img.dataset.bePostId || BE.adapters.active.getThumbPostId(img)}`);
			openViewerForThumb(img, thumb);
		}

		function onGalleryHover(e) {
			if (!BE.settings.get('media.hoverPreview')) return;
			const thumb = e.target.closest('.be-thumb-wrap');
			if (!thumb) return;
			// pointerover bubbles for every child element boundary inside
			// the thumbnail (image, action bar, etc). Only (re)trigger the
			// preview the first time we enter THIS wrapper, not on every
			// bubble — this is what was causing the reported flicker.
			if (thumb.dataset.beHovering === '1') return;
			thumb.dataset.beHovering = '1';
			const img = thumb.matches('img') ? thumb : (thumb.querySelector('.be-thumb-img') || thumb.querySelector('img'));
			if (img) BE.modules.hover.show(img);
		}

		function onGalleryHoverEnd(e) {
			if (!BE.settings.get('media.hoverPreview')) return;
			const thumb = e.target.closest('.be-thumb-wrap');
			if (!thumb) return;
			// Only actually hide once the pointer has left the wrapper
			// entirely — relatedTarget still inside it means this pointerout
			// was just an internal child boundary crossing.
			if (thumb.contains(e.relatedTarget)) return;
			delete thumb.dataset.beHovering;
			BE.modules.hover.hide();
		}

		async function enrichSinglePost(postId) {
			if (postCache.has(postId)) return postCache.get(postId);
			try {
				const post = await BE.adapters.active.fetchPost(postId);
				if (post) postCache.set(postId, post);
				return post;
			} catch (err) {
				BE.log.debug('[Gallery] enrichSinglePost failed for', postId, err);
				return null;
			}
		}

		// Background metadata enrichment: batch-fetch original-media URLs for
		// visible thumbnails so download/viewer/favorite don't have to make a
		// per-click network round-trip afterwards. Never blocks initial render.
		async function enrichThumbnails(root = galleryContainer) {
			if (!root || !BE.adapters.active || typeof BE.adapters.active.fetchThumbBatch !== 'function') return;

			const ids = BE.adapters.active.getThumbElements(root)
			.map((img) => img.dataset.bePostId || BE.adapters.active.getThumbPostId(img))
			.filter((id) => id && !enrichedPostIds.has(id));
			if (!ids.length) return;

			const CHUNK = 40;
			for (let i = 0; i < ids.length; i += CHUNK) {
				const chunk = ids.slice(i, i + CHUNK);
				chunk.forEach((id) => enrichedPostIds.add(id));
				try {
					const posts = await BE.adapters.active.fetchThumbBatch(chunk);
					for (const post of posts || []) {
						if (!post?.id) continue;
						postCache.set(String(post.id), post);
						const img = BE.dom.qs(`img[data-be-post-id="${CSS.escape(String(post.id))}"]`, root);
						if (img && post.originalUrl) img.dataset.beOriginalUrl = post.originalUrl;
					}
					BE.log.debug(`[Gallery] enriched ${posts?.length || 0}/${chunk.length} thumbnails`);
				} catch (err) {
					BE.log.debug('[Gallery] batch enrichment failed for chunk', err);
				}
			}
		}

		function findPaginatorEl() {
			const selectors = BE.adapters.active.pagination.containerSelectors.join(', ');
			if (!selectors) return null;
			try { return BE.dom.qs(selectors); } catch { return null; }
		}

		function hidePaginatorIfPresent() {
			paginatorEl = paginatorEl || findPaginatorEl();
			if (paginatorEl && !paginatorEl.classList.contains('be-pagination-hidden')) {
				paginatorEl.classList.add('be-pagination-hidden');
				paginatorHiddenByUs = true;
			}
		}

		function restorePaginatorVisibility() {
			if (paginatorHiddenByUs && paginatorEl) {
				paginatorEl.classList.remove('be-pagination-hidden');
			}
			paginatorHiddenByUs = false;
		}

		function setupInfiniteScroll() {
			// Requirement 6: fully idempotent — never leaves more than one
			// observer/sentinel behind, however many times this is called
			// (SPA nav, MutationObserver, settings toggling, re-detection).
			if (scrollObserver) { scrollObserver.disconnect(); scrollObserver = null; }
			const existingSentinel = document.getElementById('be-infinite-scroll-sentinel');
			if (existingSentinel) existingSentinel.remove();
			sentinel = null;

			if (!galleryContainer) return;

			sentinel = document.createElement('div');
			sentinel.id = 'be-infinite-scroll-sentinel';
			sentinel.style.height = '10px';
			galleryContainer.parentNode.insertBefore(sentinel, galleryContainer.nextSibling);

			scrollObserver = new IntersectionObserver((entries) => {
				if (entries[0].isIntersecting && state === 'IDLE') {
					loadNextPage();
				}
			}, { rootMargin: '600px 0px' });

			scrollObserver.observe(sentinel);

			// Requirement 5: do NOT hide the native paginator up front — only
			// once infinite scroll has proven it can actually load a page.
			paginatorEl = findPaginatorEl();
		}

		async function loadNextPage() {
			if (state !== 'IDLE') return;
			state = 'LOADING';
			clearTimeout(retryTimer);

			// Requirement 1 fix: use a mutable local so a successfully
			// calculated fallback URL is actually used for the request below.
			let nextUrl = nextPageUrl || BE.adapters.active.pagination.getNextUrl(document);
			if (!nextUrl) {
				const calc = BE.adapters.active.pagination.calculateNextUrl(location.href);
				if (!calc) {
					state = 'EXHAUSTED';
					BE.log.info('[Gallery] No more pages. Next URL not found.');
					return;
				}
				nextUrl = calc;
				nextPageUrl = calc;
			}

			// Requirement 11: never re-request an already-visited page.
			const nextIdentity = BE.adapters.active.pagination.getPageIdentity(nextUrl);
			if (visitedPageIdentities.has(nextIdentity)) {
				state = 'EXHAUSTED';
				BE.log.info(`[Gallery] Pagination loop detected (identity "${nextIdentity}" already visited). Stopping.`);
				return;
			}

			BE.log.debug('[Gallery] infinite scroll triggered');
			BE.log.debug(`[Gallery] next URL: ${nextUrl}`);
			BE.log.debug(`[Gallery] pagination identity: ${nextIdentity}`);
			BE.log.debug('[Gallery] request started');

			try {
				const res = await BE.net.request({ url: nextUrl, headers: { 'Accept': 'text/html' } }, 3);
				BE.log.debug(`[Gallery] HTTP status: ${res.status}`);
				BE.log.debug(`[Gallery] response length: ${res.responseText.length}`);

				if (res.responseText.includes('login') && res.responseText.includes('password')) {
					throw new Error('Login page returned instead of gallery.');
				}
				if (res.status === 403 || res.status === 404 || res.status === 500) {
					throw new Error(`HTTP ${res.status} error page returned.`);
				}

				visitedPageIdentities.add(nextIdentity);

				const doc = new DOMParser().parseFromString(res.responseText, 'text/html');
				const newContainer = BE.adapters.active.getGalleryContainer(doc);

				if (!newContainer) {
					throw new Error('Gallery container not found in response.');
				}
				BE.log.debug('[Gallery] gallery container found');

				const thumbs = BE.adapters.active.getThumbElements(newContainer);
				BE.log.debug(`[Gallery] thumbnails found: ${thumbs.length}`);

				if (!thumbs.length) {
					state = 'EXHAUSTED';
					BE.log.info('[Gallery] Empty valid page. No more posts.');
					restorePaginatorVisibility();
					return;
				}

				let inserted = 0;
				for (const thumb of thumbs) {
					const img = thumb.tagName === 'IMG' ? thumb : thumb.querySelector('img');
					if (!img) continue;

					const rawPostId = BE.adapters.active.getThumbPostId(img);
					if (!rawPostId) continue;
					const postId = String(rawPostId);
					if (loadedPostIds.has(postId)) continue;

					loadedPostIds.add(postId);

					// Requirement 17: same wrapper-resolution path as the
					// initial thumbnails — no separate/duplicated logic.
					// Note getWrapperForImg's "don't return the gallery
					// container" guard doesn't apply to this parsed-doc
					// element (it belongs to a detached document, not the
					// live one), so it's safe to reuse directly here.
					const wrap = (BE.adapters.active.getThumbWrapper && BE.adapters.active.getThumbWrapper(img)) || img.closest('a') || img.parentElement;
					if (!wrap) continue;

					const clonedWrap = wrap.cloneNode(true);
					const clonedImg = clonedWrap.tagName === 'IMG' ? clonedWrap : clonedWrap.querySelector('img');
					if (!clonedImg) continue;

					clonedWrap.classList.add('be-thumb-wrap');
					clonedImg.classList.add('be-thumb-img');
					clonedImg.dataset.bePostId = postId;

					// Requirement 18: cloneNode never carries over JS event
					// state — the per-thumbnail hover action bar (built via
					// direct pointerenter/pointerleave listeners, not
					// delegation) would otherwise silently stop working on
					// every dynamically-inserted thumbnail. Drop the cloned
					// (dead) bar and rebuild it for real once attached.
					const staleActions = clonedWrap.querySelector('.be-thumb-actions');
					if (staleActions) staleActions.remove();

					galleryContainer.appendChild(clonedWrap);
					if (getComputedStyle(clonedWrap).position === 'static') clonedWrap.style.position = 'relative';
					buildThumbActions(clonedWrap, clonedImg);

					inserted++;
				}
				BE.log.debug(`[Gallery] unique posts: ${inserted}`);
				BE.log.debug(`[Gallery] inserted: ${inserted}`);

				nextPageUrl = BE.adapters.active.pagination.getNextUrl(doc);
				if (!nextPageUrl) {
					const calculated = BE.adapters.active.pagination.calculateNextUrl(nextUrl);
					nextPageUrl = calculated;
				}

				if (!nextPageUrl) {
					state = 'EXHAUSTED';
					BE.log.info('[Gallery] No more pages. Next URL not found in response.');
					restorePaginatorVisibility();
				} else if (inserted === 0) {
					// Requirement 10: a page can be fetched successfully yet
					// contain zero *unique* posts (fully overlapping page).
					// Continue the chain (the loop guard above still protects
					// against A→B→A / repeated-URL loops), but don't spin
					// forever if this keeps happening with no forward progress.
					BE.log.debug('[Gallery] page contained no new posts; continuing pagination chain');
					state = 'IDLE';
					hidePaginatorIfPresent();
				} else {
					BE.log.debug(`[Gallery] next URL detected: ${nextPageUrl}`);
					state = 'IDLE';
					// Requirement 5: only now, after a demonstrated successful
					// load, is it safe to hide the native paginator.
					hidePaginatorIfPresent();
				}

			} catch (err) {
				BE.log.error('[Gallery] pagination error', err);
				BE.log.error(`[Gallery] URL: ${nextUrl}`);
				BE.log.error(`[Gallery] reason: ${err.message}`);
				state = 'ERROR';
				// Requirement 5: on failure, make sure the user isn't stranded —
				// restore the native paginator rather than leaving it hidden.
				restorePaginatorVisibility();
				retryTimer = setTimeout(() => {
					state = 'IDLE';
					loadNextPage();
				}, 5000);
			}
		}

		return { init, applyGridSettings, enhanceThumbnails, enrichThumbnails, setupInfiniteScroll };
	})();

	/* ============================================================ *
	 *  UI MODULE (Settings & Toolbar)
	 * ============================================================ */
	BE.modules.ui = (() => {
		let toolbarRoot = null;
		let postActionBar = null;
		let currentPostCache = null;

		function injectStyles() {
			const css = `
			:root {
				--be-accent: ${BE.settings.get('general.accentColor') || '#ff8ac6'};
			}
			.be-gallery-grid {
				display: grid;
				grid-template-columns: var(--be-grid-template, repeat(auto-fill, minmax(var(--be-thumbnail-size, 220px), 1fr)));
				gap: var(--be-grid-gap, 8px);
				align-items: stretch;
			}
			.be-thumb-wrap {
				position: relative;
				overflow: hidden;
				min-width: 0;
				min-height: 0;
				aspect-ratio: 3/4;
				background: rgba(128,128,128,0.1);
				border-radius: 4px;
			}
			/* Requirement 11–16: now that .be-thumb-wrap is reliably the
			   actual CSS grid item (not some unrelated ancestor), make sure
			   OUR grid sizing is what visibly governs it — many booru sites
			   give the equivalent native element a fixed width/float, which
			   would otherwise silently override grid-template-columns and
			   make thumbnailSize/gridDensity/gridGap changes invisible. */
			.be-gallery-grid > .be-thumb-wrap,
			.be-gallery-grid .be-thumb-wrap {
				float: none !important;
				width: 100% !important;
				height: auto !important;
				max-width: none !important;
				margin: 0 !important;
			}
			.be-thumb-img {
				display: block;
				width: 100%;
				height: 100%;
				max-width: 100%;
				max-height: 100%;
				object-fit: contain;
			}
			/* gallery.compactMode: previously toggled with no CSS consumer,
			   so it "saved" but never visibly did anything. */
			.be-gallery-grid.be-compact-mode .be-thumb-wrap {
				aspect-ratio: 1/1;
				border-radius: 2px;
			}
			.be-gallery-grid.be-compact-mode .be-thumb-actions {
				padding: 1px;
				gap: 2px;
			}
			.be-gallery-grid.be-compact-mode .be-thumb-action-btn {
				padding: 1px 4px;
				font-size: 10px;
			}
			.be-pagination-hidden {
				display: none !important;
			}
			#be-root {
			position: fixed;
			z-index: 999999;
			font-family: -apple-system, "Segoe UI", Roboto, sans-serif;
			}
			#be-root.be-pos-bottom-right { bottom: 10px; right: 10px; }
			#be-root.be-pos-bottom-left  { bottom: 10px; left: 10px; }
			#be-root.be-pos-top-right    { top: 10px; right: 10px; }
			#be-root.be-pos-top-left     { top: 10px; left: 10px; }
			.be-toolbar {
				display: flex;
				gap: 4px;
				background: rgba(20,20,20,0.85);
				padding: 6px;
				border-radius: 8px;
				box-shadow: 0 2px 10px rgba(0,0,0,0.35);
			}
			.be-toolbar-btn {
				background: var(--be-accent);
				color: #fff;
				border: none;
				padding: 7px 10px;
				border-radius: 5px;
				cursor: pointer;
				font-weight: bold;
				font-size: 12px;
				white-space: nowrap;
			}
			.be-toolbar-btn:disabled {
				background: #666;
				cursor: not-allowed;
				opacity: 0.6;
			}
			.be-post-action-bar {
				display: flex;
				flex-wrap: wrap;
				gap: 6px;
				margin: 8px 0;
				padding: 6px;
				background: rgba(128,128,128,0.08);
				border-radius: 6px;
			}
			.be-post-action-bar button {
				background: var(--be-accent);
				color: #fff;
				border: none;
				padding: 6px 10px;
				border-radius: 4px;
				cursor: pointer;
				font-size: 12px;
			}
			.be-post-action-bar button:disabled {
				background: #999;
				cursor: not-allowed;
				opacity: 0.6;
			}
			.be-settings-panel {
				position: fixed;
				top: 50px;
				right: 10px;
				width: 340px;
				max-height: 80vh;
				overflow-y: auto;
				background: #fff;
				color: #000;
				border: 1px solid #ccc;
				padding: 15px;
				z-index: 999999;
				box-shadow: 0 4px 10px rgba(0,0,0,0.2);
				font-family: sans-serif;
			}
			.be-settings-panel h3 {
				margin-top: 15px;
				margin-bottom: 5px;
				border-bottom: 1px solid #eee;
				padding-bottom: 5px;
			}
			.be-settings-row {
				margin-bottom: 10px;
			}
			.be-settings-row label {
				display: block;
				font-size: 12px;
				margin-bottom: 4px;
				font-weight: bold;
			}
			.be-settings-toolbar {
				display: flex;
				gap: 6px;
				flex-wrap: wrap;
				margin-bottom: 10px;
				padding-bottom: 10px;
				border-bottom: 1px solid #ddd;
			}
			.be-settings-toolbar button {
				flex: 1;
				background: var(--be-accent);
				color: #fff;
				border: none;
				padding: 6px 8px;
				border-radius: 4px;
				cursor: pointer;
				font-size: 11px;
			}
			`;
			_GM.addStyle(css);
		}

		/* ---- current-post resolution (works on post pages, cached) ---- */
		async function getCurrentPost(forceRefresh = false) {
			if (currentPostCache && !forceRefresh) return currentPostCache;
			if (!BE.adapters.active) return null;
			try {
				const isPost = typeof BE.adapters.active.isPostPage === 'function' && BE.adapters.active.isPostPage();
				if (!isPost) return null;
				const id = BE.adapters.active.getPostId?.();
				if (!id) return null;
				const post = await BE.adapters.active.fetchPost(id);
				if (post) currentPostCache = post;
				return post;
			} catch (err) {
				BE.log.warn('[UI] could not resolve current post', err);
				return null;
			}
		}

		function findPrevNextLinks() {
			const sels = {
				next: ['a[rel="next"]', 'a.next-post', 'a#post-next', '.next-post a', 'a.next_page[href*="/posts/"]'],
				prev: ['a[rel="prev"]', 'a.prev-post', 'a#post-prev', '.prev-post a', 'a.prev_page[href*="/posts/"]'],
			};
			const find = (list) => { for (const s of list) { const el = BE.dom.qs(s); if (el?.href) return el.href; } return null; };
			return { next: find(sels.next), prev: find(sels.prev) };
		}

		function makeActionHandlers() {
			return {
				async download() {
					const post = await getCurrentPost();
					if (!post) { BE.modules.toast.show('No post detected on this page', 'warn'); return; }
					BE.modules.downloader.downloadPost(post);
				},
				async openOriginal() {
					const post = await getCurrentPost();
					const url = post?.originalUrl || post?.sampleUrl || post?.previewUrl;
					if (!url) { BE.modules.toast.show('Original media URL not found', 'warn'); return; }
					const mode = BE.settings.get('download.openMode');
					if (mode === 'popup') window.open(url, '_blank', 'width=1000,height=800');
					else window.open(url, '_blank', 'noopener');
				},
				async viewer() {
					const post = await getCurrentPost();
					if (!post) { BE.modules.toast.show('No post detected on this page', 'warn'); return; }
					BE.modules.viewer.open(post);
				},
				async favorite() {
					const post = await getCurrentPost();
					if (!post) { BE.modules.toast.show('No post detected on this page', 'warn'); return; }
					BE.modules.favorites.toggle(post);
				},
				prev() {
					const { prev } = findPrevNextLinks();
					if (prev) location.href = prev; else BE.modules.toast.show('No previous post found', 'warn');
				},
				next() {
					const { next } = findPrevNextLinks();
					if (next) location.href = next; else BE.modules.toast.show('No next post found', 'warn');
				},
			};
		}

		function createToolbar() {
			removeToolbar();
			const root = BE.dom.create('div', { id: 'be-root' });
			root.className = `be-pos-${BE.settings.get('general.toolbarPosition') || 'bottom-right'}`;

			const bar = BE.dom.create('div', { class: 'be-toolbar' });
			const actions = makeActionHandlers();
			const onPostPage = !!(BE.adapters.active?.isPostPage?.());
			const hasFav = BE.modules.favorites.supported();
			const { prev, next } = onPostPage ? findPrevNextLinks() : {};

			const specs = [
				['Settings', () => createSettingsPanel(), true],
					 ['Viewer', actions.viewer, onPostPage],
					 ['Download', actions.download, onPostPage],
					 ['Open Original', actions.openOriginal, onPostPage],
					 ['Favorite', actions.favorite, onPostPage && hasFav],
					 ['Prev', actions.prev, onPostPage && !!prev],
					 ['Next', actions.next, onPostPage && !!next],
			];
			for (const [label, fn, enabled] of specs) {
				const btn = document.createElement('button');
				btn.className = 'be-toolbar-btn';
				btn.textContent = label;
				btn.disabled = !enabled;
				btn.title = enabled ? label : `${label} — not available on this page`;
				btn.addEventListener('click', () => fn && fn());
				bar.appendChild(btn);
			}
			root.appendChild(bar);
			document.body.appendChild(root);
			toolbarRoot = root;
		}

		function removeToolbar() {
			toolbarRoot?.remove();
			toolbarRoot = null;
		}

		function createPostActionBar() {
			postActionBar?.remove();
			postActionBar = null;
			if (!BE.adapters.active?.isPostPage?.()) return;

			const mediaEl = BE.dom.qs('#image, #main_image, .image-container, main img.post-image, video');
			if (!mediaEl) return;

			const bar = BE.dom.create('div', { class: 'be-post-action-bar' });
			const actions = makeActionHandlers();
			const hasFav = BE.modules.favorites.supported();
			const specs = [
				['Download', actions.download, true],
				['Open Original', actions.openOriginal, true],
				['Fullscreen', actions.viewer, true],
				['Favorite', actions.favorite, hasFav],
				['Reverse Search', async () => {
					const post = await getCurrentPost();
					const url = post?.sampleUrl || post?.previewUrl || post?.originalUrl;
					if (url) window.open(`https://saucenao.com/search.php?url=${encodeURIComponent(url)}`, '_blank', 'noopener');
				}, true],
				['Info', async () => {
					const post = await getCurrentPost();
					if (!post) return;
					BE.modules.toast.show(
						`#${post.id} · ${post.width}x${post.height} · ${post.rating} · score ${post.score}`,
						'info', 4000,
					);
				}, true],
			];
			for (const [label, fn, enabled] of specs) {
				const btn = document.createElement('button');
				btn.textContent = label;
				btn.disabled = !enabled;
				btn.addEventListener('click', () => fn && fn());
				bar.appendChild(btn);
			}

			const host = mediaEl.closest('div, section, article') || mediaEl.parentElement;
			(host || document.body).insertAdjacentElement('afterend', bar);
			postActionBar = bar;
		}

		function createSettingsPanel() {
			let panel = document.getElementById('be-settings-panel');
			if (panel) {
				panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
				return;
			}

			panel = document.createElement('div');
			panel.id = 'be-settings-panel';
			panel.className = 'be-settings-panel';

			const topBar = document.createElement('div');
			topBar.className = 'be-settings-toolbar';
			const mkTop = (label, fn) => {
				const b = document.createElement('button');
				b.textContent = label;
				b.addEventListener('click', fn);
				topBar.appendChild(b);
				return b;
			};
			mkTop('Export', () => {
				const json = BE.settings.exportJSON();
				navigator.clipboard?.writeText(json).catch(() => {});
				BE.modules.toast.show('Settings copied to clipboard', 'success');
			});
			mkTop('Import', () => {
				const json = prompt('Paste exported Booru Enhancer settings JSON:');
				if (!json) return;
				const ok = BE.settings.importJSON(json);
				BE.modules.toast.show(ok ? 'Settings imported' : 'Import failed — invalid JSON', ok ? 'success' : 'error');
				if (ok) { panel.remove(); createSettingsPanel(); }
			});
			mkTop('Reset', () => {
				if (!confirm('Reset all Booru Enhancer settings to defaults?')) return;
				BE.settings.resetAll();
				panel.remove();
				createSettingsPanel();
				BE.modules.toast.show('Settings reset to defaults', 'success');
			});
			mkTop('Close', () => { panel.style.display = 'none'; });
			panel.appendChild(topBar);

			const categories = BE.settings.categories();
			for (const cat of categories) {
				const h = document.createElement('h3');
				h.textContent = cat;
				panel.appendChild(h);

				for (const key of BE.settings.byCategory(cat)) {
					const def = BE.settings.SCHEMA[key];
					const row = document.createElement('div');
					row.className = 'be-settings-row';

					const label = document.createElement('label');
					label.textContent = def.label;
					row.appendChild(label);

					let input;
					if (def.type === 'bool') {
						input = document.createElement('input');
						input.type = 'checkbox';
						input.checked = BE.settings.get(key);
						input.addEventListener('change', () => BE.settings.set(key, input.checked));
					} else if (def.type === 'select') {
						input = document.createElement('select');
						for (const choice of def.choices) {
							const opt = document.createElement('option');
							opt.value = choice;
							opt.textContent = choice;
							input.appendChild(opt);
						}
						input.value = BE.settings.get(key);
						input.addEventListener('change', () => BE.settings.set(key, input.value));
					} else if (def.type === 'color') {
						input = document.createElement('input');
						input.type = 'color';
						input.value = BE.settings.get(key);
						input.addEventListener('input', () => BE.settings.set(key, input.value));
					} else if (def.type === 'range' || def.type === 'number') {
						const valSpan = document.createElement('span');
						valSpan.textContent = ` [${BE.settings.get(key)}]`;
						valSpan.style.fontWeight = 'bold';

						input = document.createElement('input');
						input.type = def.type === 'range' ? 'range' : 'number';
						input.min = def.min;
						input.max = def.max;
						input.value = BE.settings.get(key);
						input.style.width = '90%';
						input.addEventListener('input', () => {
							valSpan.textContent = ` [${input.value}]`;
							BE.settings.set(key, Number(input.value));
						});
						row.appendChild(input);
						row.appendChild(valSpan);
						panel.appendChild(row);
						continue;
					} else if (def.type === 'textarea') {
						input = document.createElement('textarea');
						input.value = BE.settings.get(key);
						input.style.width = '100%';
						input.style.height = '60px';
						input.addEventListener('change', () => BE.settings.set(key, input.value));
					} else {
						input = document.createElement('input');
						input.type = 'text';
						input.value = BE.settings.get(key);
						input.style.width = '100%';
						input.addEventListener('change', () => BE.settings.set(key, input.value));
					}
					row.appendChild(input);
					panel.appendChild(row);
				}
			}

			document.body.appendChild(panel);
		}

		function invalidateCurrentPost() { currentPostCache = null; }

		return {
			injectStyles, createToolbar, removeToolbar, createPostActionBar,
			createSettingsPanel, getCurrentPost, invalidateCurrentPost,
		};
	})();

	/* ============================================================ *
	 *  MENU COMMANDS
	 * ============================================================ */
	BE.modules.menu = (() => {
		let registered = false;
		function register() {
			if (registered) return; // avoid duplicate entries on re-init (SPA nav)
	registered = true;
			try {
				_GM.registerMenuCommand('Booru Enhancer: Settings', () => BE.modules.ui.createSettingsPanel());
				_GM.registerMenuCommand('Booru Enhancer: Enable/Disable', () => {
					const cur = BE.settings.get('general.enabled');
					BE.settings.set('general.enabled', !cur);
					BE.modules.toast.show(`Booru Enhancer ${!cur ? 'enabled' : 'disabled'} — reload the page to apply`, 'info', 4000);
				});
				_GM.registerMenuCommand('Booru Enhancer: Reset Settings', () => {
					if (confirm('Reset all Booru Enhancer settings to defaults?')) {
						BE.settings.resetAll();
						BE.modules.toast.show('Settings reset', 'success');
					}
				});
				_GM.registerMenuCommand('Booru Enhancer: Toggle Debug Mode', () => {
					const cur = BE.settings.get('debug.verboseLogging');
					BE.settings.set('debug.verboseLogging', !cur);
					BE.modules.toast.show(`Debug mode ${!cur ? 'ON' : 'OFF'}`, 'info');
				});
			} catch (err) {
				// Some managers (or contexts without menu support) may not have
				// GM_registerMenuCommand available at all — this must not be fatal.
				BE.log.warn('[Menu] registerMenuCommand unavailable', err);
			}
		}
		return { register };
	})();

	/* ============================================================ *
	 *  INITIALIZATION  (resilient bootstrap)
	 * ============================================================ *
	 *  Every stage is wrapped so that one broken module can never
	 *  take down the rest of the script, and every failure/skip is
	 *  logged with a reason instead of a silent `return`.
	 * ============================================================ */
	let _beInitialized = false;

	function safeStage(name, fn) {
		try {
			fn();
			BE.log.info(`[Init] ${name}: ok`);
			return true;
		} catch (err) {
			BE.log.error(`[Init] ${name}: FAILED`, err);
			return false;
		}
	}

	BE.modules.init = function init() {
		// Idempotent: calling BE.init()/BE.modules.init() twice (e.g. after an
		// SPA navigation) must never create duplicate toolbars/listeners.
		if (_beInitialized) {
			BE.log.debug('[Init] already initialized, re-applying page-specific UI only');
			refreshForCurrentPage();
			return;
		}
		_beInitialized = true;

		BE.log.info('[Init] Starting...');
		BE.log.info(`[Init] Host: ${location.hostname}`);

		BE.store.whenReady(() => {
			let settingsOk = safeStage('Settings', () => BE.settings._load());
			if (!settingsOk) {
				BE.log.warn('[Init] Settings failed to load — continuing with schema defaults.');
			}
			BE.log.info('[Init] Settings loaded');

			let adapter = null;
			const adapterOk = safeStage('Adapter detection', () => {
				adapter = BE.core.detectAdapter();
			});
			if (!adapterOk || !adapter) {
				BE.log.error('[Init] Adapter detection failed: falling back to generic adapter.');
				adapter = BE.adapters.registry.find((a) => a.id === 'generic') || null;
			}
			BE.adapters.active = adapter;
			BE.log.info(`[Init] Adapter: ${adapter ? adapter.id : 'NONE'}`);

			if (!adapter) {
				BE.log.error('[Init] FATAL: no adapter available (not even generic). Booru Enhancer cannot run on this page.');
				return;
			}

			// general.enabled may be undefined/corrupt in storage — treat
			// anything except an explicit `false` as enabled.
			const enabled = BE.settings.get('general.enabled') !== false;
			if (!enabled) {
				BE.log.info('[Init] Booru Enhancer is disabled in settings for this site. Registering menu command only.');
				safeStage('Menu commands', () => BE.modules.menu.register());
				return;
			}

			// 1. IMMEDIATE UI INIT — must appear regardless of storage/API state.
			safeStage('UI styles', () => BE.modules.ui.injectStyles());
			safeStage('Toolbar', () => BE.modules.ui.createToolbar());
			safeStage('Post action bar', () => BE.modules.ui.createPostActionBar());
			safeStage('Menu commands', () => BE.modules.menu.register());
			BE.log.info('[Init] UI initialized');

			// 2. IMMEDIATE GALLERY BINDING & LAYOUT
			let container = null;
			safeStage('Gallery init', () => {
				container = adapter.getGalleryContainer();
				if (container) BE.modules.gallery.init(container);
			});
				BE.log.info(`[Init] Gallery initialized (container: ${container ? 'found' : 'none on this page'})`);

				// 3. ASYNC METADATA ENRICHMENT — never blocks the UI above.
				setTimeout(() => safeStage('Metadata enrichment', () => BE.modules.gallery.enrichThumbnails()), 0);

				// 4. ASYNC INFINITE SCROLL — set up as soon as the gallery exists,
				// no arbitrary fixed delay beyond letting the container settle.
				if (BE.settings.get('gallery.infiniteScroll') && container) {
					safeStage('Infinite scroll', () => BE.modules.gallery.setupInfiniteScroll());
				}

				// 5. Viewer/hover/favorites are lazily initialized on first use,
				// so a failure there can never block startup.
		});
	};

	function refreshForCurrentPage() {
		safeStage('Toolbar refresh', () => BE.modules.ui.createToolbar());
		safeStage('Post action bar refresh', () => BE.modules.ui.createPostActionBar());
		BE.modules.ui.invalidateCurrentPost();
	}

	/* ============================================================ *
	 *  SPA / AJAX NAVIGATION HANDLING
	 * ============================================================ *
	 *  Sites like Danbooru/e621 can swap content via pushState/AJAX
	 *  without a full reload. Re-detect state and refresh page-scoped
	 *  UI (toolbar buttons, post action bar) without re-creating
	 *  duplicate global UI (toolbar root/listeners are idempotent).
	 * ============================================================ */
	(function watchSpaNavigation() {
		let lastUrl = location.href;
		const onUrlChange = BE.dom.debounce(() => {
			if (location.href === lastUrl) return;
			lastUrl = location.href;
			BE.log.debug('[Nav] URL changed, refreshing page-scoped UI');
			safeStage('Adapter re-detect', () => { BE.adapters.active = BE.core.detectAdapter() || BE.adapters.active; });
			refreshForCurrentPage();
		}, 150);

		for (const fnName of ['pushState', 'replaceState']) {
			const orig = history[fnName];
			history[fnName] = function patched(...args) {
				const ret = orig.apply(this, args);
				onUrlChange();
				return ret;
			};
		}
		window.addEventListener('popstate', onUrlChange);

		// Fallback for sites that replace DOM content without touching the
		// URL/history API at all. We only re-initialize when the container
		// found by the adapter is genuinely a DIFFERENT element than the one
		// the gallery module already owns — that distinguishes a true SPA
		// content swap from unrelated mutations inside the same gallery,
		// which must never reset pagination/loadedPostIds state.
		const bodyObserver = new MutationObserver(BE.dom.debounce(() => {
			if (!BE.adapters.active) return;
			const container = BE.adapters.active.getGalleryContainer();
			if (container && !container.dataset.beGalleryInit) {
				BE.log.debug('[Nav] gallery container replaced, re-initializing gallery module');
				safeStage('Gallery re-init', () => {
					BE.modules.gallery.init(container);
					if (BE.settings.get('gallery.infiniteScroll')) {
						BE.modules.gallery.setupInfiniteScroll();
					}
					setTimeout(() => safeStage('Metadata enrichment (re-init)', () => BE.modules.gallery.enrichThumbnails()), 0);
				});
			}
		}, 400));
		BE.dom.ready(() => bodyObserver.observe(document.body, { childList: true, subtree: true }));
	})();

	BE.dom.ready(BE.modules.init);

	// Public, idempotent entry point mentioned in the architecture spec —
	// calling this more than once must never duplicate UI/listeners.
	BE.init = BE.modules.init;
})();