better_drunkenslug_xxx_previews

Better Thumbnail preview for drunkenslug xxx with infinite scrolling, Hover zoom, gallery view, Ctrl-hover lightbox, click-to-add-to-cart, configurable keyword filters, and session-based new post highlighting

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey, Greasemonkey of Violentmonkey.

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

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Userscripts.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een gebruikersscriptbeheerder nodig.

(Ik heb al een user script manager, laat me het downloaden!)

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

(Ik heb al een beheerder - laat me doorgaan met de installatie!)

// ==UserScript==
// @name         better_drunkenslug_xxx_previews
// @namespace    http://tampermonkey.net/
// @version      3.0.1
// @description  Better Thumbnail preview for drunkenslug xxx with infinite scrolling, Hover zoom, gallery view, Ctrl-hover lightbox, click-to-add-to-cart, configurable keyword filters, and session-based new post highlighting
// @author       takuto
// @match        https://drunkenslug.com/*
// @run-at       document-idle
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_xmlhttpRequest
// @connect      api.github.com
// @license      MIT
// ==/UserScript==

// Diagnostics. The script guards nearly every DOM and storage access, so
// without a channel a site markup change just makes features stop working with
// no signal at all. Each context reports once per page load to stay quiet.
const EBDS_WARNED_CONTEXTS = new Set();
function ebdsWarn(context, error) {
    try {
        if (EBDS_WARNED_CONTEXTS.has(context)) return;
        EBDS_WARNED_CONTEXTS.add(context);
        console.warn('[EBDS] ' + context, error);
    } catch (e) { }
}

// Shared registry for the few helpers that cross IIFE boundaries. Previously
// these were published on `window`, where any page script could read or replace
// them.
const EBDS = {};

const EBDS_IS_SEARCH_PAGE = location.pathname.startsWith('/search');

const EBDS_ENABLED = (() => {
    try {
        const t = parseInt(new URLSearchParams(location.search).get('t'), 10);
        return (Number.isInteger(t) && t >= 6000 && t <= 6999) || EBDS_IS_SEARCH_PAGE;
    } catch (e) { return false; }
})();

const EBDS_DEFAULT_BLACKLIST_TERMS = [
    ''
];
const EBDS_BLACKLIST_STORAGE_KEY = 'ebdsBlacklistTerms';
const EBDS_NEWSGROUP_BLACKLIST_STORAGE_KEY = 'ebdsExcludedNewsgroups';
const EBDS_SHOW_GALLERY_NEWSGROUP_KEY = 'ebdsShowGalleryNewsgroup';
const EBDS_HIGHLIGHT_KEYWORDS_STORAGE_KEY = 'ebdsHighlightKeywords';
const EBDS_HIDE_WITHOUT_PICS_STORAGE_KEY = 'ebdsHideWithoutPictures';
const EBDS_HIDE_WITHOUT_PICS_ONLY_GALLERY_KEY = 'ebdsHideWithoutPicturesOnlyGallery';
const EBDS_MIN_FILE_SIZE_MB_KEY = 'ebdsMinFileSizeMb';
const EBDS_SCALE_HOVER_PREVIEW_KEY = 'ebdsScaleHoverPreview';
const EBDS_FILTER_LINKS_POSITION_KEY = 'ebdsFilterLinksPosition';
const EBDS_INFINITE_SCROLL_KEY = 'ebdsInfiniteScroll';
const EBDS_INFINITE_SCROLL_ONLY_GALLERY_KEY = 'ebdsInfiniteScrollOnlyGallery';
// Storage key for session expiration minutes setting
const EBDS_SESSION_EXPIRATION_KEY = 'ebdsSessionExpiration';
const EBDS_SAB_URL_KEY = 'ebdsSabnzbdUrl';
const EBDS_SAB_API_KEY = 'ebdsSabnzbdApiKey';
const EBDS_HIDE_SAB_BUTTON_KEY = 'ebdsHideSabReadFeedsButton';
const EBDS_GITHUB_TOKEN_KEY = 'ebdsGithubGistToken';
const EBDS_GITHUB_GIST_ID_KEY = 'ebdsGithubGistId';
const EBDS_GITHUB_AUTO_BACKUP_KEY = 'ebdsGithubAutoBackup';
const EBDS_GITHUB_LAST_BACKUP_KEY = 'ebdsGithubLastBackup';
const EBDS_INCLUDE_SAB_IN_BACKUP_KEY = 'ebdsIncludeSabnzbdInBackup';
const EBDS_GALLERY_VERTICAL_LIMIT_KEY = 'ebdsGalleryVerticalLimit';
const EBDS_GALLERY_SHOW_ONLY_NEW_KEY = 'ebdsGalleryShowOnlyNew';
const EBDS_GALLERY_COLS_KEY = 'ebdsGalleryCols';
const EBDS_LINK_VISIBILITY_KEY = 'ebdsCategoryLinksVisibility';
const EBDS_CONFIG_GIST_FILENAME = 'even-better-drunkenslug-config.json';

// Backup allowlist, populated by readPref. It used to be a hand-kept list that
// had to be updated whenever a preference was added, with nothing to catch an
// omission; deriving it from the reads means every preference the script
// actually loads is backed up, and nothing else is.
const EBDS_CONFIG_STORAGE_KEYS = [];

// Single reader for every persisted preference. Replaces about a dozen
// near-identical try/catch blocks, each with its own coercion and clamping.
// Types: 'flag' ('1'/'0'), 'number', 'integer', 'enum', 'json', 'string'.
function readPref(key, type, fallback, options) {
    if (!EBDS_CONFIG_STORAGE_KEYS.includes(key)) EBDS_CONFIG_STORAGE_KEYS.push(key);
    const opts = options || {};
    let raw = null;
    try { raw = localStorage.getItem(key); }
    catch (e) { ebdsWarn('read preference ' + key, e); return fallback; }
    if (raw === null) return fallback;

    if (type === 'flag') return raw === '1';
    if (type === 'enum') return Array.isArray(opts.allowed) && opts.allowed.includes(raw) ? raw : fallback;
    if (type === 'number' || type === 'integer') {
        const value = type === 'integer' ? parseInt(raw, 10) : Number(raw);
        if (!Number.isFinite(value)) return fallback;
        if (typeof opts.min === 'number' && value < opts.min) return opts.min;
        if (typeof opts.max === 'number' && value > opts.max) return opts.max;
        return value;
    }
    if (type === 'json') {
        try {
            const value = JSON.parse(raw);
            return opts.validate && !opts.validate(value) ? fallback : value;
        } catch (e) { return fallback; }
    }
    return raw;
}

function writePref(key, value) {
    try { localStorage.setItem(key, String(value)); }
    catch (e) { ebdsWarn('write preference ' + key, e); }
}

function writeFlagPref(key, value) {
    writePref(key, value ? '1' : '0');
}

let EBDS_HIDE_WITHOUT_PICTURES = readPref(EBDS_HIDE_WITHOUT_PICS_STORAGE_KEY, 'flag', true);
let EBDS_HIDE_WITHOUT_PICTURES_ONLY_GALLERY = readPref(EBDS_HIDE_WITHOUT_PICS_ONLY_GALLERY_KEY, 'flag', false);
let EBDS_MIN_FILE_SIZE_MB = readPref(EBDS_MIN_FILE_SIZE_MB_KEY, 'number', 0, { min: 0 });
let EBDS_SCALE_HOVER_PREVIEW = readPref(EBDS_SCALE_HOVER_PREVIEW_KEY, 'flag', false);
let EBDS_FILTER_LINKS_POSITION = readPref(EBDS_FILTER_LINKS_POSITION_KEY, 'enum', 'footer', {
    allowed: ['footer', 'header', 'hidden']
});
let EBDS_INFINITE_SCROLL = readPref(EBDS_INFINITE_SCROLL_KEY, 'flag', true);
let EBDS_INFINITE_SCROLL_ONLY_GALLERY = readPref(EBDS_INFINITE_SCROLL_ONLY_GALLERY_KEY, 'flag', false);

function isListingInfiniteScrollActive() {
    return EBDS_INFINITE_SCROLL && !EBDS_INFINITE_SCROLL_ONLY_GALLERY;
}

let EBDS_HIDE_SAB_BUTTON = readPref(EBDS_HIDE_SAB_BUTTON_KEY, 'flag', false);

const EBDS_TEMPORARY_FILTER_BYPASS = {
    withoutPictures: false,
    keywords: false,
    newsgroups: false,
    minimumSize: false
};

const EBDS_RESULT_TABLE_SELECTOR = '#browsetable';
const EBDS_ROW_ID_PREFIX = 'guid';
const EBDS_ROW_SELECTOR = 'tr[id^="' + EBDS_ROW_ID_PREFIX + '"]';
const EBDS_RESULT_ROW_SELECTOR = EBDS_RESULT_TABLE_SELECTOR + ' ' + EBDS_ROW_SELECTOR;

function isReleaseRow(row) {
    return !!(row && row.id && row.id.indexOf(EBDS_ROW_ID_PREFIX) === 0);
}

function getReleaseGuid(row) {
    return isReleaseRow(row) ? row.id.substring(EBDS_ROW_ID_PREFIX.length) : null;
}

// Result rows, preferring the browse table. Different parts of the script used
// to mix the scoped and unscoped selectors, so on any page whose results are not
// in #browsetable the gallery collected items with one and ordered them with the
// other. Falling back keeps a single answer everywhere.
function getReleaseRows(root) {
    const scope = root || document;
    const scoped = scope.querySelectorAll(EBDS_RESULT_ROW_SELECTOR);
    return scoped.length ? scoped : scope.querySelectorAll(EBDS_ROW_SELECTOR);
}

const EBDS_FILE_SIZE_PATTERN = /\b(\d+(?:[.,]\d+)?)\s*(KB|MB|GB|TB)\b/i;
// The site renders the size in its own cell. Scanning the whole row instead
// matched size-like tokens inside release names first ("... 4 GB Collection ...",
// which reported a 304 MB release as 4 GB), because the title precedes the size
// cell in document order.
const EBDS_SIZE_CELL_SELECTOR = 'td.less.right';

function getReleaseSizeText(row) {
    if (!row) return '';
    let sizeCell = null;
    try { sizeCell = row.querySelector(EBDS_SIZE_CELL_SELECTOR); } catch (e) { }
    const source = sizeCell ? sizeCell.textContent : row.textContent;
    const match = String(source || '').match(EBDS_FILE_SIZE_PATTERN);
    return match ? match[0].trim() : '';
}

function getReleaseSizeMb(row) {
    if (!row || !row.dataset) return parseFileSizeMb(getReleaseSizeText(row));
    const cached = row.dataset.ebdsSizeMb;
    if (cached !== undefined) return cached === '' ? null : Number(cached);
    const value = parseFileSizeMb(getReleaseSizeText(row));
    try { row.dataset.ebdsSizeMb = value === null ? '' : String(value); } catch (e) { }
    return value;
}

function parseFileSizeMb(value) {
    const match = String(value || '').match(EBDS_FILE_SIZE_PATTERN);
    if (!match) return null;
    const amount = Number(match[1].replace(',', '.'));
    if (!Number.isFinite(amount)) return null;
    const factors = { KB: 1 / 1024, MB: 1, GB: 1024, TB: 1024 * 1024 };
    return amount * factors[match[2].toUpperCase()];
}

function applyCtrlHoverPreviewSizing(image) {
    image.style.objectFit = 'contain';
    if (!EBDS_SCALE_HOVER_PREVIEW) {
        image.style.width = 'auto';
        image.style.height = 'auto';
        image.style.maxWidth = '95vw';
        image.style.maxHeight = '95vh';
        return;
    }

    image.style.maxWidth = '100vw';
    image.style.maxHeight = '100vh';
    const scaleToViewport = () => {
        if (!image.naturalWidth || !image.naturalHeight) return;
        const imageRatio = image.naturalWidth / image.naturalHeight;
        const viewportRatio = window.innerWidth / window.innerHeight;
        if (imageRatio >= viewportRatio) {
            image.style.width = '100vw';
            image.style.height = 'auto';
        } else {
            image.style.width = 'auto';
            image.style.height = '100vh';
        }
    };
    if (image.complete) scaleToViewport();
    else image.addEventListener('load', scaleToViewport, { once: true });
}

function normalizeBlacklistTerm(term) {
    return (term || '').trim().toLowerCase();
}

function getReleaseTitleText(row) {
    const titleLink = row ? row.querySelector('a.title') : null;
    return titleLink ? titleLink.textContent.trim().toLowerCase() : '';
}

function normalizeNewsgroup(value) {
    return String(value || '').trim().toLowerCase();
}

function getReleaseNewsgroup(row) {
    if (!row) return '';
    const groupLink = row.querySelector('.infotip.badge.badge-inverse.halffade');
    if (!groupLink) return '';
    try {
        const groupFromUrl = new URL(groupLink.href, location.href).searchParams.get('g');
        if (groupFromUrl) return normalizeNewsgroup(groupFromUrl);
    } catch (e) { }
    return normalizeNewsgroup(groupLink.getAttribute('title') || groupLink.textContent);
}

function getReleaseNewsgroupLabel(row) {
    if (!row) return '';
    const groupLink = row.querySelector('.infotip.badge.badge-inverse.halffade');
    if (!groupLink) return '';
    try {
        const groupFromUrl = new URL(groupLink.href, location.href).searchParams.get('g');
        if (groupFromUrl) return groupFromUrl.trim();
    } catch (e) { }
    return String(groupLink.getAttribute('title') || groupLink.textContent || '').trim();
}

function loadExcludedNewsgroups() {
    const stored = readPref(EBDS_NEWSGROUP_BLACKLIST_STORAGE_KEY, 'json', null, { validate: Array.isArray });
    if (!stored) return [];
    return Array.from(new Set(stored.map(normalizeNewsgroup).filter(Boolean)));
}

function persistExcludedNewsgroups(groups) {
    writePref(EBDS_NEWSGROUP_BLACKLIST_STORAGE_KEY, JSON.stringify(groups));
}

let EBDS_EXCLUDED_NEWSGROUPS = loadExcludedNewsgroups();

let EBDS_SHOW_GALLERY_NEWSGROUP = readPref(EBDS_SHOW_GALLERY_NEWSGROUP_KEY, 'flag', false);

function loadBlacklistTerms() {
    const stored = readPref(EBDS_BLACKLIST_STORAGE_KEY, 'json', null, { validate: Array.isArray });
    if (!stored) return EBDS_DEFAULT_BLACKLIST_TERMS.slice();
    return Array.from(new Set(stored.map(normalizeBlacklistTerm).filter(Boolean)));
}

function persistBlacklistTerms(terms) {
    writePref(EBDS_BLACKLIST_STORAGE_KEY, JSON.stringify(terms));
}

let EBDS_BLACKLIST_TERMS = loadBlacklistTerms();

function loadHighlightKeywords() {
    const stored = readPref(EBDS_HIGHLIGHT_KEYWORDS_STORAGE_KEY, 'json', null, { validate: Array.isArray });
    if (!stored) return [];
    return Array.from(new Set(stored.map(normalizeBlacklistTerm).filter(Boolean)));
}

function persistHighlightKeywords(terms) {
    writePref(EBDS_HIGHLIGHT_KEYWORDS_STORAGE_KEY, JSON.stringify(terms));
}

let EBDS_HIGHLIGHT_KEYWORDS = loadHighlightKeywords();

let ctrlPressed = false;

const EBDS_CLICKED_STORAGE_KEY = 'ebdsClickedGuids';
const EBDS_UI_SESSION_KEY = 'ebdsUiSession';
const EBDS_GALLERY_OPEN_KEY = 'ebdsGalleryOpen';
let EBDS_CLICKED_GUIDS = new Set();
const EBDS_PENDING_CART_REQUESTS = new Map();
function loadClickedGuids() {
    try {
        const raw = localStorage.getItem(EBDS_CLICKED_STORAGE_KEY) || '[]';
        const arr = JSON.parse(raw);
        if (Array.isArray(arr)) return new Set(arr.filter(Boolean).map(String));
    } catch (e) { }
    return new Set();
}
function persistClickedGuids() {
    try { localStorage.setItem(EBDS_CLICKED_STORAGE_KEY, JSON.stringify(Array.from(EBDS_CLICKED_GUIDS))); }
    catch (e) { ebdsWarn('persist clicked guids', e); }
}
function addClickedGuid(guid) {
    if (!guid) return;
    EBDS_CLICKED_GUIDS.add(String(guid));
    persistClickedGuids();
}
function clearClickedGuids() {
    EBDS_CLICKED_GUIDS = new Set();
    try { localStorage.removeItem(EBDS_CLICKED_STORAGE_KEY); } catch (e) { }
}

EBDS_CLICKED_GUIDS = loadClickedGuids();

// Removed in 3.0.0: the Shift-click "hide by prefix" feature. Drop its key so
// stale data does not linger in localStorage forever.
try { localStorage.removeItem('ebdsHiddenPrefixes'); } catch (e) { }

let ebdsToastTimer = null;
// Minimal self-contained toast: cart failures used to be reported only to the
// console, so a silently dropped add looked identical to a successful one.
function ebdsToast(message, isError) {
    try {
        let toast = document.getElementById('ebds-toast');
        if (!toast) {
            toast = document.createElement('div');
            toast.id = 'ebds-toast';
            toast.setAttribute('role', 'status');
            toast.style.cssText = 'position:fixed;left:50%;top:16px;transform:translateX(-50%);' +
                'z-index:2147483647;padding:10px 14px;border-radius:8px;font-size:13px;font-weight:600;' +
                'color:#fff;box-shadow:0 6px 18px rgba(0,0,0,0.35);max-width:80vw;text-align:center;';
            document.body.appendChild(toast);
        }
        toast.textContent = message;
        toast.style.background = isError ? '#dc3545' : '#198754';
        toast.hidden = false;
        clearTimeout(ebdsToastTimer);
        ebdsToastTimer = setTimeout(() => { toast.hidden = true; }, isError ? 6000 : 2500);
    } catch (e) { }
}

// The cart endpoint's success payload is not documented, so this cannot assert
// success positively. It rejects the failure modes that otherwise arrive as a
// plain 200: a redirect away from the cart endpoint, and a signed-out login page.
function describeCartFailure(response, body) {
    try {
        if (response.redirected) {
            const target = new URL(response.url, location.href);
            if (!/\/cart\b/.test(target.pathname)) return 'not signed in, or redirected elsewhere';
        }
        if (/<form[^>]*\b(?:login|signin)\b|name=["']?password["']?/i.test(body)) return 'not signed in';
    } catch (e) { }
    return null;
}

function addToCartByGuid(guid, src) {
    if (!guid) return Promise.resolve(false);
    const guidKey = String(guid);
    if (EBDS_PENDING_CART_REQUESTS.has(guidKey)) return EBDS_PENDING_CART_REQUESTS.get(guidKey);

    const row = document.getElementById('guid' + guidKey);
    const cart = row ? row.querySelector('.icon_cart') : null;
    let request;

    // Dynamically appended cart icons do not have the site's jQuery handler, so use the API fallback.
    if (cart && !(row && row.dataset.ebdsInfiniteAdded === '1')) {
        request = new Promise(resolve => {
            let settled = false;
            const observer = new MutationObserver(() => {
                if (cart.classList.contains('icon_cart_clicked')) finish(true);
            });
            const timeout = setTimeout(() => {
                ebdsToast('Add to cart timed out - the site did not confirm the item', true);
                finish(false);
            }, 10000);
            function finish(added) {
                if (settled) return;
                settled = true;
                clearTimeout(timeout);
                observer.disconnect();
                if (added) markAddedInDOM(guidKey, src);
                resolve(added);
            }
            observer.observe(cart, { attributes: true, attributeFilter: ['class'] });
            try {
                cart.click();
                if (cart.classList.contains('icon_cart_clicked')) finish(true);
            } catch (error) {
                ebdsToast('Could not add to cart - the site rejected the click', true);
                finish(false);
            }
        });
    } else {
        request = (async () => {
            const server = (window.SERVERROOT !== undefined) ? String(window.SERVERROOT) : '/';
            const url = server.replace(/\/?$/, '/') + 'cart?add=' + encodeURIComponent(guidKey);
            try {
                const resp = await fetch(url, { method: 'POST', credentials: 'same-origin' });
                if (!resp.ok) {
                    ebdsToast('Could not add to cart (HTTP ' + resp.status + ')', true);
                    return false;
                }
                const failure = describeCartFailure(resp, await resp.text());
                if (failure) {
                    ebdsToast('Could not add to cart: ' + failure, true);
                    return false;
                }
                markAddedInDOM(guidKey, src);
                return true;
            } catch (err) {
                ebdsToast('Could not add to cart - the request failed', true);
            }
            return false;
        })();
    }

    request = request.finally(() => EBDS_PENDING_CART_REQUESTS.delete(guidKey));

    EBDS_PENDING_CART_REQUESTS.set(guidKey, request);
    return request;
}

function markAddedInDOM(guidToUse, src) {
    // When an item is marked added/hidden, also record its GUID for this session
    try { if (guidToUse) addClickedGuid(guidToUse); } catch (e) { }

    const orig = guidToUse ? document.querySelector('img.ebds-preview-img[data-guid="' + guidToUse + '"]') : null;
    if (orig) {
        const wrapper = orig.closest('.ebds-preview');
        try { if (wrapper) wrapper.remove(); else orig.remove(); } catch (e) { }
    }
    // Remove gallery items matching by data-guid or src
    try {
        const overlay = document.getElementById('ebds-gallery-overlay');
        if (overlay) {
            const galleryItems = Array.from(overlay.querySelectorAll('.ebds-gallery-item')).filter(item => {
                const img = item.querySelector('img');
                return (guidToUse && item.dataset.guid === guidToUse)
                    || (img && guidToUse && img.dataset.guid === guidToUse)
                    || (img && src && img.src === src);
            });
            galleryItems.forEach(item => {
                try { item.remove(); } catch (e) { }
            });

            try {
                if (typeof EBDS.updateGalleryFooter === 'function') EBDS.updateGalleryFooter();
            } catch (e) { }
        }
    } catch (e) { }
}

// Single source of truth for the Ctrl-hover modifier. A Ctrl chord that changes
// window focus (Ctrl+Tab, Ctrl+W, Alt/Cmd+Tab while Ctrl is held) delivers no
// keyup to the document, which used to leave ctrlPressed stuck true - afterwards
// merely moving the mouse over a thumbnail opened a full-screen lightbox that
// mouseleave then refused to close.
function ebdsReleaseCtrl() {
    ctrlPressed = false;
    try {
        if (typeof EBDS.closeAllHoverLightboxes === 'function') EBDS.closeAllHoverLightboxes();
    } catch (e) { }
}

document.addEventListener('keydown', (e) => {
    if (e.key === 'Control') ctrlPressed = true;
});
document.addEventListener('keyup', (e) => {
    if (e.key === 'Control') ebdsReleaseCtrl();
});
window.addEventListener('blur', ebdsReleaseCtrl);
document.addEventListener('visibilitychange', () => {
    if (document.hidden) ebdsReleaseCtrl();
});

// Pure filter decision for one row, kept out of the DOM so it can be exercised
// directly. Highlight keywords override the keyword, no-picture and
// minimum-size filters, but deliberately not the newsgroup exclusion.
function computeRowVisibility(flags) {
    const bypass = flags.bypass || {};
    const highlightOverrides = !!flags.highlighted;

    // The no-picture filter always applies in the gallery, and in the listing
    // only when it is not limited to Gallery mode.
    const missingInGallery = !!flags.missingPicture;
    const missingInListing = missingInGallery && !flags.hidePicturesOnlyInGallery;

    // A row is hidden when any filter matches it, and revealed again as soon as
    // a temporary bypass is active for one of the filters that matched it. Each
    // bypass used to clear only its own clause, so "N below minimum size"
    // appeared dead whenever the same release was also caught by another filter:
    // the reveal was overwritten by the clause that still applied.
    const hiddenByOther = !!(
        (!highlightOverrides && flags.blacklisted) ||
        flags.excludedNewsgroup ||
        (!highlightOverrides && flags.belowMinimumSize)
    );
    const revealedByOther = !!(
        (bypass.keywords && flags.blacklisted) ||
        (bypass.newsgroups && flags.excludedNewsgroup) ||
        (bypass.minimumSize && flags.belowMinimumSize)
    );

    const listingHidden = hiddenByOther || (!highlightOverrides && missingInListing);
    const listingRevealed = !!(revealedByOther || (bypass.withoutPictures && missingInListing));
    const galleryHidden = hiddenByOther || (!highlightOverrides && missingInGallery);
    const galleryRevealed = !!(revealedByOther || (bypass.withoutPictures && missingInGallery));

    return {
        hideInListing: !!(listingHidden && !listingRevealed),
        hideInGallery: !!(galleryHidden && !galleryRevealed),
        temporarilyShown: !!(!highlightOverrides && listingHidden && listingRevealed),
        // Scoped separately because the gallery applies the no-picture filter
        // even when the listing does not, so a card can be visible only because
        // of a bypass while its listing row was never hidden at all.
        temporarilyShownInGallery: !!(!highlightOverrides && galleryHidden && galleryRevealed)
    };
}

function applyRowFiltering() {
    if (!EBDS_ENABLED) return;
    getReleaseRows().forEach(row => {
        const hasOriginalImage = row.dataset && row.dataset.ebdsHasImgLink === '1';
        const titleText = getReleaseTitleText(row);
        const containsBlacklistedTerm = EBDS_BLACKLIST_TERMS.some(term => term && titleText.includes(term));
        const excludedNewsgroup = EBDS_EXCLUDED_NEWSGROUPS.includes(getReleaseNewsgroup(row));
        const containsHighlightedKeyword = EBDS_HIGHLIGHT_KEYWORDS.some(term => term && titleText.includes(term));
        const fileSizeMb = getReleaseSizeMb(row);
        const belowMinimumSize = EBDS_MIN_FILE_SIZE_MB > 0 && fileSizeMb !== null && fileSizeMb < EBDS_MIN_FILE_SIZE_MB;
        const visibility = computeRowVisibility({
            blacklisted: containsBlacklistedTerm,
            excludedNewsgroup,
            highlighted: containsHighlightedKeyword,
            belowMinimumSize,
            missingPicture: EBDS_HIDE_WITHOUT_PICTURES && !hasOriginalImage,
            hidePicturesOnlyInGallery: EBDS_HIDE_WITHOUT_PICTURES_ONLY_GALLERY,
            bypass: EBDS_TEMPORARY_FILTER_BYPASS
        });
        const shouldHide = visibility.hideInListing;
        // Single source of truth for the gallery, which applies the no-picture
        // filter even when the listing does not.
        row.dataset.ebdsGalleryHidden = visibility.hideInGallery ? '1' : '0';
        row.dataset.ebdsGalleryTemporary = visibility.temporarilyShownInGallery ? '1' : '0';
        row.classList.toggle('ebds-temporarily-shown', visibility.temporarilyShown);
        row.classList.toggle('ebds-keyword-highlight', containsHighlightedKeyword);

        if (shouldHide) {
            row.dataset.ebdsHidden = '1';
            row.style.display = 'none';
            // Also remove from gallery if present
            const guid = getReleaseGuid(row);
            if (guid) {
                const overlay = document.getElementById('ebds-gallery-overlay');
                if (overlay && overlay.classList.contains('visible')) {
                    const galleryImgs = Array.from(overlay.querySelectorAll('img')).filter(i => i.dataset.guid == guid);
                    galleryImgs.forEach(imgElem => {
                        const itemWrapper = imgElem.closest('.ebds-gallery-item');
                        if (itemWrapper) itemWrapper.remove();
                    });

                }
            }
        } else if (row.dataset.ebdsHidden === '1') {
            row.style.display = '';
            delete row.dataset.ebdsHidden;
        }
    });
    try {
        if (typeof EBDS.refreshGallery === 'function') EBDS.refreshGallery();
    } catch (e) { ebdsWarn('refresh gallery after filtering', e); }
}


(function () {
    'use strict';
    if (!EBDS_ENABLED) return;

    function isSupportedImageUrl(value) {
        try {
            return /\.(?:jpe?g|gif|png|webp|avif)$/i.test(new URL(value, location.href).pathname);
        } catch (e) {
            return false;
        }
    }

    function createImagePreview(link) {
        let div = document.createElement('div');
        div.classList.add('ebds-preview');
        div.style.display = 'inline-block';
        div.style.margin = '10px';
        div.style.position = 'relative';

        let img = document.createElement('img');
        img.classList.add('ebds-preview-img');
        // Use thumbnail src if available, else the link href
        let thumbSrc = link.querySelector('img') ? link.querySelector('img').src : link.href;
        img.src = thumbSrc;
        img.style.maxWidth = '400px';
        img.style.maxHeight = '400px';
        img.style.display = 'block';
        img.style.cursor = 'pointer';

        // Capture row/guid/cart at creation time so click handler can always find them
        let containingRow = link.closest('tr');
        let capturedGuid = null;
        capturedGuid = getReleaseGuid(containingRow);

        // expose a few data attributes so gallery items can add to cart later
        if (capturedGuid) try { img.dataset.guid = capturedGuid; } catch (e) { }
        try { img.dataset.href = link.href; } catch (e) { }
        if (containingRow && containingRow.id) try { img.dataset.rowId = containingRow.id; } catch (e) { };

        // Try to derive a display name and file size from the row (best-effort)
        let displayName = null;
        let displaySize = null;
        let displayPosted = null;
        let detailsHref = null;
        try {
            if (containingRow) {
                // Prefer a non-image link's text for the release name
                const nameAnchor = Array.from(containingRow.querySelectorAll('a')).find(a => a.href !== link.href && !isSupportedImageUrl(a.href) && a.textContent.trim().length > 0);
                if (nameAnchor) displayName = nameAnchor.textContent.trim();
                // Fallback: try the third cell which commonly holds the name
                if (!displayName) {
                    const nameCell = containingRow.querySelector('td:nth-child(3)');
                    if (nameCell) displayName = nameCell.textContent.trim();
                }
                displaySize = getReleaseSizeText(containingRow) || null;
                // Posted time: look for td with class "less mid"
                const timeCell = containingRow.querySelector('td.less.mid');
                if (timeCell) displayPosted = timeCell.textContent.trim();
                // Details href: look for a.title
                const titleLink = containingRow.querySelector('a.title');
                if (titleLink) detailsHref = titleLink.href;
            }
        } catch (e) { }
        if (displayName) try { img.dataset.name = displayName; } catch (e) { }
        if (displaySize) try { img.dataset.size = displaySize; } catch (e) { }
        if (displayPosted) try { img.dataset.posted = displayPosted; } catch (e) { }
        if (detailsHref) try { img.dataset.detailsHref = detailsHref; } catch (e) { }
        const newsgroupLabel = containingRow ? getReleaseNewsgroupLabel(containingRow) : '';
        if (newsgroupLabel) try { img.dataset.newsgroup = newsgroupLabel; } catch (e) { }

        // Store caption data for the gallery (do not display inline in the listing)
        try {
            const posted = displayPosted || '';
            const name = displayName || '';
            const size = displaySize || '';
            let fullText;
            if (size) {
                const prefix = posted ? posted + ' • ' : '';
                const suffix = ' • ' + size;
                fullText = prefix + name + suffix;
            } else {
                const parts = [posted, name].filter(Boolean);
                fullText = parts.join(' • ');
            }
            if (fullText.trim()) img.title = fullText.trim(); // tooltip only
        } catch (e) { }

        // Clicking the thumbnail opens the shared enlarged preview.
        img.addEventListener('click', function (e) {
            e.preventDefault();
            openEnlargedPreview({
                src: link.href,
                thumbnail: img,
                guid: capturedGuid || (() => {
                    const liveRow = containingRow || link.closest('tr') || null;
                    return getReleaseGuid(liveRow);
                })()
            });
        });

        // Add Ctrl+hover for lightbox preview
        img.addEventListener('mouseenter', (e) => {
            if (e.ctrlKey || ctrlPressed) openHoverLightbox(img, img.src);
        });
        img.addEventListener('mousemove', (e) => {
            if (e.ctrlKey || ctrlPressed) openHoverLightbox(img, img.src);
            else closeHoverLightbox(img);
        });
        img.addEventListener('mouseleave', () => {
            if (!ctrlPressed) closeHoverLightbox(img);
        });

        // Insert thumbnail inline (previous behavior)
        div.appendChild(img);
        link.parentNode.insertBefore(div, link.nextSibling);
    }

    EBDS.hoverLightboxes = new Map();

    // One shared enlarged-preview modal for the whole page. Building one per row
    // left hundreds of full-screen nodes in the body once infinite scrolling had
    // appended a few pages, and made the scroll handler below walk all of them.
    let enlargedContainer = null;
    let enlargedImg = null;
    let enlargedState = null;

    function ensureEnlargedPreview() {
        if (enlargedContainer) return;
        enlargedContainer = document.createElement('div');
        enlargedContainer.className = 'ebds-enlarged';
        enlargedContainer.style.cssText = 'position:fixed;top:0;left:0;width:100vw;height:100vh;' +
            'z-index:2147483660;display:none;align-items:center;justify-content:center;' +
            'background-color:rgba(0,0,0,0.8);';

        enlargedImg = document.createElement('img');
        enlargedImg.style.maxWidth = '90vw';
        enlargedImg.style.maxHeight = '90vh';
        enlargedImg.style.objectFit = 'contain';
        enlargedImg.style.cursor = 'pointer';
        enlargedContainer.appendChild(enlargedImg);

        enlargedContainer.addEventListener('click', ev => {
            if (ev.target === enlargedContainer) closeEnlargedPreview();
        });
        enlargedImg.addEventListener('click', onEnlargedImageClick);
        document.body.appendChild(enlargedContainer);
    }

    function openEnlargedPreview(state) {
        ensureEnlargedPreview();
        enlargedState = Object.assign({ openedAt: Date.now() }, state);
        if (enlargedImg.getAttribute('src') !== state.src) enlargedImg.src = state.src;
        enlargedContainer.style.display = 'flex';
    }

    function closeEnlargedPreview() {
        if (!enlargedContainer) return;
        enlargedContainer.style.display = 'none';
        enlargedState = null;
    }

    function onEnlargedImageClick(e) {
        e.preventDefault();
        const state = enlargedState;
        if (!state) return;
        // Ignore the click that opened the modal so a double-click on the
        // thumbnail cannot add the item by accident.
        if (Date.now() - state.openedAt < 300) {
            state.openedAt = 0;
            return;
        }
        const thumbnail = state.thumbnail;
        // Hide the thumbnail up front so layout and mouse events do not flicker.
        const restoreThumbnail = () => setTimeout(() => {
            try { thumbnail.style.visibility = 'visible'; thumbnail.style.pointerEvents = 'auto'; } catch (ex) { }
        }, 400);
        try { thumbnail.style.visibility = 'hidden'; thumbnail.style.pointerEvents = 'none'; } catch (ex) { }

        if (!state.guid) {
            ebdsToast('Could not work out which release this preview belongs to', true);
            restoreThumbnail();
            return;
        }
        addToCartByGuid(state.guid, state.src).then(added => {
            if (added) closeEnlargedPreview();
            else restoreThumbnail();
        });
    }

    // Single shared listener; the modal is one node, so this is now O(1).
    function closeAllEnlarged() {
        if (enlargedContainer && enlargedContainer.style.display !== 'none') closeEnlargedPreview();
    }
    ['scroll', 'wheel', 'touchmove'].forEach(evt => {
        window.addEventListener(evt, closeAllEnlarged, { passive: true });
    });
    window.addEventListener('resize', closeAllEnlarged);
    EBDS.closeAllEnlarged = closeAllEnlarged;

    function openHoverLightbox(img, src) {
        if (EBDS.hoverLightboxes.has(img)) return;
        const lb = document.createElement('div');
        lb.className = 'ebds-hover-lb';
        lb.style.position = 'fixed';
        lb.style.inset = '0';
        lb.style.background = 'rgba(0,0,0,0.95)';
        lb.style.display = 'flex';
        lb.style.alignItems = 'center';
        lb.style.justifyContent = 'center';
        lb.style.zIndex = '2147483650';
        const im = document.createElement('img');
        im.src = src;
        applyCtrlHoverPreviewSizing(im);
        lb.appendChild(im);
        im.style.cursor = 'pointer';
        im.title = 'Click to add to cart';
        im.addEventListener('click', (ev) => {
            ev.stopPropagation();
            const guid = img.dataset.guid;
            if (guid) {
                addToCartByGuid(guid, img.src);
                lb.remove();
                EBDS.hoverLightboxes.delete(img);
            }
        });
        lb.addEventListener('click', () => {
            lb.remove();
            EBDS.hoverLightboxes.delete(img);
        });
        document.body.appendChild(lb);
        EBDS.hoverLightboxes.set(img, lb);
    }

    function closeHoverLightbox(img) {
        const lb = EBDS.hoverLightboxes.get(img);
        if (lb) {
            lb.remove();
            EBDS.hoverLightboxes.delete(img);
        }
    }

    function processRows(rows) {
        Array.from(rows).forEach(row => {
            if (row.dataset.ebdsPreviewProcessed === '1') return;
            const imageLinks = Array.from(row.querySelectorAll('a[href]')).filter(link => isSupportedImageUrl(link.href));
            if (imageLinks.length > 0) {
                try { row.dataset.ebdsHasImgLink = '1'; } catch (e) { }
                const preferredLink = imageLinks.find(link => link.querySelector('img')) || imageLinks[0];
                createImagePreview(preferredLink);
            }
            row.dataset.ebdsPreviewProcessed = '1';
            Array.from(row.querySelectorAll('a')).forEach(link => {
                const text = link.textContent.trim();
                if (text === 'Thumbnail' || text === 'Preview') link.remove();
            });
        });
    }

    EBDS.processRows = processRows;
    processRows(getReleaseRows());
})();

(function () {
    'use strict';
    if (!EBDS_ENABLED) return;
    // Inject CSS for gallery button and overlay
    const style = document.createElement('style');
    style.textContent = `
    .ebds-gallery-button{
        position:fixed;
        right:16px;
        bottom:16px;
        transform:none;
        z-index:2147483646;
        background:#0d6efd;
        color:white;
        border:none;
        border-radius:50%;
        width:56px;
        height:56px;
        display:flex;
        align-items:center;
        justify-content:center;
        box-shadow:0 6px 18px rgba(0,0,0,0.25);
        cursor:pointer;
        font-weight:600;
        font-size:14px;
    }
    .ebds-gallery-button.active{
        background:white;
        color:#0d6efd;
    }
    .ebds-config-button{
        z-index:2147483646;
        background:#f0ad4e;
        color:#1b1f24;
        border:none;
        border-radius:10px;
        padding:8px 12px;
        min-width:72px;
        display:inline-flex;
        align-items:center;
        justify-content:center;
        box-shadow:0 6px 18px rgba(0,0,0,0.18);
        cursor:pointer;
        font-weight:700;
        font-size:12px;
        line-height:1.2;
        gap:6px;
    }
    .ebds-config-button.ebds-floating{
        position:fixed;
        top:12px;
        right:16px;
        transform:none;
    }
    .ebds-sab-button{
        position:fixed;
        top:12px;
        right:96px;
        z-index:2147483646;
        background:#6c757d;
        color:white;
        border:none;
        border-radius:10px;
        padding:7px 9px;
        min-width:82px;
        cursor:pointer;
        font-weight:700;
        font-size:11px;
        line-height:1.2;
        box-shadow:0 6px 18px rgba(0,0,0,0.18);
    }
    .ebds-sab-button:disabled{ cursor:wait; opacity:0.8; }
    .ebds-sab-button.ebds-success{ background:#198754; }
    .ebds-sab-button.ebds-error{ background:#dc3545; }
    .ebds-config-panel{
        position:fixed;
        right:16px;
        top:60px;
        bottom:auto;
        width:320px;
        max-width:90vw;
        background:#0f0f0f;
        color:#eee;
        border:1px solid rgba(255,255,255,0.08);
        border-radius:12px;
        padding:12px;
        box-shadow:0 12px 32px rgba(0,0,0,0.55);
        z-index:2147483647;
        display:none;
        max-height:80vh;
        overflow-y:auto;
    }
    .ebds-config-panel.visible{ display:block; }
    .ebds-config-title{ font-weight:700; font-size:14px; margin-bottom:4px; }
    .ebds-config-desc{ color:#b5b5b5; font-size:12px; margin-bottom:8px; }
    .ebds-config-section{ margin-top:10px; }
    .ebds-config-section > summary{ cursor:pointer; list-style-position:inside; }
    .ebds-config-section > summary.ebds-config-title{ margin-bottom:0; }
    .ebds-config-section[open] > summary.ebds-config-title{ margin-bottom:4px; }
    .ebds-config-section-content{ margin-top:8px; }
    .ebds-config-help{ color:#b5b5b5; font-size:11px; line-height:1.35; margin:0 0 8px; }
    .ebds-config-status{ min-height:16px; }
    .ebds-config-row{ display:flex; gap:8px; }
    .ebds-config-input{ flex:1; padding:7px 8px; border-radius:8px; border:1px solid #2b2b2b; background:#1a1a1a; color:#eee; }
    .ebds-config-add{ background:#0d6efd; color:white; border:none; border-radius:8px; padding:7px 10px; font-weight:700; cursor:pointer; }
    .ebds-chip-list{ display:flex; flex-wrap:wrap; gap:8px; margin-top:10px; }
    .ebds-chip{ background:#191919; border:1px solid #2d2d2d; padding:6px 8px; border-radius:999px; display:inline-flex; align-items:center; gap:6px; font-size:12px; }
    .ebds-chip button{ background:none; border:none; color:#bbb; cursor:pointer; font-weight:700; }
    .ebds-chip button:hover{ color:#fff; }
    tr.ebds-keyword-highlight > td{
        background-color:rgba(122,184,0,0.18) !important;
        box-shadow:inset 0 2px 0 rgba(122,184,0,0.8), inset 0 -2px 0 rgba(122,184,0,0.8);
        font-weight:700;
    }
    tr.ebds-keyword-highlight > td:first-child{ box-shadow:inset 5px 0 #7ab800, inset 0 2px 0 rgba(122,184,0,0.8), inset 0 -2px 0 rgba(122,184,0,0.8); }
    .ebds-gallery-item.ebds-keyword-highlight{ outline:3px solid #7ab800; outline-offset:2px; box-shadow:0 0 12px rgba(122,184,0,0.45); }
    .ebds-gallery-item.ebds-keyword-highlight .ebds-preview-caption{ color:#7ab800; font-weight:700; }

    .ebds-config-empty{ color:#888; font-size:12px; }
    .ebds-config-actions{ display:flex; justify-content:flex-end; margin-top:10px; }
    .ebds-config-close{ background:#343a40; color:white; border:none; border-radius:8px; padding:6px 10px; cursor:pointer; }
    .ebds-config-toggle{ display:flex; align-items:center; gap:8px; margin:0; font-size:12px; }
    .ebds-infinite-scroll-gallery-only-config{ margin-left:16px; }
    .ebds-hide-without-pics-only-gallery-config{ margin-left:16px; }
    .ebds-config-toggle input[type="checkbox"]{ width:16px; height:16px; }
    .ebds-config-toggle input[type="range"]{ -webkit-appearance: none; appearance: none; background: #ddd; height: 6px; border-radius: 3px; flex:1; }
    .ebds-config-toggle input[type="range"]::-webkit-slider-thumb{ -webkit-appearance: none; appearance: none; width: 16px; height: 16px; background: #0d6efd; border-radius: 50%; cursor: pointer; }
    .ebds-config-toggle input[type="range"]::-moz-range-thumb{ width: 16px; height: 16px; background: #0d6efd; border-radius: 50%; cursor: pointer; border: none; }
    .ebds-category-toggles{
        display: grid;
        grid-template-columns: 1fr 1fr;
        gap: 8px;
    }
    #ebds-gallery-overlay{
        position:fixed;
        inset:0;
        z-index:2147483645;
        background:rgba(0,0,0,0.9);
        display:none;
        padding:24px;
        overflow:auto;
    }
    #ebds-gallery-overlay.visible{ display:block; }
    .ebds-gallery-grid{
        display:grid;
        grid-template-columns: repeat(var(--ebds-cols, 3), minmax(0, 1fr));
        gap:16px;
        align-items:start;
    }
    .ebds-gallery-item img{
        width:100%;
        height:auto;
        max-height:500px;
        display:block;
        border-radius:6px;
        box-shadow:0 4px 12px rgba(0,0,0,0.5);
        cursor:pointer;
    }
    .ebds-gallery-placeholder-visual{
        min-height:180px;
        display:flex;
        align-items:center;
        justify-content:center;
        border:1px dashed #555;
        border-radius:6px;
        background:#191919;
        color:#888;
        font-size:14px;
        text-decoration:none;
        cursor:pointer;
    }
    .ebds-gallery-placeholder-visual:hover{ color:#bbb; border-color:#777; }
    .ebds-gallery-item.ebds-temporarily-shown img{
        box-shadow:0 0 0 2px #ff8c00, 0 4px 12px rgba(0,0,0,0.5);
    }
    .ebds-gallery-item.ebds-temporarily-shown .ebds-preview-caption{ color:#ff8c00; }
    .ebds-gallery-item.ebds-temporarily-shown .ebds-gallery-placeholder-visual{
        color:#ff8c00;
        border-color:#ff8c00;
    }
    /* When the vertical limiter is disabled, allow tall images to expand */
    #ebds-gallery-overlay.ebds-gallery-no-vertical-limit .ebds-gallery-item img{
        max-height:none;
    }
    .ebds-gallery-item{
        position:relative;
        min-width:0;
        overflow:hidden;
    }
    .ebds-gallery-newsgroup{
        display:block;
        width:max-content;
        max-width:100%;
        margin:6px auto 0;
        overflow:hidden;
        text-overflow:ellipsis;
        white-space:nowrap;
    }
    .ebds-preview-caption{
        display:block;
        width:100%;
        margin-top:6px;
        font-size:12px;
        color:#ddd;
        max-width:100%;
        overflow:hidden;
        text-overflow:ellipsis;
        white-space:nowrap;
    }
    /* Highlight for posts newer than last visit */
    .ebds-preview-caption.ebds-new-post{
        color: #7ab800; /* green */
    }
    .ebds-gallery-category-links{
        margin-top:0px;
        display:flex;
        justify-content:center;
        gap:10px;
        flex-wrap:wrap;
    }
    .ebds-gallery-category-links a{
        color:#ddd;
        text-decoration:none;
    }
    .ebds-gallery-category-links a:hover{
        color:#fff;
    }
    .ebds-gallery-pagination{
        margin-top:0px;
        display:flex;
        justify-content:center;
        color:#ddd;
    }
    .ebds-gallery-pagination-spacer.ebds-top{ padding:4px 0 16px; }
    .ebds-gallery-pagination-spacer.ebds-bottom{ padding:16px 0 4px; }
    .ebds-gallery-pagination .pagination{ margin:0; }
    .ebds-gallery-pagination a, .ebds-gallery-pagination span { color: #ddd; }
    body.ebds-infinite-active nav.pagination:not(.ebds-gallery-pagination),
    body.ebds-infinite-active nav[aria-label="Pagination"]:not(.ebds-gallery-pagination),
    body.ebds-infinite-active ul.pagination:not(.ebds-gallery-pagination){ display:none !important; }
    .ebds-infinite-status{
        display:block;
        width:100%;
        padding:16px;
        border:0;
        background:transparent;
        color:#aaa;
        font-size:12px;
        text-align:center;
        cursor:default;
    }
    .ebds-infinite-status[hidden]{ display:none; }
    .ebds-infinite-status.ebds-retry{ color:#f0a040; cursor:pointer; }
    .ebds-gallery-filter-links{
        padding:16px 0 4px;
        color:#aaa;
        font-size:12px;
        text-align:center;
    }
    .ebds-gallery-filter-links.ebds-header{ padding:4px 0 16px; }
    .ebds-gallery-filter-links a{ color:#f0a040; text-decoration:none; }
    .ebds-gallery-filter-links a:hover{ color:#ffc166; text-decoration:underline; }
    .ebds-gallery-filter-links a.ebds-filter-bypassed{
        color:#ff8c00;
        font-weight:600;
        text-decoration:underline;
    }
    .ebds-gallery-scroll-top{
        position:fixed;
        left:50%;
        bottom:16px;
        transform:translateX(-50%);
        z-index:1;
        padding:8px 12px;
        border-radius:999px;
        background:rgba(13,110,253,0.95);
        color:#fff;
        box-shadow:0 6px 18px rgba(0,0,0,0.35);
        font-size:12px;
        text-decoration:none;
        white-space:nowrap;
    }
    .ebds-gallery-scroll-top:hover{
        background:#3d8bfd;
        color:#fff;
        text-decoration:none;
    }
    .ebds-gallery-scroll-top[hidden]{ display:none; }
    .ebds-listing-scroll-top{
        position:fixed;
        left:50%;
        bottom:16px;
        transform:translateX(-50%);
        z-index:2147483644;
        padding:8px 12px;
        border-radius:999px;
        background:rgba(13,110,253,0.95);
        color:#fff;
        box-shadow:0 6px 18px rgba(0,0,0,0.35);
        font-size:12px;
        text-decoration:none;
        white-space:nowrap;
    }
    .ebds-listing-scroll-top:hover{
        background:#3d8bfd;
        color:#fff;
        text-decoration:none;
    }
    .ebds-listing-scroll-top[hidden]{ display:none; }
    `;
    document.head.appendChild(style);
    // Gallery-only infinite scrolling still appends its rows into the listing
    // table, so once pages have been appended the site's pagination no longer
    // describes what is on screen and must stay hidden even though listing
    // infinite scrolling is nominally off.
    let ebdsAppendedPageCount = 0;

    function isListingPaginationSuppressed() {
        return isListingInfiniteScrollActive() || ebdsAppendedPageCount > 0;
    }

    function updateInfinitePaginationState() {
        document.body.classList.toggle('ebds-infinite-active', isListingPaginationSuppressed());
    }

    updateInfinitePaginationState();

    let EBDS_GALLERY_COLS = readPref(EBDS_GALLERY_COLS_KEY, 'integer', 3, { min: 1, max: 10 });
    // Gallery vertical limiter: when true, images are constrained to max-height.
    let EBDS_GALLERY_VERTICAL_LIMIT = readPref(EBDS_GALLERY_VERTICAL_LIMIT_KEY, 'flag', false);
    let EBDS_GALLERY_SHOW_ONLY_NEW = readPref(EBDS_GALLERY_SHOW_ONLY_NEW_KEY, 'flag', false);
    let EBDS_SESSION_EXPIRATION_MINUTES = readPref(EBDS_SESSION_EXPIRATION_KEY, 'integer', 5, { min: 1, max: 1440 });

    const categoryLinks = [
        { id: 'todaysTop', href: '/browse?t=6000&top=1', title: 'Todays Top Grabs', text: 'Todays Top Grabs' },
        { id: 'allXxx', href: '/browse?t=6000', text: 'All XXX' },
        { id: 'dvd', href: '/browse?t=6010', text: 'DVD' },
        { id: 'hd', href: '/browse?t=6040', text: 'HD' },
        { id: 'other', href: '/browse?t=6999', text: 'Other' },
        { id: 'packs', href: '/browse?t=6070', text: 'Packs' },
        { id: 'sd', href: '/browse?t=6080', text: 'SD' },
        { id: 'uhd', href: '/browse?t=6045', text: 'UHD' },
        { id: 'vr', href: '/browse?t=6050', text: 'VR' },
        { id: 'wmv', href: '/browse?t=6020', text: 'WMV' },
        { id: 'xvid', href: '/browse?t=6030', text: 'XviD' }
    ];

    // Create floating button
    const btn = document.createElement('button');
    btn.className = 'ebds-gallery-button';
    btn.title = 'Open image gallery (G)';
    btn.textContent = 'Gallery';
    document.body.appendChild(btn);

    // Configuration button and panel for blacklist keywords
    const configBtn = document.createElement('button');
    configBtn.className = 'ebds-config-button ebds-floating';
    configBtn.title = 'Configure EBDS';
    configBtn.textContent = 'Config';
    document.body.appendChild(configBtn);

    const sabBtn = document.createElement('button');
    sabBtn.className = 'ebds-sab-button';
    sabBtn.hidden = true;
    sabBtn.title = 'Tell SABnzbd to read and process all RSS feeds now';
    sabBtn.textContent = 'Read feeds';
    document.body.appendChild(sabBtn);

    const configPanel = document.createElement('div');
    configPanel.className = 'ebds-config-panel';
    configPanel.innerHTML = `
        <div class="ebds-config-title">Keyboard shortcuts</div>
        <div class="ebds-config-desc">G: Gallery | ←/→: Navigate | Esc: Close | Ctrl-hover: Preview</div>
        <div class="ebds-config-toggle">
            <input class="ebds-infinite-scroll-toggle" type="checkbox" id="ebdsInfiniteScrollToggle">
            <label for="ebdsInfiniteScrollToggle">Infinite scrolling</label>
        </div>
        <div class="ebds-config-toggle ebds-infinite-scroll-gallery-only-config">
            <input class="ebds-infinite-scroll-gallery-only-toggle" type="checkbox" id="ebdsInfiniteScrollGalleryOnlyToggle">
            <label for="ebdsInfiniteScrollGalleryOnlyToggle">Only in Gallery mode</label>
        </div>
        <div class="ebds-config-toggle">
            <input class="ebds-gallery-only-new-toggle" type="checkbox" id="ebdsGalleryOnlyNewToggle">
            <label for="ebdsGalleryOnlyNewToggle">Show only new items in gallery</label>
        </div>
        <div class="ebds-config-toggle">
            <input class="ebds-hide-nopics-toggle" type="checkbox" id="ebdsHideNoPicsToggle">
            <label for="ebdsHideNoPicsToggle">Hide items without pictures</label>
        </div>
        <div class="ebds-config-toggle ebds-hide-without-pics-only-gallery-config">
            <input class="ebds-hide-without-pics-only-gallery-toggle" type="checkbox" id="ebdsHideWithoutPicsOnlyGalleryToggle">
            <label for="ebdsHideWithoutPicsOnlyGalleryToggle">Only in Gallery mode</label>
        </div>
        <details class="ebds-config-section">
            <summary class="ebds-config-title">Advanced options</summary>
            <div class="ebds-config-section-content">
                <div class="ebds-config-toggle">
                    <label for="ebdsMinFileSizeInput">Minimum filesize (MB, 0 disables)</label>
                    <input id="ebdsMinFileSizeInput" type="number" min="0" step="0.01" value="0" style="width:90px; padding:4px;">
                    <button id="ebdsMinFileSizeSet" type="button" class="ebds-config-add" style="padding:6px 8px;">Set</button>
                </div>
                <div class="ebds-config-toggle">
                    <input class="ebds-vertical-limit-toggle" type="checkbox" id="ebdsVerticalLimitToggle">
                    <label for="ebdsVerticalLimitToggle">Limit gallery image height</label>
                </div>
                <div class="ebds-config-toggle">
                    <input class="ebds-scale-hover-toggle" type="checkbox" id="ebdsScaleHoverToggle">
                    <label for="ebdsScaleHoverToggle">Scale Ctrl-hover preview to browser size</label>
                </div>
                <div class="ebds-config-toggle">
                    <label for="ebdsFilterLinksPosition">Filter count links</label>
                    <select id="ebdsFilterLinksPosition">
                        <option value="footer">Footer</option>
                        <option value="header">Header</option>
                        <option value="hidden">Not shown</option>
                    </select>
                </div>
                <div class="ebds-config-toggle">
                    <label for="ebdsGalleryCols">Gallery columns: <span id="ebdsGalleryColsValue">3</span></label>
                    <input type="range" id="ebdsGalleryCols" min="1" max="10" value="3">
                </div>
                <div class="ebds-config-toggle">
                    <label for="ebdsSessionExpirationInput">Session expiration (mins): <span id="ebdsSessionExpirationValue">5</span></label>
                    <input type="number" id="ebdsSessionExpirationInput" min="1" max="1440" value="5" style="width:80px; padding:4px;">
                    <button id="ebdsSessionExpirationSet" type="button" class="ebds-config-add" style="margin-left:8px; padding:6px 8px;">Set</button>
                </div>
            </div>
        </details>
        <details class="ebds-config-section">
            <summary class="ebds-config-title">SABnzbd</summary>
            <div class="ebds-config-section-content">
                <div class="ebds-config-desc">Credentials are stored by the userscript manager.</div>
                <div class="ebds-config-toggle" style="margin-bottom:8px;">
                    <input class="ebds-hide-sab-button-toggle" type="checkbox" id="ebdsHideSabButtonToggle">
                    <label for="ebdsHideSabButtonToggle">Hide Read feeds button</label>
                </div>
                <div class="ebds-config-row" style="margin-bottom:8px;">
                    <input id="ebdsSabUrl" class="ebds-config-input" type="url" inputmode="url" autocomplete="off" placeholder="https://sabnzbd.example.com" aria-label="SABnzbd URL">
                </div>
                <div class="ebds-config-row">
                    <input id="ebdsSabApiKey" class="ebds-config-input" type="password" autocomplete="new-password" data-bwignore="true" placeholder="SABnzbd API key" aria-label="SABnzbd API key">
                    <button id="ebdsSabSave" class="ebds-config-add" type="button">Save</button>
                </div>
                <div id="ebdsSabStatus" class="ebds-config-desc" style="margin-top:6px; margin-bottom:0;"></div>
            </div>
        </details>
        <details class="ebds-config-section">
            <summary class="ebds-config-title">Exclude keywords</summary>
            <div class="ebds-config-section-content">
                <div class="ebds-config-desc">Release titles containing these terms will be hidden.</div>
                <div class="ebds-config-row">
                    <input class="ebds-config-input ebds-blacklist-input" type="text" placeholder="Add keyword..." aria-label="Add keyword to exclude">
                    <button class="ebds-config-add ebds-blacklist-add" type="button">Add</button>
                </div>
                <div class="ebds-chip-list"></div>
            </div>
        </details>
        <details class="ebds-config-section">
            <summary class="ebds-config-title">Exclude newsgroups</summary>
            <div class="ebds-config-section-content">
                <div class="ebds-config-desc">Releases from these newsgroups will be hidden. The list below contains groups found on this page.</div>
                <div class="ebds-config-row">
                    <input class="ebds-config-input ebds-newsgroup-input" type="text" placeholder="Add newsgroup..." aria-label="Add newsgroup to exclude">
                    <button class="ebds-config-add ebds-newsgroup-add" type="button">Add</button>
                </div>
                <div class="ebds-config-row">
                    <select class="ebds-config-input ebds-current-newsgroups" aria-label="Current newsgroups">
                        <option value="">Select a current newsgroup...</option>
                    </select>
                    <button class="ebds-config-add ebds-current-newsgroup-add" type="button" title="Add selected newsgroup">Add</button>
                </div>
                <div class="ebds-config-toggle">
                    <input class="ebds-show-gallery-newsgroup-toggle" type="checkbox" id="ebdsShowGalleryNewsgroupToggle">
                    <label for="ebdsShowGalleryNewsgroupToggle">Show newsgroup badges in gallery</label>
                </div>
                <div class="ebds-chip-list ebds-newsgroup-list"></div>
            </div>
        </details>
        <details class="ebds-config-section">
            <summary class="ebds-config-title">Highlight keywords</summary>
            <div class="ebds-config-section-content">
                <div class="ebds-config-desc">Release titles containing these terms will be highlighted without being hidden.</div>
                <div class="ebds-config-row">
                    <input class="ebds-config-input ebds-highlight-input" type="text" placeholder="Add keyword..." aria-label="Add keyword to highlight">
                    <button class="ebds-config-add ebds-highlight-add" type="button">Add</button>
                </div>
                <div class="ebds-chip-list ebds-highlight-list"></div>
            </div>
        </details>
        <details class="ebds-config-section">
            <summary class="ebds-config-title">GitHub Gist backup</summary>
            <div class="ebds-config-section-content">
                <div class="ebds-config-help">Create a fine-grained GitHub token with the Gists user permission set to write, then enter it here. The token is stored only in userscript-manager storage.</div>
                <div class="ebds-config-row" style="margin-bottom:8px;">
                    <input id="ebdsGithubToken" class="ebds-config-input" type="password" autocomplete="new-password" placeholder="GitHub token" aria-label="GitHub token">
                    <button id="ebdsGithubTokenSave" class="ebds-config-add" type="button">Save</button>
                    <button id="ebdsGithubTokenForget" class="ebds-config-close" type="button" title="Remove the stored GitHub token">Forget</button>
                </div>
                <div class="ebds-config-row" style="margin-bottom:8px;">
                    <input id="ebdsGithubGistId" class="ebds-config-input" type="text" autocomplete="off" placeholder="Gist ID (optional for first backup)" aria-label="GitHub Gist ID">
                    <button id="ebdsGithubGistIdSave" class="ebds-config-add" type="button">Save</button>
                </div>
                <label class="ebds-config-toggle" style="margin-bottom:8px;">
                    <input id="ebdsGithubIncludeSab" type="checkbox">
                    <span>Include SABnzbd credentials in backup</span>
                </label>
                <label class="ebds-config-toggle" style="margin-bottom:8px;">
                    <input id="ebdsGithubAutoBackup" type="checkbox">
                    <span>Automatically back up when the last backup is older than 1 day</span>
                </label>
                <div class="ebds-config-row">
                    <button id="ebdsGithubBackup" class="ebds-config-add" type="button">Backup to Gist</button>
                    <button id="ebdsGithubRestore" class="ebds-config-close" type="button">Restore from Gist</button>
                </div>
                <div id="ebdsGithubStatus" class="ebds-config-help ebds-config-status" style="margin-top:6px; margin-bottom:0;"></div>
            </div>
        </details>


        <div class="ebds-config-actions"><button class="ebds-config-close" type="button">Close</button></div>
    `;
    document.body.appendChild(configPanel);

    const categoryLinksDiv = document.createElement('details');
    categoryLinksDiv.className = 'ebds-config-section';
    categoryLinksDiv.innerHTML = '<summary class="ebds-config-title">Category links visibility</summary><div class="ebds-config-section-content"><div class="ebds-config-desc">Choose which category links to show in the gallery.</div><div class="ebds-category-toggles"></div></div>';
    configPanel.appendChild(categoryLinksDiv);

    const actions = configPanel.querySelector('.ebds-config-actions');
    if (actions) {
        configPanel.insertBefore(categoryLinksDiv, actions);
    }

    function loadLinkVisibility() {
        return readPref(EBDS_LINK_VISIBILITY_KEY, 'json', {}, {
            validate: value => !!value && typeof value === 'object' && !Array.isArray(value)
        });
    }

    function persistLinkVisibility(vis) {
        writePref(EBDS_LINK_VISIBILITY_KEY, JSON.stringify(vis));
    }

    let EBDS_LINK_VISIBILITY = loadLinkVisibility();

    categoryLinks.forEach(link => {
        const toggleDiv = document.createElement('div');
        toggleDiv.className = 'ebds-config-toggle';
        const input = document.createElement('input');
        input.type = 'checkbox';
        input.id = 'ebdsLink' + link.id;
        input.checked = EBDS_LINK_VISIBILITY[link.id] !== false;
        input.addEventListener('change', () => {
            EBDS_LINK_VISIBILITY[link.id] = input.checked;
            persistLinkVisibility(EBDS_LINK_VISIBILITY);
        });
        const label = document.createElement('label');
        label.htmlFor = input.id;
        label.textContent = link.text;
        toggleDiv.appendChild(input);
        toggleDiv.appendChild(label);
        categoryLinksDiv.querySelector('.ebds-category-toggles').appendChild(toggleDiv);
    });

    const blacklistInput = configPanel.querySelector('.ebds-blacklist-input');
    const blacklistList = configPanel.querySelector('.ebds-chip-list');
    const blacklistSection = blacklistInput && blacklistInput.closest('.ebds-config-section');
    const addBlacklistBtn = configPanel.querySelector('.ebds-blacklist-add');
    const newsgroupInput = configPanel.querySelector('.ebds-newsgroup-input');
    const newsgroupList = configPanel.querySelector('.ebds-newsgroup-list');
    const addNewsgroupBtn = configPanel.querySelector('.ebds-newsgroup-add');
    const currentNewsgroupsSelect = configPanel.querySelector('.ebds-current-newsgroups');
    const addCurrentNewsgroupBtn = configPanel.querySelector('.ebds-current-newsgroup-add');
    const showGalleryNewsgroupToggle = configPanel.querySelector('.ebds-show-gallery-newsgroup-toggle');
    const highlightInput = configPanel.querySelector('.ebds-highlight-input');
    const highlightList = configPanel.querySelector('.ebds-highlight-list');
    const highlightSection = highlightInput && highlightInput.closest('.ebds-config-section');
    const addHighlightBtn = configPanel.querySelector('.ebds-highlight-add');
    const closeConfigBtn = configPanel.querySelector('.ebds-config-actions .ebds-config-close');
    const hidePicsToggle = configPanel.querySelector('.ebds-hide-nopics-toggle');
    const hideWithoutPicsOnlyGalleryToggle = configPanel.querySelector('.ebds-hide-without-pics-only-gallery-toggle');
    const minFileSizeInput = configPanel.querySelector('#ebdsMinFileSizeInput');
    const minFileSizeSetBtn = configPanel.querySelector('#ebdsMinFileSizeSet');
    const verticalLimitToggle = configPanel.querySelector('.ebds-vertical-limit-toggle');
    const scaleHoverToggle = configPanel.querySelector('.ebds-scale-hover-toggle');
    const filterLinksPositionSelect = configPanel.querySelector('#ebdsFilterLinksPosition');
    const galleryOnlyNewToggle = configPanel.querySelector('.ebds-gallery-only-new-toggle');
    const infiniteScrollToggle = configPanel.querySelector('.ebds-infinite-scroll-toggle');
    const sabUrlInput = configPanel.querySelector('#ebdsSabUrl');
    const sabApiKeyInput = configPanel.querySelector('#ebdsSabApiKey');
    const sabSaveBtn = configPanel.querySelector('#ebdsSabSave');
    const sabStatus = configPanel.querySelector('#ebdsSabStatus');
    const hideSabButtonToggle = configPanel.querySelector('.ebds-hide-sab-button-toggle');
    const infiniteScrollGalleryOnlyToggle = configPanel.querySelector('.ebds-infinite-scroll-gallery-only-toggle');
    const githubTokenInput = configPanel.querySelector('#ebdsGithubToken');
    const githubTokenSaveBtn = configPanel.querySelector('#ebdsGithubTokenSave');
    const githubTokenForgetBtn = configPanel.querySelector('#ebdsGithubTokenForget');
    const githubGistIdInput = configPanel.querySelector('#ebdsGithubGistId');
    const githubGistIdSaveBtn = configPanel.querySelector('#ebdsGithubGistIdSave');
    const githubIncludeSabToggle = configPanel.querySelector('#ebdsGithubIncludeSab');
    const githubAutoBackupToggle = configPanel.querySelector('#ebdsGithubAutoBackup');
    const githubBackupBtn = configPanel.querySelector('#ebdsGithubBackup');
    const githubRestoreBtn = configPanel.querySelector('#ebdsGithubRestore');
    const githubStatus = configPanel.querySelector('#ebdsGithubStatus');

    function loadSabConfig() {
        try {
            return {
                url: String(GM_getValue(EBDS_SAB_URL_KEY, '') || ''),
                apiKey: String(GM_getValue(EBDS_SAB_API_KEY, '') || '')
            };
        } catch (e) {
            return { url: '', apiKey: '' };
        }
    }

    function loadGithubSetting(key) {
        try { return String(GM_getValue(key, '') || ''); } catch (e) { return ''; }
    }

    async function saveGithubSetting(key, value) {
        await GM_setValue(key, String(value || '').trim());
    }

    function setGithubStatus(message, isError = false) {
        if (!githubStatus) return;
        githubStatus.textContent = message;
        githubStatus.style.color = isError ? '#ff7b7b' : '#7ab800';
    }

    function loadLastGithubBackup() {
        try {
            const stored = JSON.parse(GM_getValue(EBDS_GITHUB_LAST_BACKUP_KEY, '') || '');
            if (!stored || typeof stored !== 'object' || !stored.gistId || !stored.savedAt) return null;
            const savedAt = new Date(stored.savedAt);
            if (Number.isNaN(savedAt.getTime())) return null;
            return { gistId: String(stored.gistId), savedAt };
        } catch (e) {
            return null;
        }
    }

    function formatBackupAge(savedAt) {
        const elapsedSeconds = Math.max(0, Math.floor((Date.now() - savedAt.getTime()) / 1000));
        if (elapsedSeconds < 60) return 'just now';
        const minutes = Math.floor(elapsedSeconds / 60);
        if (minutes < 60) return `${minutes} minute${minutes === 1 ? '' : 's'} ago`;
        const hours = Math.floor(minutes / 60);
        if (hours < 24) return `${hours} hour${hours === 1 ? '' : 's'} ago`;
        const days = Math.floor(hours / 24);
        return `${days} day${days === 1 ? '' : 's'} ago`;
    }

    function renderLastGithubBackupStatus() {
        const lastBackup = loadLastGithubBackup();
        if (!lastBackup) return false;
        setGithubStatus(`Backup saved to Gist ${lastBackup.gistId}. Last backup: ${formatBackupAge(lastBackup.savedAt)}.`);
        return true;
    }

    async function recordLastGithubBackup(gistId) {
        await GM_setValue(EBDS_GITHUB_LAST_BACKUP_KEY, JSON.stringify({
            gistId,
            savedAt: new Date().toISOString()
        }));
    }

    function requestGithub(method, path, token, body) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method,
                url: `https://api.github.com${path}`,
                headers: {
                    Accept: 'application/vnd.github+json',
                    Authorization: `Bearer ${token}`,
                    'X-GitHub-Api-Version': '2022-11-28',
                    ...(body ? { 'Content-Type': 'application/json' } : {})
                },
                data: body ? JSON.stringify(body) : undefined,
                timeout: 15000,
                responseType: 'json',
                onload: response => {
                    let data = response.response;
                    if (!data && response.responseText) {
                        try { data = JSON.parse(response.responseText); } catch (e) { }
                    }
                    if (response.status < 200 || response.status >= 300) {
                        reject(new Error(data && data.message ? data.message : `GitHub HTTP ${response.status || 'error'}`));
                        return;
                    }
                    resolve(data);
                },
                ontimeout: () => reject(new Error('GitHub request timed out')),
                onerror: () => reject(new Error('Unable to connect to GitHub'))
            });
        });
    }

    function readConfigBackup(includeSabnzbd) {
        const localStorageValues = {};
        EBDS_CONFIG_STORAGE_KEYS.forEach(key => {
            try {
                const value = localStorage.getItem(key);
                if (value !== null) localStorageValues[key] = value;
            } catch (e) { }
        });

        const backup = {
            format: 'even-better-drunkenslug-config',
            version: 1,
            exportedAt: new Date().toISOString(),
            localStorage: localStorageValues
        };
        if (includeSabnzbd) backup.sabnzbd = loadSabConfig();
        return backup;
    }

    function validateConfigBackup(backup) {
        if (!backup || backup.format !== 'even-better-drunkenslug-config' || backup.version !== 1) {
            throw new Error('This Gist does not contain a supported EBDS config backup');
        }
        if (!backup.localStorage || typeof backup.localStorage !== 'object' || Array.isArray(backup.localStorage)) {
            throw new Error('The EBDS config backup is missing local settings');
        }
        // Values are written straight into localStorage, so reject anything that
        // would stringify into nonsense such as "[object Object]".
        const badKey = EBDS_CONFIG_STORAGE_KEYS.find(key => {
            if (!Object.prototype.hasOwnProperty.call(backup.localStorage, key)) return false;
            const type = typeof backup.localStorage[key];
            return type !== 'string' && type !== 'number' && type !== 'boolean';
        });
        if (badKey) throw new Error(`The EBDS config backup has an unusable value for ${badKey}`);
    }

    async function backupConfigToGithub() {
        const token = loadGithubSetting(EBDS_GITHUB_TOKEN_KEY);
        if (!token) throw new Error('Save a GitHub token first');
        const gistId = loadGithubSetting(EBDS_GITHUB_GIST_ID_KEY);
        const backup = readConfigBackup(!!githubIncludeSabToggle.checked);
        const body = { files: { [EBDS_CONFIG_GIST_FILENAME]: { content: JSON.stringify(backup, null, 2) } } };
        const response = gistId
            ? await requestGithub('PATCH', `/gists/${encodeURIComponent(gistId)}`, token, body)
            : await requestGithub('POST', '/gists', token, { description: 'EBDS configuration backup', public: false, ...body });
        if (!response || !response.id) throw new Error('GitHub returned an invalid Gist response');
        if (!gistId) {
            await saveGithubSetting(EBDS_GITHUB_GIST_ID_KEY, response.id);
            if (configPanel.classList.contains('visible')) githubGistIdInput.value = response.id;
        }
        await recordLastGithubBackup(response.id);
        renderLastGithubBackupStatus();
    }

    async function restoreConfigFromGithub() {
        const token = loadGithubSetting(EBDS_GITHUB_TOKEN_KEY);
        const gistId = loadGithubSetting(EBDS_GITHUB_GIST_ID_KEY);
        if (!token) throw new Error('Save a GitHub token first');
        if (!gistId) throw new Error('Save a Gist ID first');
        const response = await requestGithub('GET', `/gists/${encodeURIComponent(gistId)}`, token);
        const file = response && response.files && response.files[EBDS_CONFIG_GIST_FILENAME];
        if (!file || typeof file.content !== 'string') throw new Error(`Gist file ${EBDS_CONFIG_GIST_FILENAME} was not found`);
        let backup;
        try { backup = JSON.parse(file.content); } catch (e) { throw new Error('The Gist backup is not valid JSON'); }
        validateConfigBackup(backup);

        // Apply all-or-nothing: a failure part way through used to leave some
        // keys restored, some removed and the rest untouched.
        const desired = EBDS_CONFIG_STORAGE_KEYS.map(key => [
            key,
            Object.prototype.hasOwnProperty.call(backup.localStorage, key)
                ? String(backup.localStorage[key])
                : null
        ]);
        const previous = EBDS_CONFIG_STORAGE_KEYS.map(key => [key, localStorage.getItem(key)]);
        const write = ([key, value]) => {
            if (value === null) localStorage.removeItem(key);
            else localStorage.setItem(key, value);
        };
        try {
            desired.forEach(write);
        } catch (e) {
            previous.forEach(entry => {
                try { write(entry); } catch (rollbackError) { ebdsWarn('roll back config restore', rollbackError); }
            });
            throw new Error('Unable to restore settings - your previous settings were kept');
        }
        if (backup.sabnzbd && typeof backup.sabnzbd === 'object') {
            await GM_setValue(EBDS_SAB_URL_KEY, String(backup.sabnzbd.url || ''));
            await GM_setValue(EBDS_SAB_API_KEY, String(backup.sabnzbd.apiKey || ''));
        }
        setGithubStatus('Config restored. Reloading…');
        setTimeout(() => location.reload(), 400);
    }

    // The config panel lives in the site's DOM, where any page script can read
    // input values. The token is therefore never written back into the field;
    // fields that the user must be able to read are filled only while the panel
    // is open (see showConfigPanelSecrets/clearConfigPanelSecrets).
    function renderGithubTokenState() {
        githubTokenInput.value = '';
        const hasToken = !!loadGithubSetting(EBDS_GITHUB_TOKEN_KEY);
        githubTokenInput.placeholder = hasToken ? 'Token saved - enter a new one to replace it' : 'GitHub token';
        githubTokenForgetBtn.hidden = !hasToken;
    }

    function showConfigPanelSecrets() {
        githubGistIdInput.value = loadGithubSetting(EBDS_GITHUB_GIST_ID_KEY);
        sabUrlInput.value = loadSabConfig().url;
        renderGithubTokenState();
    }

    function clearConfigPanelSecrets() {
        githubTokenInput.value = '';
        githubGistIdInput.value = '';
        sabUrlInput.value = '';
        sabApiKeyInput.value = '';
    }

    renderGithubTokenState();
    githubIncludeSabToggle.checked = readPref(EBDS_INCLUDE_SAB_IN_BACKUP_KEY, 'flag', false);
    githubAutoBackupToggle.checked = readPref(EBDS_GITHUB_AUTO_BACKUP_KEY, 'flag', false);
    renderLastGithubBackupStatus();
    githubIncludeSabToggle.addEventListener('change', () => {
        writeFlagPref(EBDS_INCLUDE_SAB_IN_BACKUP_KEY, githubIncludeSabToggle.checked);
    });
    githubAutoBackupToggle.addEventListener('change', async () => {
        writeFlagPref(EBDS_GITHUB_AUTO_BACKUP_KEY, githubAutoBackupToggle.checked);
        if (githubAutoBackupToggle.checked) await maybeAutoBackupToGithub();
    });
    githubTokenSaveBtn.addEventListener('click', async () => {
        const entered = githubTokenInput.value.trim();
        if (!entered) {
            setGithubStatus(loadGithubSetting(EBDS_GITHUB_TOKEN_KEY)
                ? 'Existing token kept. Enter a new token to replace it.'
                : 'Enter a GitHub token first.', !loadGithubSetting(EBDS_GITHUB_TOKEN_KEY));
            return;
        }
        try {
            await saveGithubSetting(EBDS_GITHUB_TOKEN_KEY, entered);
            renderGithubTokenState();
            setGithubStatus('GitHub token saved.');
            await maybeAutoBackupToGithub();
        }
        catch (e) { setGithubStatus(e.message || 'Unable to save GitHub token.', true); }
    });
    githubTokenForgetBtn.addEventListener('click', async () => {
        if (!window.confirm('Remove the stored GitHub token from this browser?')) return;
        try {
            await saveGithubSetting(EBDS_GITHUB_TOKEN_KEY, '');
            renderGithubTokenState();
            setGithubStatus('GitHub token removed.');
        }
        catch (e) { setGithubStatus(e.message || 'Unable to remove GitHub token.', true); }
    });
    githubGistIdSaveBtn.addEventListener('click', async () => {
        try { await saveGithubSetting(EBDS_GITHUB_GIST_ID_KEY, githubGistIdInput.value); setGithubStatus('Gist ID saved.'); }
        catch (e) { setGithubStatus(e.message || 'Unable to save Gist ID.', true); }
    });
    githubBackupBtn.addEventListener('click', async () => {
        githubBackupBtn.disabled = true;
        setGithubStatus('Uploading backup…');
        try { await backupConfigToGithub(); }
        catch (e) { setGithubStatus(e.message || 'Unable to upload backup.', true); }
        finally { githubBackupBtn.disabled = false; }
    });
    githubRestoreBtn.addEventListener('click', async () => {
        if (!window.confirm('Restore EBDS settings from this Gist and reload the page?')) return;
        githubRestoreBtn.disabled = true;
        setGithubStatus('Downloading backup…');
        try { await restoreConfigFromGithub(); }
        catch (e) { setGithubStatus(e.message || 'Unable to restore backup.', true); }
        finally { githubRestoreBtn.disabled = false; }
    });

    let githubAutoBackupInFlight = false;
    async function maybeAutoBackupToGithub() {
        if (githubAutoBackupInFlight || !githubAutoBackupToggle.checked || !loadGithubSetting(EBDS_GITHUB_TOKEN_KEY)) return;
        const lastBackup = loadLastGithubBackup();
        const oneDayMs = 24 * 60 * 60 * 1000;
        const currentGistId = loadGithubSetting(EBDS_GITHUB_GIST_ID_KEY);
        if (lastBackup && lastBackup.gistId === currentGistId && (Date.now() - lastBackup.savedAt.getTime()) < oneDayMs) return;

        githubAutoBackupInFlight = true;
        try {
            await backupConfigToGithub();
        } catch (e) {
            // Automatic backups run in the background; leave the last successful status intact.
        } finally {
            githubAutoBackupInFlight = false;
        }
    }

    setTimeout(() => { maybeAutoBackupToGithub(); }, 0);

    function normalizeSabUrl(rawUrl) {
        const url = new URL(String(rawUrl || '').trim());
        if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new Error('URL must use HTTP or HTTPS');
        url.username = '';
        url.password = '';
        url.search = '';
        url.hash = '';
        url.pathname = url.pathname.replace(/\/+$/, '');
        return url.toString().replace(/\/$/, '');
    }

    function setSabStatus(message, isError = false) {
        if (!sabStatus) return;
        sabStatus.textContent = message;
        sabStatus.style.color = isError ? '#ff7b7b' : '#7ab800';
    }

    function updateSabButtonVisibility(config = loadSabConfig()) {
        sabBtn.hidden = EBDS_HIDE_SAB_BUTTON || !(config.url && config.apiKey);
    }

    function buildSabApiUrl(config, params) {
        const apiUrl = new URL(config.url);
        apiUrl.pathname = apiUrl.pathname.replace(/\/+$/, '');
        if (!apiUrl.pathname.endsWith('/api')) apiUrl.pathname += '/api';
        apiUrl.search = '';
        apiUrl.searchParams.set('output', 'json');
        apiUrl.searchParams.set('apikey', config.apiKey);
        Object.entries(params).forEach(([key, value]) => apiUrl.searchParams.set(key, String(value)));
        return apiUrl;
    }

    function requestSab(config, params) {
        return new Promise((resolve, reject) => {
            let apiUrl;
            try {
                apiUrl = buildSabApiUrl(config, params);
            } catch (e) {
                reject(new Error('Invalid SABnzbd URL'));
                return;
            }

            GM_xmlhttpRequest({
                method: 'GET',
                url: apiUrl.toString(),
                timeout: 15000,
                responseType: 'json',
                onload: response => {
                    let data = response.response;
                    if (!data && response.responseText) {
                        try { data = JSON.parse(response.responseText); } catch (e) { }
                    }
                    if (response.status < 200 || response.status >= 300) {
                        reject(new Error(data && data.error ? data.error : `HTTP ${response.status || 'error'}`));
                        return;
                    }
                    if (data && data.error) {
                        reject(new Error(data.error));
                        return;
                    }
                    resolve(data);
                },
                ontimeout: () => reject(new Error('SABnzbd request timed out')),
                onerror: () => reject(new Error('Unable to connect to SABnzbd'))
            });
        });
    }

    async function saveSabConfig() {
        sabSaveBtn.disabled = true;
        sabSaveBtn.textContent = 'Testing…';
        try {
            const url = normalizeSabUrl(sabUrlInput.value);
            const existingConfig = loadSabConfig();
            const enteredApiKey = sabApiKeyInput.value.trim();
            // Validating sends the key to whatever URL is in the field, so a
            // typo would hand the stored key to a stranger. Reusing it silently
            // is only safe when the URL has not changed.
            if (!enteredApiKey && existingConfig.apiKey && existingConfig.url !== url) {
                throw new Error('URL changed - re-enter the API key to confirm sending it there');
            }
            const apiKey = enteredApiKey || existingConfig.apiKey;
            if (!apiKey) throw new Error('API key is required');
            const config = { url, apiKey };
            const response = await requestSab(config, { mode: 'queue', start: 0, limit: 1 });
            if (!response || !response.queue) throw new Error('Unexpected response from SABnzbd');
            await GM_setValue(EBDS_SAB_URL_KEY, url);
            await GM_setValue(EBDS_SAB_API_KEY, apiKey);
            sabUrlInput.value = url;
            sabApiKeyInput.value = '';
            sabApiKeyInput.placeholder = 'API key saved';
            updateSabButtonVisibility(config);
            setSabStatus('Connection verified and saved.');
            return true;
        } catch (e) {
            setSabStatus(e.message || 'Unable to verify SABnzbd settings.', true);
            return false;
        } finally {
            sabSaveBtn.disabled = false;
            sabSaveBtn.textContent = 'Save';
        }
    }

    function setSabButtonState(label, state = '') {
        sabBtn.textContent = label;
        sabBtn.classList.remove('ebds-success', 'ebds-error');
        if (state) sabBtn.classList.add(state);
    }

    async function triggerSabFeeds() {
        const config = loadSabConfig();
        if (!config.url || !config.apiKey) {
            updateSabButtonVisibility(config);
            return;
        }

        sabBtn.disabled = true;
        setSabButtonState('Reading…');
        try {
            const response = await requestSab(config, { mode: 'rss_now' });
            if (!response || response.status !== true) throw new Error('SABnzbd did not accept the request');
            setSabButtonState('Feeds requested', 'ebds-success');
            setTimeout(() => setSabButtonState('Read feeds'), 2500);
        } catch (e) {
            setSabButtonState('SAB error', 'ebds-error');
            setSabStatus(e.message || 'SABnzbd request failed.', true);
            setTimeout(() => setSabButtonState('Read feeds'), 3000);
        } finally {
            sabBtn.disabled = false;
        }
    }

    const initialSabConfig = loadSabConfig();
    if (initialSabConfig.apiKey) sabApiKeyInput.placeholder = 'API key saved';
    updateSabButtonVisibility(initialSabConfig);
    sabSaveBtn.addEventListener('click', saveSabConfig);
    sabApiKeyInput.addEventListener('keydown', e => {
        if (e.key === 'Enter') saveSabConfig();
    });
    sabBtn.addEventListener('click', triggerSabFeeds);
    if (hideSabButtonToggle) {
        hideSabButtonToggle.checked = EBDS_HIDE_SAB_BUTTON;
        hideSabButtonToggle.addEventListener('change', () => {
            EBDS_HIDE_SAB_BUTTON = !!hideSabButtonToggle.checked;
            writeFlagPref(EBDS_HIDE_SAB_BUTTON_KEY, EBDS_HIDE_SAB_BUTTON);
            updateSabButtonVisibility();
        });
    }

    // Apply vertical limiter state to overlay
    function applyGalleryVerticalLimit() {
        try {
            if (!overlay) return;
            if (EBDS_GALLERY_VERTICAL_LIMIT) overlay.classList.remove('ebds-gallery-no-vertical-limit');
            else overlay.classList.add('ebds-gallery-no-vertical-limit');
        } catch (e) { }
    }

    // initialize toggle states and bind events

    if (hidePicsToggle) {
        hidePicsToggle.checked = EBDS_HIDE_WITHOUT_PICTURES;
        const hideWithoutPicsOnlyGalleryContainer = hideWithoutPicsOnlyGalleryToggle && hideWithoutPicsOnlyGalleryToggle.closest('.ebds-config-toggle');
        const updateHideWithoutPicsOnlyGalleryVisibility = () => {
            if (hideWithoutPicsOnlyGalleryContainer) {
                hideWithoutPicsOnlyGalleryContainer.style.display = hidePicsToggle.checked ? 'flex' : 'none';
            }
        };
        updateHideWithoutPicsOnlyGalleryVisibility();
        hidePicsToggle.addEventListener('change', () => {
            EBDS_HIDE_WITHOUT_PICTURES = !!hidePicsToggle.checked;
            EBDS_TEMPORARY_FILTER_BYPASS.withoutPictures = false;
            writeFlagPref(EBDS_HIDE_WITHOUT_PICS_STORAGE_KEY, EBDS_HIDE_WITHOUT_PICTURES);
            updateHideWithoutPicsOnlyGalleryVisibility();
            applyRowFiltering();
        });
    }
    if (hideWithoutPicsOnlyGalleryToggle) {
        hideWithoutPicsOnlyGalleryToggle.checked = EBDS_HIDE_WITHOUT_PICTURES_ONLY_GALLERY;
        hideWithoutPicsOnlyGalleryToggle.addEventListener('change', () => {
            EBDS_HIDE_WITHOUT_PICTURES_ONLY_GALLERY = !!hideWithoutPicsOnlyGalleryToggle.checked;
            writeFlagPref(EBDS_HIDE_WITHOUT_PICS_ONLY_GALLERY_KEY, EBDS_HIDE_WITHOUT_PICTURES_ONLY_GALLERY);
            applyRowFiltering();
            try { if (overlay && overlay.classList && overlay.classList.contains('visible')) openGallery(); } catch (e) { }
            try { updateGalleryFooter(); } catch (e) { }
        });
    }

    if (minFileSizeInput && minFileSizeSetBtn) {
        minFileSizeInput.value = String(EBDS_MIN_FILE_SIZE_MB);
        function setMinimumFileSize() {
            const value = Number(minFileSizeInput.value);
            if (!Number.isFinite(value) || value < 0) {
                minFileSizeInput.setCustomValidity('Enter a value of 0 or greater.');
                minFileSizeInput.reportValidity();
                return;
            }
            minFileSizeInput.setCustomValidity('');
            EBDS_MIN_FILE_SIZE_MB = value;
            EBDS_TEMPORARY_FILTER_BYPASS.minimumSize = false;
            minFileSizeInput.value = String(value);
            writePref(EBDS_MIN_FILE_SIZE_MB_KEY, value);
            applyRowFiltering();
        }
        minFileSizeSetBtn.addEventListener('click', setMinimumFileSize);
        minFileSizeInput.addEventListener('keydown', e => {
            if (e.key === 'Enter') setMinimumFileSize();
        });
    }

    if (verticalLimitToggle) {
        verticalLimitToggle.checked = EBDS_GALLERY_VERTICAL_LIMIT;
        verticalLimitToggle.addEventListener('change', () => {
            EBDS_GALLERY_VERTICAL_LIMIT = !!verticalLimitToggle.checked;
            writeFlagPref(EBDS_GALLERY_VERTICAL_LIMIT_KEY, EBDS_GALLERY_VERTICAL_LIMIT);
            applyGalleryVerticalLimit();
        });
    }

    if (scaleHoverToggle) {
        scaleHoverToggle.checked = EBDS_SCALE_HOVER_PREVIEW;
        scaleHoverToggle.addEventListener('change', () => {
            EBDS_SCALE_HOVER_PREVIEW = !!scaleHoverToggle.checked;
            writeFlagPref(EBDS_SCALE_HOVER_PREVIEW_KEY, EBDS_SCALE_HOVER_PREVIEW);
        });
    }

    if (filterLinksPositionSelect) {
        filterLinksPositionSelect.value = EBDS_FILTER_LINKS_POSITION;
        filterLinksPositionSelect.addEventListener('change', () => {
            EBDS_FILTER_LINKS_POSITION = filterLinksPositionSelect.value;
            writePref(EBDS_FILTER_LINKS_POSITION_KEY, EBDS_FILTER_LINKS_POSITION);
            updateGalleryFooter();
        });
    }

    if (galleryOnlyNewToggle) {
        galleryOnlyNewToggle.checked = EBDS_GALLERY_SHOW_ONLY_NEW;
        const galleryOnlyNewContainer = galleryOnlyNewToggle.closest('.ebds-config-toggle');
        if (galleryOnlyNewContainer && EBDS_IS_SEARCH_PAGE) galleryOnlyNewContainer.style.display = 'none';
        galleryOnlyNewToggle.addEventListener('change', () => {
            EBDS_GALLERY_SHOW_ONLY_NEW = !!galleryOnlyNewToggle.checked;
            writeFlagPref(EBDS_GALLERY_SHOW_ONLY_NEW_KEY, EBDS_GALLERY_SHOW_ONLY_NEW);
            try { if (overlay && overlay.classList && overlay.classList.contains('visible')) openGallery(); } catch (e) { }
            try {
                if (typeof EBDS.refreshInfiniteScroll === 'function') EBDS.refreshInfiniteScroll();
            } catch (e) { }
        });
    }
    if (infiniteScrollToggle) {
        infiniteScrollToggle.checked = EBDS_INFINITE_SCROLL;
        infiniteScrollToggle.addEventListener('change', () => {
            EBDS_INFINITE_SCROLL = !!infiniteScrollToggle.checked;
            if (infiniteScrollGalleryOnlyToggle) {
                const infiniteScrollGalleryOnlyContainer = infiniteScrollGalleryOnlyToggle.closest('.ebds-config-toggle');
                if (infiniteScrollGalleryOnlyContainer) infiniteScrollGalleryOnlyContainer.style.display = EBDS_INFINITE_SCROLL ? 'flex' : 'none';
            }
            writeFlagPref(EBDS_INFINITE_SCROLL_KEY, EBDS_INFINITE_SCROLL);
            updateInfinitePaginationState();
            try {
                if (typeof EBDS.setInfiniteScrollEnabled === 'function') {
                    EBDS.setInfiniteScrollEnabled(EBDS_INFINITE_SCROLL);
                }
            } catch (e) { }
            try { if (typeof EBDS.updateListingScrollTop === 'function') EBDS.updateListingScrollTop(); } catch (e) { }
            try { if (overlay.classList.contains('visible')) openGallery(); } catch (e) { }
        });
    }
    if (infiniteScrollGalleryOnlyToggle) {
        infiniteScrollGalleryOnlyToggle.checked = EBDS_INFINITE_SCROLL_ONLY_GALLERY;
        const infiniteScrollGalleryOnlyContainer = infiniteScrollGalleryOnlyToggle.closest('.ebds-config-toggle');
        if (infiniteScrollGalleryOnlyContainer) infiniteScrollGalleryOnlyContainer.style.display = EBDS_INFINITE_SCROLL ? 'flex' : 'none';
        infiniteScrollGalleryOnlyToggle.addEventListener('change', () => {
            EBDS_INFINITE_SCROLL_ONLY_GALLERY = !!infiniteScrollGalleryOnlyToggle.checked;
            writeFlagPref(EBDS_INFINITE_SCROLL_ONLY_GALLERY_KEY, EBDS_INFINITE_SCROLL_ONLY_GALLERY);
            updateInfinitePaginationState();
            try {
                if (typeof EBDS.refreshInfiniteScroll === 'function') EBDS.refreshInfiniteScroll();
            } catch (e) { }
            try { if (typeof EBDS.updateListingScrollTop === 'function') EBDS.updateListingScrollTop(); } catch (e) { }
        });
    }

    const galleryColsSlider = configPanel.querySelector('#ebdsGalleryCols');
    const galleryColsValue = configPanel.querySelector('#ebdsGalleryColsValue');
    if (galleryColsSlider && galleryColsValue) {
        galleryColsSlider.value = EBDS_GALLERY_COLS;
        galleryColsValue.textContent = EBDS_GALLERY_COLS;
        galleryColsSlider.addEventListener('input', () => {
            const val = parseInt(galleryColsSlider.value);
            EBDS_GALLERY_COLS = val;
            galleryColsValue.textContent = val;
            writePref(EBDS_GALLERY_COLS_KEY, val);
            // Update grid columns
            grid.style.setProperty('--ebds-cols', val);
        });
    }

    // Session expiration minutes control
    const sessionExpirationInput = configPanel.querySelector('#ebdsSessionExpirationInput');
    const sessionExpirationValue = configPanel.querySelector('#ebdsSessionExpirationValue');
    const sessionExpirationSetBtn = configPanel.querySelector('#ebdsSessionExpirationSet');
    if (sessionExpirationInput && sessionExpirationValue) {
        sessionExpirationInput.value = EBDS_SESSION_EXPIRATION_MINUTES;
        sessionExpirationValue.textContent = EBDS_SESSION_EXPIRATION_MINUTES;
        function applySessionExpiration() {
            let m = parseInt(sessionExpirationInput.value, 10);
            if (!Number.isFinite(m) || m < 1) m = 1;
            if (m > 1440) m = 1440;
            EBDS_SESSION_EXPIRATION_MINUTES = m;
            sessionExpirationInput.value = m;
            sessionExpirationValue.textContent = m;
            writePref(EBDS_SESSION_EXPIRATION_KEY, m);
        }
        sessionExpirationSetBtn && sessionExpirationSetBtn.addEventListener('click', applySessionExpiration);
        sessionExpirationInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') applySessionExpiration(); });
        sessionExpirationInput.addEventListener('blur', applySessionExpiration);
    }


    function renderBlacklistChips() {
        blacklistList.innerHTML = '';
        if (!EBDS_BLACKLIST_TERMS.length) {
            const empty = document.createElement('div');
            empty.className = 'ebds-config-empty';
            empty.textContent = 'No keywords yet.';
            blacklistList.appendChild(empty);
            return;
        }

        EBDS_BLACKLIST_TERMS.forEach(term => {
            const chip = document.createElement('div');
            chip.className = 'ebds-chip';
            const label = document.createElement('span');
            label.textContent = term;
            const removeBtn = document.createElement('button');
            removeBtn.type = 'button';
            removeBtn.title = 'Remove keyword';
            removeBtn.textContent = 'x';
            removeBtn.addEventListener('click', () => {
                EBDS_BLACKLIST_TERMS = EBDS_BLACKLIST_TERMS.filter(t => t !== term);
                EBDS_TEMPORARY_FILTER_BYPASS.keywords = false;
                persistBlacklistTerms(EBDS_BLACKLIST_TERMS);
                applyRowFiltering();
                renderBlacklistChips();
            });
            chip.appendChild(label);
            chip.appendChild(removeBtn);
            blacklistList.appendChild(chip);
        });
    }

    function addBlacklistTerm(raw) {
        const normalized = normalizeBlacklistTerm(raw);
        if (!normalized) return;
        if (!EBDS_BLACKLIST_TERMS.includes(normalized)) {
            EBDS_BLACKLIST_TERMS.push(normalized);
            EBDS_TEMPORARY_FILTER_BYPASS.keywords = false;
            persistBlacklistTerms(EBDS_BLACKLIST_TERMS);
            applyRowFiltering();
            renderBlacklistChips();
        }
    }

    function getCurrentNewsgroupLabels() {
        const groups = new Map();
        getReleaseRows().forEach(row => {
            const value = getReleaseNewsgroup(row);
            const label = getReleaseNewsgroupLabel(row);
            if (value && label && !groups.has(value)) groups.set(value, label);
        });
        return Array.from(groups.entries()).sort((a, b) => a[1].localeCompare(b[1]));
    }

    function renderCurrentNewsgroups() {
        if (!currentNewsgroupsSelect) return;
        const selected = currentNewsgroupsSelect.value;
        currentNewsgroupsSelect.innerHTML = '';
        const placeholder = document.createElement('option');
        placeholder.value = '';
        placeholder.textContent = 'Select a current newsgroup...';
        currentNewsgroupsSelect.appendChild(placeholder);
        getCurrentNewsgroupLabels().filter(([value]) => !EBDS_EXCLUDED_NEWSGROUPS.includes(value)).forEach(([value, label]) => {
            const option = document.createElement('option');
            option.value = value;
            option.textContent = label;
            currentNewsgroupsSelect.appendChild(option);
        });
        if (Array.from(currentNewsgroupsSelect.options).some(option => option.value === selected)) {
            currentNewsgroupsSelect.value = selected;
        }
    }

    function renderExcludedNewsgroupChips() {
        if (!newsgroupList) return;
        newsgroupList.innerHTML = '';
        if (!EBDS_EXCLUDED_NEWSGROUPS.length) {
            const empty = document.createElement('div');
            empty.className = 'ebds-config-empty';
            empty.textContent = 'No newsgroups yet.';
            newsgroupList.appendChild(empty);
            return;
        }
        EBDS_EXCLUDED_NEWSGROUPS.forEach(group => {
            const chip = document.createElement('div');
            chip.className = 'ebds-chip';
            const label = document.createElement('span');
            label.textContent = group;
            const removeBtn = document.createElement('button');
            removeBtn.type = 'button';
            removeBtn.title = 'Remove excluded newsgroup';
            removeBtn.textContent = 'x';
            removeBtn.addEventListener('click', () => {
                EBDS_EXCLUDED_NEWSGROUPS = EBDS_EXCLUDED_NEWSGROUPS.filter(value => value !== group);
                EBDS_TEMPORARY_FILTER_BYPASS.newsgroups = false;
                persistExcludedNewsgroups(EBDS_EXCLUDED_NEWSGROUPS);
                applyRowFiltering();
                renderCurrentNewsgroups();
                renderExcludedNewsgroupChips();
            });
            chip.appendChild(label);
            chip.appendChild(removeBtn);
            newsgroupList.appendChild(chip);
        });
    }

    function addExcludedNewsgroup(raw) {
        const normalized = normalizeNewsgroup(raw);
        if (!normalized || EBDS_EXCLUDED_NEWSGROUPS.includes(normalized)) return;
        EBDS_EXCLUDED_NEWSGROUPS.push(normalized);
        EBDS_TEMPORARY_FILTER_BYPASS.newsgroups = false;
        persistExcludedNewsgroups(EBDS_EXCLUDED_NEWSGROUPS);
        applyRowFiltering();
        renderCurrentNewsgroups();
        renderExcludedNewsgroupChips();
    }

    function renderHighlightChips() {
        highlightList.innerHTML = '';
        if (!EBDS_HIGHLIGHT_KEYWORDS.length) {
            const empty = document.createElement('div');
            empty.className = 'ebds-config-empty';
            empty.textContent = 'No keywords yet.';
            highlightList.appendChild(empty);
            return;
        }

        EBDS_HIGHLIGHT_KEYWORDS.forEach(term => {
            const chip = document.createElement('div');
            chip.className = 'ebds-chip';
            const label = document.createElement('span');
            label.textContent = term;
            const removeBtn = document.createElement('button');
            removeBtn.type = 'button';
            removeBtn.title = 'Remove highlight keyword';
            removeBtn.textContent = 'x';
            removeBtn.addEventListener('click', () => {
                EBDS_HIGHLIGHT_KEYWORDS = EBDS_HIGHLIGHT_KEYWORDS.filter(t => t !== term);
                persistHighlightKeywords(EBDS_HIGHLIGHT_KEYWORDS);
                applyRowFiltering();
                renderHighlightChips();
            });
            chip.appendChild(label);
            chip.appendChild(removeBtn);
            highlightList.appendChild(chip);
        });
    }

    function addHighlightKeyword(raw) {
        const normalized = normalizeBlacklistTerm(raw);
        if (!normalized) return;
        if (!EBDS_HIGHLIGHT_KEYWORDS.includes(normalized)) {
            EBDS_HIGHLIGHT_KEYWORDS.push(normalized);
            persistHighlightKeywords(EBDS_HIGHLIGHT_KEYWORDS);
            applyRowFiltering();
            renderHighlightChips();
        }
    }

    addBlacklistBtn.addEventListener('click', () => {
        addBlacklistTerm(blacklistInput.value);
        blacklistInput.value = '';
        blacklistInput.focus();
    });

    blacklistInput.addEventListener('keydown', (e) => {
        if (e.key === 'Enter') {
            e.preventDefault();
            addBlacklistTerm(blacklistInput.value);
            blacklistInput.value = '';
            blacklistInput.focus();
        }
    });

    addNewsgroupBtn.addEventListener('click', () => {
        addExcludedNewsgroup(newsgroupInput.value);
        newsgroupInput.value = '';
        newsgroupInput.focus();
    });

    newsgroupInput.addEventListener('keydown', (e) => {
        if (e.key === 'Enter') {
            e.preventDefault();
            addExcludedNewsgroup(newsgroupInput.value);
            newsgroupInput.value = '';
            newsgroupInput.focus();
        }
    });

    addCurrentNewsgroupBtn.addEventListener('click', () => {
        addExcludedNewsgroup(currentNewsgroupsSelect.value);
        currentNewsgroupsSelect.focus();
    });

    showGalleryNewsgroupToggle.checked = EBDS_SHOW_GALLERY_NEWSGROUP;
    showGalleryNewsgroupToggle.addEventListener('change', () => {
        EBDS_SHOW_GALLERY_NEWSGROUP = !!showGalleryNewsgroupToggle.checked;
        writeFlagPref(EBDS_SHOW_GALLERY_NEWSGROUP_KEY, EBDS_SHOW_GALLERY_NEWSGROUP);
        try { if (overlay.classList.contains('visible')) openGallery(); } catch (e) { }
    });

    addHighlightBtn.addEventListener('click', () => {
        addHighlightKeyword(highlightInput.value);
        highlightInput.value = '';
        highlightInput.focus();
    });

    highlightInput.addEventListener('keydown', (e) => {
        if (e.key === 'Enter') {
            e.preventDefault();
            addHighlightKeyword(highlightInput.value);
            highlightInput.value = '';
            highlightInput.focus();
        }
    });



    configBtn.addEventListener('click', () => {
        const nowVisible = !configPanel.classList.contains('visible');
        configPanel.classList.toggle('visible');
        if (!nowVisible) clearConfigPanelSecrets();
        if (nowVisible) {
            showConfigPanelSecrets();
            renderLastGithubBackupStatus();
            renderBlacklistChips();
            renderCurrentNewsgroups();
            renderExcludedNewsgroupChips();
            if (showGalleryNewsgroupToggle) showGalleryNewsgroupToggle.checked = EBDS_SHOW_GALLERY_NEWSGROUP;
            renderHighlightChips();
            if (hidePicsToggle) hidePicsToggle.checked = EBDS_HIDE_WITHOUT_PICTURES;
            if (hideWithoutPicsOnlyGalleryToggle) hideWithoutPicsOnlyGalleryToggle.checked = EBDS_HIDE_WITHOUT_PICTURES_ONLY_GALLERY;
            if (verticalLimitToggle) verticalLimitToggle.checked = EBDS_GALLERY_VERTICAL_LIMIT;
            if (scaleHoverToggle) scaleHoverToggle.checked = EBDS_SCALE_HOVER_PREVIEW;
            if (filterLinksPositionSelect) filterLinksPositionSelect.value = EBDS_FILTER_LINKS_POSITION;
            if (galleryOnlyNewToggle) galleryOnlyNewToggle.checked = EBDS_GALLERY_SHOW_ONLY_NEW;
            if (infiniteScrollToggle) infiniteScrollToggle.checked = EBDS_INFINITE_SCROLL;
            if (infiniteScrollGalleryOnlyToggle) infiniteScrollGalleryOnlyToggle.checked = EBDS_INFINITE_SCROLL_ONLY_GALLERY;
            if (hideSabButtonToggle) hideSabButtonToggle.checked = EBDS_HIDE_SAB_BUTTON;
            try {
                if (blacklistSection && blacklistSection.open) blacklistInput.focus();
            } catch (e) { }
        }
    });

    closeConfigBtn.addEventListener('click', () => {
        configPanel.classList.remove('visible');
        clearConfigPanelSecrets();
    });

    // Keep Config button floating on the right side of the viewport

    // Create overlay
    const overlay = document.createElement('div');
    overlay.id = 'ebds-gallery-overlay';
    const grid = document.createElement('div');
    grid.className = 'ebds-gallery-grid';
    overlay.appendChild(grid);
    let galleryCurrentPage = 1;
    try {
        const pageNav = document.querySelector('nav.pagination[aria-label="Pagination"], nav.pagination, ul.pagination, .pagination');
        const current = pageNav && pageNav.querySelector('[aria-current="page"], li.active, .current, a.active');
        const currentLink = current && (current.tagName === 'A' ? current : current.querySelector('a'));
        const pageNumber = parseInt((currentLink || current)?.textContent.trim(), 10);
        if (Number.isInteger(pageNumber) && pageNumber > 0) galleryCurrentPage = pageNumber;
    } catch (e) { }
    getReleaseRows().forEach(row => {
        if (!row.dataset.ebdsPage) row.dataset.ebdsPage = String(galleryCurrentPage);
    });
    const galleryScrollTopLink = document.createElement('a');
    galleryScrollTopLink.className = 'ebds-gallery-scroll-top';
    galleryScrollTopLink.href = '#';
    const updateGalleryScrollTopLabel = () => {
        galleryScrollTopLink.textContent = `Back to top [${galleryCurrentPage}] ↑`;
    };
    updateGalleryScrollTopLabel();
    const updateGalleryCurrentPageFromScroll = () => {
        if (!overlay.classList.contains('visible')) return;
        const overlayTop = overlay.getBoundingClientRect().top;
        const currentItem = Array.from(grid.querySelectorAll('.ebds-gallery-item'))
            .find(item => item.getBoundingClientRect().bottom > overlayTop + 8);
        const pageNumber = currentItem ? parseInt(currentItem.dataset.ebdsPage, 10) : NaN;
        if (Number.isInteger(pageNumber) && pageNumber > 0 && pageNumber !== galleryCurrentPage) {
            galleryCurrentPage = pageNumber;
            updateGalleryScrollTopLabel();
        }
    };
    galleryScrollTopLink.hidden = true;
    galleryScrollTopLink.addEventListener('click', event => {
        event.preventDefault();
        overlay.scrollTo({ top: 0, behavior: 'smooth' });
    });
    overlay.appendChild(galleryScrollTopLink);
    overlay.tabIndex = -1;
    const updateGalleryScrollTopLink = () => {
        updateGalleryCurrentPageFromScroll();
        const topBar = overlay.querySelector('.ebds-gallery-category-links');
        if (!EBDS_INFINITE_SCROLL || !overlay.classList.contains('visible') || !topBar) {
            galleryScrollTopLink.hidden = true;
            return;
        }
        const overlayTop = overlay.getBoundingClientRect().top;
        galleryScrollTopLink.hidden = topBar.getBoundingClientRect().bottom > overlayTop + 1;
    };
    overlay.addEventListener('scroll', updateGalleryScrollTopLink, { passive: true });
    document.body.appendChild(overlay);

    const listingScrollTopLink = document.createElement('a');
    listingScrollTopLink.className = 'ebds-listing-scroll-top';
    listingScrollTopLink.href = '#';
    let listingCurrentPage = galleryCurrentPage;
    const updateListingScrollTopLabel = () => {
        listingScrollTopLink.textContent = `Back to top [${listingCurrentPage}] ↑`;
    };
    updateListingScrollTopLabel();
    listingScrollTopLink.hidden = true;
    listingScrollTopLink.addEventListener('click', event => {
        event.preventDefault();
        window.scrollTo({ top: 0, behavior: 'smooth' });
    });
    document.body.appendChild(listingScrollTopLink);

    const updateListingScrollTopLink = () => {
        const currentRow = Array.from(getReleaseRows())
            .find(row => row.getBoundingClientRect().bottom > 0);
        const pageNumber = currentRow ? parseInt(currentRow.dataset.ebdsPage, 10) : NaN;
        if (Number.isInteger(pageNumber) && pageNumber > 0 && pageNumber !== listingCurrentPage) {
            listingCurrentPage = pageNumber;
            updateListingScrollTopLabel();
        }
        const listingHeader = document.querySelector('.nzb_multi_operations');
        listingScrollTopLink.hidden = !isListingPaginationSuppressed() || overlay.classList.contains('visible') ||
            !listingHeader || listingHeader.getBoundingClientRect().bottom > 0;
    };
    EBDS.updateListingScrollTop = updateListingScrollTopLink;
    window.addEventListener('scroll', updateListingScrollTopLink, { passive: true });
    window.addEventListener('resize', updateListingScrollTopLink, { passive: true });
    updateListingScrollTopLink();
    // Ensure the vertical limiter state is applied now.
    try { applyGalleryVerticalLimit(); } catch (e) { }

    // Quick state for gallery hover lightboxes
    let galleryHoverLightboxes = new Map();
    let currentHoveredGalleryItem = null;
    // Track whether Control is currently pressed so we don't accidentally close the hover lightbox while it's held
    // using global ctrlPressed

    // preserve previous display value of the main browse container so we can hide/show it
    let browseContainerPrevDisplay = null;

    // Set initial columns
    grid.style.setProperty('--ebds-cols', EBDS_GALLERY_COLS);

    // --- EBDS: per-category session and saved state helpers ---
    const EBDS_SESSION_PREFIX = 'ebdSession_';
    const EBDS_SAVED_PREFIX = 'ebdSaved_';
    const EBDS_SAVED_EXPIRED_PREFIX = 'ebdSavedExpired_';
    const EBDS_BASELINE_PENDING_PREFIX = 'ebdBaselinePending_';

    // Gallery mode is a deliberate choice, not transient session state: it stays
    // on until it is switched off, however long the gap between visits. 3.0.0
    // briefly expired it with the UI session, which made the setting look like it
    // was not being saved. Any stored value counts, so both the bare '1' written
    // by 2.x and the timestamp written by 3.0.0 still restore.
    function markGalleryOpen() {
        try { localStorage.setItem(EBDS_GALLERY_OPEN_KEY, '1'); }
        catch (e) { ebdsWarn('persist gallery open state', e); }
    }

    function clearGalleryOpen() {
        try { localStorage.removeItem(EBDS_GALLERY_OPEN_KEY); } catch (e) { }
    }

    function shouldRestoreGalleryOpen() {
        try { return !!localStorage.getItem(EBDS_GALLERY_OPEN_KEY); }
        catch (e) { return false; }
    }

    function getCategoryIdFromUrl() {
        try {
            const params = new URLSearchParams(location.search);
            const t = params.get('t');
            if (t) {
                // The site emits an empty top= on ordinary browse URLs and a period
                // on the Top Grabs views (1/7/30/365/99999). Only an exact match is
                // the same listing: treating top=7 as the plain category let a
                // grab-ordered page overwrite that category's date baseline.
                const top = (params.get('top') || '').trim();
                const match = categoryLinks.find(link => {
                    const linkUrl = new URL(link.href, location.origin);
                    if (linkUrl.searchParams.get('t') !== t) return false;
                    return (linkUrl.searchParams.get('top') || '').trim() === top;
                });
                return match ? match.id : null;
            }
            // Fallback: try to match a visible category link by pathname/search
            const href = location.pathname + (location.search || '');
            const match = categoryLinks.find(l => href.indexOf(l.href) !== -1 || l.href.indexOf(href) !== -1);
            if (match) return match.id;
        } catch (e) { }
        return null;
    }

    // The date baseline is only meaningful on a newest-first, unfiltered listing.
    // The site also supports ob=<field>_<dir> sorting and g=<group> narrowing,
    // neither of which is part of the category identity, so capturing a baseline
    // from such a page would anchor new-item highlighting to an arbitrary date.
    function isDefaultListingOrder() {
        try {
            const params = new URLSearchParams(location.search);
            const ob = (params.get('ob') || '').trim();
            if (ob && ob !== 'posted_desc') return false;
            // g=-1 is the site's "all groups" value.
            const group = (params.get('g') || '').trim();
            if (group && group !== '-1') return false;
            return true;
        } catch (e) { return false; }
    }

    function parseRowTitleEpoch(row) {
        try {
            if (!row) return null;
            // Look for the first time cell with a title attribute: <td class="less mid" title="YYYY-MM-DD HH:MM:SS">...
            const timeCell = row.querySelector('td.less.mid[title]');
            if (!timeCell) return null;
            const t = timeCell.getAttribute('title');
            if (!t) return null;
            // Convert to ISO-ish form to be parsed as local datetime: replace first space with 'T'
            const iso = t.replace(' ', 'T');
            const d = new Date(iso);
            const ms = d.getTime();
            return Number.isFinite(ms) ? ms : null;
        } catch (e) { return null; }
    }

    function getSessionTimestamp(catId) {
        try {
            if (!catId) return 0;
            const v = localStorage.getItem(EBDS_SESSION_PREFIX + catId);
            if (!v) return 0;
            const n = parseInt(v, 10);
            return Number.isFinite(n) ? n : 0;
        } catch (e) { return 0; }
    }

    function setSessionTimestamp(catId, epochMs) {
        try {
            if (!catId || !epochMs) return;
            localStorage.setItem(EBDS_SESSION_PREFIX + catId, String(Number(epochMs)));
        } catch (e) { }
    }

    function getSavedTimestamp(catId) {
        try {
            if (!catId) return 0;
            const v = localStorage.getItem(EBDS_SAVED_PREFIX + catId);
            if (!v) return 0;
            const n = parseInt(v, 10);
            return Number.isFinite(n) ? n : 0;
        } catch (e) { return 0; }
    }

    function setSavedTimestamp(catId, epochMs) {
        try {
            if (!catId || !epochMs) return;
            localStorage.setItem(EBDS_SAVED_PREFIX + catId, String(Number(epochMs)));
        } catch (e) { }
    }

    function getSavedExpiredTimestamp(catId) {
        try {
            if (!catId) return 0;
            const v = localStorage.getItem(EBDS_SAVED_EXPIRED_PREFIX + catId);
            if (!v) return 0;
            const n = parseInt(v, 10);
            return Number.isFinite(n) ? n : 0;
        } catch (e) { return 0; }
    }

    function setSavedExpiredTimestamp(catId, epochMs) {
        try {
            if (!catId || !epochMs) return;
            localStorage.setItem(EBDS_SAVED_EXPIRED_PREFIX + catId, String(Number(epochMs)));
        } catch (e) { }
    }

    function getLastVisitTimestamp(catId) {
        // For highlighting, use ebdSavedExpired
        return getSavedExpiredTimestamp(catId);
    }

    function setBaselinePending(catId, pending) {
        try {
            const key = EBDS_BASELINE_PENDING_PREFIX + catId;
            if (pending) localStorage.setItem(key, '1');
            else localStorage.removeItem(key);
        } catch (e) { }
    }

    function isBaselinePending(catId) {
        try { return localStorage.getItem(EBDS_BASELINE_PENDING_PREFIX + catId) === '1'; }
        catch (e) { return false; }
    }

    function saveFirstPageBaseline(catId) {
        // Use the newest row rather than the first one so a minor ordering
        // surprise cannot anchor the baseline to an older item.
        let epoch = 0;
        getReleaseRows().forEach(row => {
            const rowEpoch = parseRowTitleEpoch(row);
            if (rowEpoch && rowEpoch > epoch) epoch = rowEpoch;
        });
        if (!epoch) return false;
        setSavedTimestamp(catId, epoch);
        setBaselinePending(catId, false);
        return true;
    }

    function updateUiSessionOnPageLoad() {
        try {
            const now = Date.now();
            const expirationMs = EBDS_SESSION_EXPIRATION_MINUTES * 60 * 1000;
            const previous = parseInt(localStorage.getItem(EBDS_UI_SESSION_KEY), 10);
            if (!Number.isFinite(previous) || (now - previous) >= expirationMs) {
                clearClickedGuids();
            }
            localStorage.setItem(EBDS_UI_SESSION_KEY, String(now));
        } catch (e) { }
    }

    function updateLastVisitFromPageOnLoad() {
        try {
            if (EBDS_IS_SEARCH_PAGE) return;
            const catId = getCategoryIdFromUrl();
            if (!catId) return;
            const params = new URLSearchParams(location.search);
            const offset = params.get('offset');
            const canCaptureBaseline = (!offset || offset === '0') && isDefaultListingOrder();
            const now = Date.now();
            const sessionExpirationMs = EBDS_SESSION_EXPIRATION_MINUTES * 60 * 1000;
            const lastSession = getSessionTimestamp(catId);
            const sessionExpired = !lastSession || (now - lastSession) >= sessionExpirationMs;

            if (sessionExpired) {
                // Session expired: backup ebdSaved to ebdSavedExpired if exists
                const currentSaved = getSavedTimestamp(catId);
                if (currentSaved) {
                    setSavedExpiredTimestamp(catId, currentSaved);
                }
                // Update session timestamp
                setSessionTimestamp(catId, now);
                // Capture the baseline only from an unsorted, unfiltered first page.
                if (canCaptureBaseline) {
                    if (!saveFirstPageBaseline(catId)) setBaselinePending(catId, true);
                } else {
                    setBaselinePending(catId, true);
                }
            } else {
                // Session not expired: just update session timestamp
                setSessionTimestamp(catId, now);
                if (canCaptureBaseline && isBaselinePending(catId)) {
                    saveFirstPageBaseline(catId);
                }
            }
        } catch (e) { }
    }

    function updateSessionActivityForInfiniteScroll() {
        updateUiSessionOnPageLoad();
        if (EBDS_IS_SEARCH_PAGE) return;
        const catId = getCategoryIdFromUrl();
        if (!catId) return;
        const now = Date.now();
        const lastSession = getSessionTimestamp(catId);
        const expirationMs = EBDS_SESSION_EXPIRATION_MINUTES * 60 * 1000;
        if (!lastSession || (now - lastSession) >= expirationMs) {
            const currentSaved = getSavedTimestamp(catId);
            if (currentSaved) setSavedExpiredTimestamp(catId, currentSaved);
            // Loaded pages may be older, so defer the new baseline until page one is visited.
            setBaselinePending(catId, true);
        }
        setSessionTimestamp(catId, now);
    }

    function getGalleryOmissionCounts() {
        const counts = { withoutPictures: 0, excludedByKeywords: 0, excludedByNewsgroups: 0, belowMinimumSize: 0 };
        const currentCategoryId = getCategoryIdFromUrl();
        const lastVisit = currentCategoryId ? getLastVisitTimestamp(currentCategoryId) : 0;
        const showOnlyNew = EBDS_GALLERY_SHOW_ONLY_NEW && !EBDS_IS_SEARCH_PAGE && lastVisit > 0;

        // Each count answers "how many items is this filter hiding", so an item
        // caught by two filters is counted under both - that matches the per-filter
        // bypass links these numbers label.
        getReleaseRows().forEach(row => {
            try {
                const guid = getReleaseGuid(row);
                if (guid && EBDS_CLICKED_GUIDS.has(guid)) return;
                if (showOnlyNew) {
                    const itemEpoch = parseRowTitleEpoch(row);
                    if (!(itemEpoch && itemEpoch > lastVisit)) return;
                }

                const titleText = getReleaseTitleText(row);
                const blacklisted = EBDS_BLACKLIST_TERMS.some(term => term && titleText.includes(term));
                const excludedNewsgroup = EBDS_EXCLUDED_NEWSGROUPS.includes(getReleaseNewsgroup(row));
                const highlighted = EBDS_HIGHLIGHT_KEYWORDS.some(term => term && titleText.includes(term));
                const hasOriginalImage = row.dataset.ebdsHasImgLink === '1';
                const missingOriginalImage = EBDS_HIDE_WITHOUT_PICTURES && !hasOriginalImage;
                const fileSizeMb = getReleaseSizeMb(row);
                const belowMinimumSize = EBDS_MIN_FILE_SIZE_MB > 0 && fileSizeMb !== null && fileSizeMb < EBDS_MIN_FILE_SIZE_MB;

                if (!highlighted && missingOriginalImage) counts.withoutPictures++;
                if (!highlighted && blacklisted) counts.excludedByKeywords++;
                if (excludedNewsgroup) counts.excludedByNewsgroups++;
                if (!highlighted && belowMinimumSize) counts.belowMinimumSize++;
            } catch (e) { ebdsWarn('count gallery omissions', e); }
        });

        return counts;
    }

    function updateGalleryFooter() {
        try {
            const existingLinks = overlay.querySelector('.ebds-gallery-filter-links');
            if (existingLinks) existingLinks.remove();
            if (!overlay.classList.contains('visible') || EBDS_FILTER_LINKS_POSITION === 'hidden') return;

            const counts = getGalleryOmissionCounts();
            const filters = [
                {
                    count: counts.withoutPictures,
                    key: 'withoutPictures',
                    label: `${counts.withoutPictures} ${counts.withoutPictures === 1 ? 'item' : 'items'} without pictures`
                },
                {
                    count: counts.excludedByKeywords,
                    key: 'keywords',
                    label: `${counts.excludedByKeywords} excluded by keywords`
                },
                {
                    count: counts.excludedByNewsgroups,
                    key: 'newsgroups',
                    label: `${counts.excludedByNewsgroups} excluded by newsgroup`
                },
                {
                    count: counts.belowMinimumSize,
                    key: 'minimumSize',
                    label: `${counts.belowMinimumSize} below minimum size`
                }
            ].filter(filter => filter.count > 0);
            if (!filters.length) return;

            const linksContainer = document.createElement('div');
            linksContainer.className = 'ebds-gallery-filter-links';
            if (EBDS_FILTER_LINKS_POSITION === 'header') linksContainer.classList.add('ebds-header');
            filters.forEach((filter, index) => {
                if (index > 0) linksContainer.appendChild(document.createTextNode(' • '));
                const link = document.createElement('a');
                link.href = '#';
                const bypassed = EBDS_TEMPORARY_FILTER_BYPASS[filter.key];
                link.textContent = filter.label;
                link.title = bypassed ? 'Apply this filter again' : 'Show these items temporarily';
                link.classList.toggle('ebds-filter-bypassed', bypassed);
                link.addEventListener('click', event => {
                    event.preventDefault();
                    EBDS_TEMPORARY_FILTER_BYPASS[filter.key] = !EBDS_TEMPORARY_FILTER_BYPASS[filter.key];
                    applyRowFiltering();
                });
                linksContainer.appendChild(link);
            });
            if (EBDS_FILTER_LINKS_POSITION === 'header') {
                const topPagination = overlay.querySelector('.ebds-gallery-pagination.ebds-top');
                if (topPagination) topPagination.insertAdjacentElement('afterend', linksContainer);
                else overlay.insertBefore(linksContainer, grid);
            } else {
                // The infinite-scroll status and the cloned bottom pagination are
                // already children of the overlay, so a plain append put the
                // "footer" links below them.
                const footerAnchor = overlay.querySelector('.ebds-gallery-pagination.ebds-bottom, .ebds-infinite-status');
                if (footerAnchor) overlay.insertBefore(linksContainer, footerAnchor);
                else overlay.appendChild(linksContainer);
            }
        } catch (e) { }
    }

    EBDS.updateGalleryFooter = updateGalleryFooter;

    function formatGalleryCaption(posted, name, size) {
        const maxLength = 80;
        if (!size) {
            const text = [posted, name].filter(Boolean).join(' • ');
            return text.length > maxLength ? text.substring(0, maxLength - 1) + '…' : text;
        }

        const prefix = posted ? posted + ' • ' : '';
        const suffix = ' • ' + size;
        const fullText = prefix + name + suffix;
        if (fullText.length <= maxLength) return fullText;

        const availableNameLength = maxLength - prefix.length - suffix.length;
        const truncatedName = availableNameLength > 1
            ? name.substring(0, availableNameLength - 1) + '…'
            : '';
        return prefix + truncatedName + suffix;
    }

    function appendGalleryNewsgroup(item, row, fallbackLabel) {
        const groupLink = row ? row.querySelector('.infotip.badge.badge-inverse.halffade') : null;
        const label = fallbackLabel || (groupLink ? getReleaseNewsgroupLabel(row) : '');
        if (!label) return;
        const badge = groupLink ? groupLink.cloneNode(true) : document.createElement('span');
        if (!groupLink) badge.textContent = label;
        badge.className = 'infotip badge badge-inverse halffade ebds-gallery-newsgroup';
        badge.title = groupLink ? (groupLink.title || `Browse ${label}`) : `Newsgroup: ${label}`;
        if (groupLink) {
            badge.addEventListener('click', event => event.stopPropagation());
        }
        item.appendChild(badge);
    }

    function openGallery() {
        const previousScrollTop = overlay.classList.contains('visible') ? overlay.scrollTop : 0;
        grid.innerHTML = '';
        // determine last-visit epoch for the current category (if any)
        const currentCategoryId = getCategoryIdFromUrl();
        const lastVisit = currentCategoryId ? getLastVisitTimestamp(currentCategoryId) : 0;
        const showOnlyNew = EBDS_GALLERY_SHOW_ONLY_NEW && !EBDS_IS_SEARCH_PAGE && lastVisit > 0;

        const imgs = Array.from(document.querySelectorAll('.ebds-preview-img'));
        imgs.forEach((img, idx) => {
            // Skip images from hidden rows (filtered out)
            const rowId = img.dataset.rowId;
            const sourceRow = rowId ? document.getElementById(rowId) : null;
            if (sourceRow && sourceRow.dataset.ebdsGalleryHidden === '1') return;
            // Skip images that were clicked/added during this session
            try {
                const g = img.dataset.guid || img.getAttribute('data-guid');
                if (g && EBDS_CLICKED_GUIDS && EBDS_CLICKED_GUIDS.has(g)) return;
            } catch (e) { }

            const item = document.createElement('div');
            item.className = 'ebds-gallery-item';
            if (rowId) item.dataset.rowId = rowId;
            if (sourceRow && sourceRow.dataset.ebdsPage) item.dataset.ebdsPage = sourceRow.dataset.ebdsPage;
            if (sourceRow && sourceRow.dataset.ebdsGalleryTemporary === '1') {
                item.classList.add('ebds-temporarily-shown');
            }
            if (sourceRow && sourceRow.classList.contains('ebds-keyword-highlight')) {
                item.classList.add('ebds-keyword-highlight');
            }
            const copy = img.cloneNode(true);
            // Remove inline transition handlers to avoid duplication issues
            copy.style.maxWidth = '';
            copy.style.maxHeight = '';
            copy.addEventListener('click', (e) => {
                e.preventDefault();
                const guid = copy.dataset.guid || (copy.getAttribute('data-guid') || null);
                if (guid) addToCartForGuid(guid, copy.src);
                else addToCartBySrc(copy.src);
            });

            item.appendChild(copy);
            if (EBDS_SHOW_GALLERY_NEWSGROUP) {
                appendGalleryNewsgroup(item, sourceRow, copy.dataset.newsgroup || '');
            }
            // add caption under clone (use stored data attributes if available)
            try {
                const detailsHref = copy.dataset.detailsHref;
                const caption = document.createElement(detailsHref ? 'a' : 'div');
                caption.className = 'ebds-preview-caption';
                if (detailsHref) {
                    caption.href = detailsHref;
                    caption.style.textDecoration = 'none';
                }
                const posted = copy.dataset.posted || '';
                const name = copy.dataset.name || '';
                const size = copy.dataset.size || '';
                caption.textContent = formatGalleryCaption(posted, name, size);
                try {
                    // Mark as new when the parsed row epoch is newer than stored last visit for this category
                    const row = copy.dataset.rowId ? document.getElementById(copy.dataset.rowId) : null;
                    const itemEpoch = row ? parseRowTitleEpoch(row) : null;
                    if (itemEpoch && lastVisit && itemEpoch > lastVisit) {
                        caption.classList.add('ebds-new-post');
                    }
                } catch (e) { }
                // Optionally hide items that aren't new
                try {
                    const row = copy.dataset.rowId ? document.getElementById(copy.dataset.rowId) : null;
                    const itemEpoch = row ? parseRowTitleEpoch(row) : null;
                    const isNew = (itemEpoch && lastVisit && itemEpoch > lastVisit);
                    if (showOnlyNew && !isNew) {
                        // Skip adding this item to the gallery
                        return;
                    }
                } catch (e) { }
                item.appendChild(caption);
            } catch (e) { }
            // Add ctrl+hover behavior: open a large preview while holding Control
            try {
                copy.addEventListener('mouseenter', (e) => {
                    currentHoveredGalleryItem = item;
                    if (e.ctrlKey || ctrlPressed) openGalleryHoverLightbox(item, copy.src);
                });
                copy.addEventListener('mousemove', (e) => {
                    // open when ctrl is indicated (either via event or global flag); don't close while ctrl is held
                    if (e.ctrlKey || ctrlPressed) openGalleryHoverLightbox(item, copy.src);
                    else closeGalleryHoverLightbox(item);
                });
                copy.addEventListener('mouseleave', (e) => {
                    currentHoveredGalleryItem = null;
                    // Only close when Ctrl is NOT pressed
                    if (!ctrlPressed) closeGalleryHoverLightbox(item);
                });
            } catch (ex) { }
            grid.appendChild(item);
        });

        {
            // Placeholders follow the same decision as the image cards above, so a
            // release revealed by any bypass link shows up in the gallery too.
            getReleaseRows().forEach(row => {
                try {
                    if (row.dataset.ebdsHasImgLink === '1') return;
                    if (row.dataset.ebdsGalleryHidden === '1') return;
                    const guid = getReleaseGuid(row);
                    if (guid && EBDS_CLICKED_GUIDS.has(guid)) return;
                    const itemEpoch = parseRowTitleEpoch(row);
                    if (showOnlyNew && !(itemEpoch && itemEpoch > lastVisit)) return;

                    const item = document.createElement('div');
                    item.className = 'ebds-gallery-item ebds-gallery-placeholder';
                    if (row.dataset.ebdsGalleryTemporary === '1') {
                        item.classList.add('ebds-temporarily-shown');
                    }
                    if (row.classList.contains('ebds-keyword-highlight')) {
                        item.classList.add('ebds-keyword-highlight');
                    }
                    item.dataset.guid = guid;
                    item.dataset.rowId = row.id;
                    if (row.dataset.ebdsPage) item.dataset.ebdsPage = row.dataset.ebdsPage;
                    const detailsLink = row.querySelector('a.title');
                    const visual = document.createElement('div');
                    visual.className = 'ebds-gallery-placeholder-visual';
                    visual.textContent = 'No picture';
                    visual.title = 'Click to add to cart';
                    visual.addEventListener('click', event => {
                        event.preventDefault();
                        addToCartForGuid(guid, null);
                    });
                    item.appendChild(visual);
                    if (EBDS_SHOW_GALLERY_NEWSGROUP) appendGalleryNewsgroup(item, row);

                    const caption = document.createElement(detailsLink ? 'a' : 'div');
                    caption.className = 'ebds-preview-caption';
                    if (detailsLink) {
                        caption.href = detailsLink.href;
                        caption.style.textDecoration = 'none';
                    }
                    const name = detailsLink ? detailsLink.textContent.trim() : '';
                    const postedCell = row.querySelector('td.less.mid');
                    caption.textContent = formatGalleryCaption(
                        postedCell ? postedCell.textContent.trim() : '',
                        name,
                        getReleaseSizeText(row)
                    );
                    if (itemEpoch && lastVisit && itemEpoch > lastVisit) {
                        caption.classList.add('ebds-new-post');
                    }
                    item.appendChild(caption);
                    grid.appendChild(item);
                } catch (e) { }
            });
        }

        // Image cards and no-picture placeholders are built separately; restore source-row order.
        const rowOrder = new Map(
            Array.from(getReleaseRows())
                .map((row, index) => [row.id, index])
        );
        Array.from(grid.querySelectorAll('.ebds-gallery-item'))
            .sort((left, right) => {
                const leftIndex = rowOrder.get(left.dataset.rowId) ?? Number.MAX_SAFE_INTEGER;
                const rightIndex = rowOrder.get(right.dataset.rowId) ?? Number.MAX_SAFE_INTEGER;
                return leftIndex - rightIndex;
            })
            .forEach(item => grid.appendChild(item));

        // Add category links at the top of gallery
        try {
            // remove existing category links if present
            const oldLinks = overlay.querySelectorAll('.ebds-gallery-category-links');
            oldLinks.forEach(l => l.remove());
            const linksDiv = document.createElement('div');
            linksDiv.className = 'ebds-gallery-category-links';
            const links = categoryLinks;
            const visibleLinks = links.filter(link => EBDS_LINK_VISIBILITY[link.id] !== false);
            for (let i = 0; i < visibleLinks.length; i++) {
                const link = visibleLinks[i];
                const a = document.createElement('a');
                a.href = link.href;
                if (link.title) a.title = link.title;
                a.textContent = link.text;
                a.addEventListener('click', () => { markGalleryOpen(); });
                linksDiv.appendChild(a);
                if (i < visibleLinks.length - 1) {
                    linksDiv.appendChild(document.createTextNode(' | '));
                }
            }
            overlay.insertBefore(linksDiv, grid);
        } catch (e) { }

        // Append page pagination to top and bottom of gallery overlay (if present on the page)
        try {
            // remove existing gallery pagination clones if present
            const oldPags = overlay.querySelectorAll('.ebds-gallery-pagination, .ebds-gallery-pagination-spacer');
            oldPags.forEach(p => p.remove());
            const pageNav = document.querySelector('nav.pagination[aria-label="Pagination"]');
            if (EBDS_INFINITE_SCROLL) {
                const topSpacer = document.createElement('div');
                topSpacer.className = 'ebds-gallery-pagination-spacer ebds-top';
                topSpacer.setAttribute('aria-hidden', 'true');
                const bottomSpacer = document.createElement('div');
                bottomSpacer.className = 'ebds-gallery-pagination-spacer ebds-bottom';
                bottomSpacer.setAttribute('aria-hidden', 'true');
                overlay.insertBefore(topSpacer, grid);
                grid.insertAdjacentElement('afterend', bottomSpacer);
            } else if (pageNav) {
                const clonedNavTop = pageNav.cloneNode(true);
                clonedNavTop.classList.add('ebds-gallery-pagination', 'ebds-top');
                const clonedNavBottom = pageNav.cloneNode(true);
                clonedNavBottom.classList.add('ebds-gallery-pagination', 'ebds-bottom');
                // Keep gallery open across pages: do NOT close the gallery when pagination links are clicked.
                // Instead ensure the persistent flag is set so the new page will re-open the gallery.
                [clonedNavTop, clonedNavBottom].forEach(clonedNav => {
                    try { clonedNav.querySelectorAll('a').forEach(a => a.addEventListener('click', () => { markGalleryOpen(); })); } catch (e) { }
                });

                // If "Show only new items" is enabled, and the current page contains at least one non-new item,
                // prune cloned pagination entries after the current page (these pages will contain older/non-new items).
                try {
                    if (showOnlyNew) {
                        const rowsOnPage = Array.from(getReleaseRows());
                        const hasNonNew = rowsOnPage.some(r => {
                            try {
                                const epoch = parseRowTitleEpoch(r);
                                // treat missing epoch as non-new
                                if (!epoch) return true;
                                return lastVisit && epoch <= lastVisit;
                            } catch (e) { return false; }
                        });

                        if (hasNonNew) {
                            function pruneAfterCurrent(nav) {
                                try {
                                    const current = nav.querySelector('[aria-current="page"]') || nav.querySelector('.active, li.active, .current') || nav.querySelector('a.active');
                                    const curLi = current && current.closest ? (current.closest('li') || current) : null;
                                    if (curLi) {
                                        let sib = curLi.nextElementSibling;
                                        while (sib) {
                                            const next = sib.nextElementSibling;
                                            try { sib.remove(); } catch (e) { }
                                            sib = next;
                                        }
                                    } else {
                                        // Fallback: remove subsequent anchors after the active anchor
                                        const anchors = Array.from(nav.querySelectorAll('a'));
                                        let idx = anchors.findIndex(a => a === current || a.classList.contains('active') || a.getAttribute('aria-current') === 'page');
                                        if (idx >= 0) {
                                            for (let i = anchors.length - 1; i > idx; i--) {
                                                try { anchors[i].remove(); } catch (e) { }
                                            }
                                        } else {
                                            // As a final fallback, remove obvious "next" links
                                            const labels = /next|›|»|>|right|→/i;
                                            anchors.filter(a => a.getAttribute('rel') === 'next' || labels.test(a.getAttribute('aria-label') || '') || labels.test(a.textContent)).forEach(a => { try { a.remove(); } catch (e) { } });
                                        }
                                    }
                                } catch (e) { }
                            }
                            pruneAfterCurrent(clonedNavTop);
                            pruneAfterCurrent(clonedNavBottom);
                        }
                    }
                } catch (e) { }

                overlay.insertBefore(clonedNavTop, grid);
                overlay.appendChild(clonedNavBottom);
            }
        } catch (e) { }

        try {
            const container = document.querySelector('div.container-fluid');
            if (container) {
                if (!container.hasAttribute('data-ebds-hidden')) {
                    browseContainerPrevDisplay = container.style.display;
                    container.style.display = 'none';
                    container.setAttribute('data-ebds-hidden', '1');
                }
            }
        } catch (e) { ebdsWarn('hide the listing container', e); }
        try { applyGalleryVerticalLimit(); } catch (e) { }
        overlay.classList.add('visible');
        btn.classList.add('active');
        // A rebuild clears and repopulates the grid, which would otherwise drop
        // the reader back to the top of the gallery.
        if (previousScrollTop) overlay.scrollTop = previousScrollTop;
        try { overlay.focus({ preventScroll: true }); } catch (e) { }
        updateGalleryScrollTopLink();
        updateGalleryFooter();
        if (EBDS_INFINITE_SCROLL_ONLY_GALLERY) {
            try { if (typeof EBDS.refreshInfiniteScroll === 'function') EBDS.refreshInfiniteScroll(); } catch (e) { }
        }
        markGalleryOpen();
    }

    function closeGallery() {
        try {
            const container = document.querySelector('div.container-fluid');
            if (container && browseContainerPrevDisplay !== null) {
                container.style.display = browseContainerPrevDisplay;
                container.removeAttribute('data-ebds-hidden');
            }
            browseContainerPrevDisplay = null;
        } catch (e) { }
        overlay.classList.remove('visible');
        btn.classList.remove('active');
        galleryScrollTopLink.hidden = true;
        clearGalleryOpen();
    }

    // Hover lightboxes inside the gallery (opened while Ctrl is held)
    function openGalleryHoverLightbox(item, src) {
        try {
            if (galleryHoverLightboxes.has(item)) return;
            const lb = document.createElement('div');
            lb.className = 'ebds-gallery-hover-lb';
            lb.style.position = 'fixed';
            lb.style.inset = '0';
            lb.style.background = 'rgba(0,0,0,0.95)';
            lb.style.display = 'flex';
            lb.style.alignItems = 'center';
            lb.style.justifyContent = 'center';
            lb.style.zIndex = '2147483650';
            const im = document.createElement('img');
            im.src = src;
            applyCtrlHoverPreviewSizing(im);
            lb.appendChild(im);
            // image click => add to cart; background click closes lightbox. (Escape handled globally)
            im.style.cursor = 'pointer';
            im.title = 'Click to add to cart';
            im.addEventListener('click', (ev) => {
                ev.stopPropagation();
                try {
                    const guidImg = item.querySelector('img');
                    const guid = guidImg && guidImg.dataset ? (guidImg.dataset.guid || null) : null;
                    if (guid) addToCartForGuid(guid, src);
                    else addToCartBySrc(src);
                } catch (e) { ebdsWarn('gallery lightbox add to cart', e); }
                try { lb.remove(); } catch (e) { }
                try { galleryHoverLightboxes.delete(item); } catch (e) { }
            });
            lb.addEventListener('click', () => {
                try { lb.remove(); } catch (e) { }
                galleryHoverLightboxes.delete(item);
            });
            document.body.appendChild(lb);
            galleryHoverLightboxes.set(item, lb);
        } catch (e) { ebdsWarn('open gallery hover lightbox', e); }
    }

    function closeGalleryHoverLightbox(item) {
        try {
            const lb = galleryHoverLightboxes.get(item);
            if (lb) {
                try { lb.remove(); } catch (e) { }
                galleryHoverLightboxes.delete(item);
            }
        } catch (e) { }
    }

    // Open hover lightbox when Control is pressed while hovering over a gallery
    // item. ctrlPressed itself is maintained by the shared handler above.
    document.addEventListener('keydown', (e) => {
        if (e.key !== 'Control' || !currentHoveredGalleryItem) return;
        try {
            const img = currentHoveredGalleryItem.querySelector('img');
            if (img) openGalleryHoverLightbox(currentHoveredGalleryItem, img.src);
        } catch (ex) { }
    });

    // Closes both lightbox collections; called when Ctrl is released and when the
    // window loses focus.
    EBDS.closeAllHoverLightboxes = function () {
        try { galleryHoverLightboxes.forEach(lb => lb.remove()); galleryHoverLightboxes.clear(); } catch (ex) { }
        try { EBDS.hoverLightboxes.forEach(lb => lb.remove()); EBDS.hoverLightboxes.clear(); } catch (ex) { }
    };

    // Also close hover lightboxes when gallery closes
    const originalCloseGallery = closeGallery;
    closeGallery = function () {
        try { galleryHoverLightboxes.forEach((lb, item) => { lb.remove(); }); galleryHoverLightboxes.clear(); } catch (ex) { }
        originalCloseGallery();
    };

    // --- helper: add to cart by guid or src ---
    function addToCartForGuid(guid, src) {
        return addToCartByGuid(guid, src);
    }

    function addToCartBySrc(src) {
        const anchor = Array.from(document.querySelectorAll('a[href]')).find(a => a.href === src);
        if (anchor) {
            const row = anchor.closest('tr');
            const guid = getReleaseGuid(row);
            if (guid) { addToCartForGuid(guid, src); return; }
        }
        try { if (window.$ && $.pnotify) { $.pnotify({ title: 'CART', text: 'Could not determine item to add', type: 'error' }); } } catch (e) { }
    }

    let galleryRefreshHandle = 0;
    function flushGalleryRefresh() {
        if (galleryRefreshHandle) {
            cancelAnimationFrame(galleryRefreshHandle);
            galleryRefreshHandle = 0;
        }
        if (overlay.classList.contains('visible')) openGallery();
    }
    // Coalesce refreshes into one rebuild per frame.
    EBDS.refreshGallery = () => {
        if (!overlay.classList.contains('visible') || galleryRefreshHandle) return;
        galleryRefreshHandle = requestAnimationFrame(() => {
            galleryRefreshHandle = 0;
            if (overlay.classList.contains('visible')) openGallery();
        });
    };
    // Infinite scroll must rebuild synchronously: the gallery observer is
    // reconnected right after appending, and would immediately re-trigger if the
    // grid had not grown yet.
    EBDS.flushGalleryRefresh = flushGalleryRefresh;
    btn.addEventListener('click', () => {
        if (overlay.classList.contains('visible')) closeGallery();
        else openGallery();
        // Leaving focus on the button would suppress every keyboard shortcut.
        try { btn.blur(); } catch (e) { }
    });



    // Keyboard shortcut helpers for Prev/Next page (ArrowLeft / ArrowRight)
    function findPaginationLink(direction) {
        try {
            // Prefer rel attributes if present
            const rel = direction === 'prev' ? 'prev' : 'next';
            const relLink = document.querySelector('a[rel="' + rel + '"]');
            if (relLink) return relLink;
        } catch (e) { }

        // Try common pagination containers
        const nav = document.querySelector('nav.pagination') || document.querySelector('nav[aria-label="Pagination"]') || document.querySelector('ul.pagination') || document.querySelector('.pagination');
        if (!nav) return null;

        // Find current page item and walk siblings
        const current = nav.querySelector('[aria-current="page"]') || nav.querySelector('.active, li.active, .current') || nav.querySelector('a.active');
        if (current) {
            const curAnchor = current.tagName === 'A' ? current : (current.querySelector('a') || current);
            const li = (curAnchor && curAnchor.closest) ? curAnchor.closest('li') || curAnchor.parentElement : null;
            if (li) {
                let sib = direction === 'prev' ? li.previousElementSibling : li.nextElementSibling;
                while (sib) {
                    const a = sib.querySelector('a');
                    if (a && a.getAttribute('href')) return a;
                    sib = direction === 'prev' ? sib.previousElementSibling : sib.nextElementSibling;
                }
            }
        }

        // Fallback: try aria-label or visible text matching Prev/Next
        const labels = direction === 'prev' ? /prev|previous|‹|«|<|left|←/i : /next|›|»|>|right|→/i;
        const a = Array.from(nav.querySelectorAll('a')).find(a => (a.getAttribute('aria-label') && labels.test(a.getAttribute('aria-label'))) || labels.test(a.textContent));
        return a || null;
    }

    // Shortcuts must not intercept typing, but they must keep working after the
    // user clicks a button (which leaves focus on it), so this tests for an
    // editable target rather than for focus existing at all.
    function isTypingTarget(element) {
        if (!element) return false;
        if (element.isContentEditable) return true;
        const tag = element.tagName;
        return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || tag === 'OPTION';
    }

    // Keyboard shortcut
    document.addEventListener('keydown', (e) => {
        try {
            if (isTypingTarget(document.activeElement)) return;
        } catch (ex) { }
        if (typeof e.key !== 'string') return;

        if (e.key.toLowerCase() === 'g' && !e.altKey && !e.ctrlKey && !e.metaKey) {
            e.preventDefault();
            if (overlay.classList.contains('visible')) closeGallery();
            else openGallery();
        } else if (e.key === 'Escape') {
            // Close the gallery and any visible enlarged previews
            closeGallery();
            try { if (typeof EBDS.closeAllEnlarged === 'function') EBDS.closeAllEnlarged(); } catch (ex) { }
        } else if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && !e.altKey && !e.ctrlKey && !e.metaKey) {
            // Jump to previous/next page using site pagination
            const direction = (e.key === 'ArrowLeft') ? 'prev' : 'next';
            e.preventDefault();

            // Prefer global rel links first
            let link = null;
            try { link = document.querySelector('a[rel="' + (direction === 'prev' ? 'prev' : 'next') + '"]'); } catch (ex) { }
            if (!link) link = findPaginationLink(direction);

            // Also check overlay cloned pagination (gallery) for matching links
            if (!link && overlay && overlay.querySelector) {
                try {
                    const cloneNav = overlay.querySelector('.ebds-gallery-pagination');
                    if (cloneNav) {
                        // look for obvious labels
                        const labels = direction === 'prev' ? /prev|previous|‹|«|<|left|←/i : /next|›|»|>|right|→/i;
                        link = Array.from(cloneNav.querySelectorAll('a')).find(a => (a.getAttribute('aria-label') && labels.test(a.getAttribute('aria-label'))) || labels.test(a.textContent));
                        if (!link) {
                            const cur = cloneNav.querySelector('[aria-current="page"]') || cloneNav.querySelector('.active, li.active, a.active');
                            if (cur) {
                                const li = cur.closest ? cur.closest('li') : null;
                                if (li) {
                                    let sib = direction === 'prev' ? li.previousElementSibling : li.nextElementSibling;
                                    while (sib) {
                                        const a = sib.querySelector('a');
                                        if (a && a.getAttribute('href')) { link = a; break; }
                                        sib = direction === 'prev' ? sib.previousElementSibling : sib.nextElementSibling;
                                    }
                                }
                            }
                        }
                    }
                } catch (ex) { }
            }

            if (link) {
                try { link.click(); } catch (ex) { try { window.location = link.href; } catch (e) { } }
                // Keep gallery open if it was visible so the gallery state persists across navigation
                try { if (overlay.classList && overlay.classList.contains('visible')) markGalleryOpen(); } catch (ex) { }
            }
        }
    });

    // Update per-category last-visit on initial page load when on first page (offset missing or =0)
    try { updateUiSessionOnPageLoad(); } catch (e) { ebdsWarn('update UI session', e); }
    try { updateLastVisitFromPageOnLoad(); } catch (e) { ebdsWarn('update category last visit', e); }
    try { applyRowFiltering(); } catch (e) { ebdsWarn('initial row filtering', e); }

    // Load subsequent result pages into the current listing and gallery near the scroll boundary.
    try {
        const resultTable = document.querySelector(EBDS_RESULT_TABLE_SELECTOR);
        if (resultTable) {
            const resultRowsContainer = resultTable.tBodies[0] || resultTable;
            const listingStatus = document.createElement('button');
            listingStatus.type = 'button';
            listingStatus.className = 'ebds-infinite-status';
            resultTable.insertAdjacentElement('afterend', listingStatus);

            const galleryStatus = document.createElement('button');
            galleryStatus.type = 'button';
            galleryStatus.className = 'ebds-infinite-status';
            overlay.appendChild(galleryStatus);

            let loadingNextPage = false;
            let nextPageUrl = findNextPageUrl(document, location.href);
            let listingObserver = null;
            let galleryObserver = null;
            let newItemsBoundaryReached = false;

            function findNextPageUrl(root, baseUrl) {
                const nav = root.querySelector('nav.pagination[aria-label="Pagination"], nav.pagination, ul.pagination, .pagination');
                if (!nav) return null;
                const current = nav.querySelector('[aria-current="page"], li.active, .current, a.active');
                let sibling = current && current.closest ? (current.closest('li') || current).nextElementSibling : null;
                while (sibling) {
                    if (!sibling.classList.contains('disabled')) {
                        const link = sibling.matches('a[href]') ? sibling : sibling.querySelector('a[href]');
                        const href = link && link.getAttribute('href');
                        if (href && href !== '#') {
                            const resolved = new URL(href, baseUrl);
                            return resolved.origin === location.origin ? resolved.href : null;
                        }
                    }
                    sibling = sibling.nextElementSibling;
                }
                return null;
            }

            function updateInfiniteStatus(message, retryable) {
                [listingStatus, galleryStatus].forEach(status => {
                    status.textContent = message;
                    status.disabled = !retryable;
                    status.classList.toggle('ebds-retry', !!retryable);
                });
            }

            function setInfiniteStatusVisible(visible) {
                listingStatus.hidden = !visible || EBDS_INFINITE_SCROLL_ONLY_GALLERY;
                galleryStatus.hidden = !visible;
            }

            function getGalleryPrefetchMargin() {
                const items = Array.from(grid.querySelectorAll('.ebds-gallery-item'));
                if (items.length === 0) return 1000;
                const firstTop = items[0].getBoundingClientRect().top;
                const firstRowHeight = items
                    .filter(item => Math.abs(item.getBoundingClientRect().top - firstTop) < 2)
                    .reduce((height, item) => Math.max(height, item.getBoundingClientRect().height), 0);
                return Math.ceil(600 + firstRowHeight);
            }

            function isNewItemsOnlyActive() {
                if (!EBDS_GALLERY_SHOW_ONLY_NEW || EBDS_IS_SEARCH_PAGE) return false;
                const catId = getCategoryIdFromUrl();
                return !!(catId && getLastVisitTimestamp(catId));
            }

            function pageHasNewItems(rows) {
                if (!isNewItemsOnlyActive()) return true;
                const lastVisit = getLastVisitTimestamp(getCategoryIdFromUrl());
                return rows.some(row => {
                    const epoch = parseRowTitleEpoch(row);
                    return !!(epoch && epoch > lastVisit);
                });
            }

            async function loadNextPage() {
                if (!EBDS_INFINITE_SCROLL || loadingNextPage || !nextPageUrl ||
                    (isNewItemsOnlyActive() && newItemsBoundaryReached)) return;
                loadingNextPage = true;
                if (listingObserver) listingObserver.disconnect();
                if (galleryObserver) galleryObserver.disconnect();
                updateInfiniteStatus('Loading next page…', false);
                const requestedUrl = nextPageUrl;
                let loadedSuccessfully = false;
                const controller = new AbortController();
                const timeout = setTimeout(() => controller.abort(), 20000);
                try {
                    const response = await fetch(requestedUrl, {
                        credentials: 'same-origin',
                        signal: controller.signal
                    });
                    if (!response.ok) throw new Error('HTTP ' + response.status);
                const fetchedDocument = new DOMParser().parseFromString(await response.text(), 'text/html');
                if (!fetchedDocument.querySelector(EBDS_RESULT_TABLE_SELECTOR)) throw new Error('Result table missing from response');
                let fetchedPageNumber = galleryCurrentPage + 1;
                try {
                        const pageNav = fetchedDocument.querySelector('nav.pagination[aria-label="Pagination"], nav.pagination, ul.pagination, .pagination');
                        const current = pageNav && pageNav.querySelector('[aria-current="page"], li.active, .current, a.active');
                        const currentLink = current && (current.tagName === 'A' ? current : current.querySelector('a'));
                        const pageNumber = parseInt((currentLink || current)?.textContent.trim(), 10);
                        if (Number.isInteger(pageNumber) && pageNumber > 0) fetchedPageNumber = pageNumber;
                    } catch (e) { }
                    const fetchedRows = Array.from(getReleaseRows(fetchedDocument));
                    const newRows = fetchedRows.filter(row => !document.getElementById(row.id));
                    if (isNewItemsOnlyActive() && !pageHasNewItems(fetchedRows)) {
                        newItemsBoundaryReached = true;
                    }
                    newRows.forEach(row => {
                        row.dataset.ebdsInfiniteAdded = '1';
                        row.dataset.ebdsPage = String(fetchedPageNumber);
                        resultRowsContainer.appendChild(row);
                    });
                    if (newRows.length) {
                        ebdsAppendedPageCount++;
                        updateInfinitePaginationState();
                    }
                    const followingPageUrl = findNextPageUrl(fetchedDocument, requestedUrl);
                    nextPageUrl = followingPageUrl && followingPageUrl !== requestedUrl ? followingPageUrl : null;
                    if (typeof EBDS.processRows === 'function') EBDS.processRows(newRows);
                    updateSessionActivityForInfiniteScroll();
                    applyRowFiltering();
                    flushGalleryRefresh();
                    loadedSuccessfully = true;
                    if (!EBDS_INFINITE_SCROLL) updateInfiniteStatus('Infinite scrolling disabled', false);
                    else if (isNewItemsOnlyActive() && newItemsBoundaryReached) {
                        setInfiniteStatusVisible(false);
                    } else if (!nextPageUrl) updateInfiniteStatus('All results loaded', false);
                    else updateInfiniteStatus('Scroll to load more', false);
                } catch (error) {
                    ebdsWarn('infinite scroll page load', error);
                    updateInfiniteStatus(
                        error && error.name === 'AbortError'
                            ? 'Loading timed out — click to retry'
                            : 'Could not load more — click to retry',
                        true
                    );
                } finally {
                    clearTimeout(timeout);
                    loadingNextPage = false;
                    if (loadedSuccessfully) observeInfiniteScroll();
                }
            }

            function observeInfiniteScroll() {
                updateInfinitePaginationState();
                if (listingObserver) listingObserver.disconnect();
                if (galleryObserver) galleryObserver.disconnect();
                if (!EBDS_INFINITE_SCROLL) {
                    setInfiniteStatusVisible(false);
                    updateInfiniteStatus('Infinite scrolling disabled', false);
                    return;
                }
                if (isNewItemsOnlyActive() && newItemsBoundaryReached) {
                    setInfiniteStatusVisible(false);
                    return;
                }
                setInfiniteStatusVisible(true);
                if (!nextPageUrl) {
                    updateInfiniteStatus('All results loaded', false);
                    return;
                }
                updateInfiniteStatus('Scroll to load more', false);
                if (!EBDS_INFINITE_SCROLL_ONLY_GALLERY) {
                    listingObserver = new IntersectionObserver(entries => {
                        if (entries.some(entry => entry.isIntersecting)) loadNextPage();
                    }, { rootMargin: '600px 0px' });
                    listingObserver.observe(listingStatus);
                }
                galleryObserver = new IntersectionObserver(entries => {
                    if (entries.some(entry => entry.isIntersecting)) loadNextPage();
                }, { root: overlay, rootMargin: getGalleryPrefetchMargin() + 'px 0px' });
                galleryObserver.observe(galleryStatus);
            }

            listingStatus.addEventListener('click', loadNextPage);
            galleryStatus.addEventListener('click', loadNextPage);
            EBDS.setInfiniteScrollEnabled = enabled => {
                EBDS_INFINITE_SCROLL = !!enabled;
                observeInfiniteScroll();
            };
            EBDS.refreshInfiniteScroll = observeInfiniteScroll;
            if (isNewItemsOnlyActive()) {
                newItemsBoundaryReached = !pageHasNewItems(
                    Array.from(getReleaseRows(resultTable))
                );
            }
            setInfiniteStatusVisible(false);
            const startObserving = () => requestAnimationFrame(observeInfiniteScroll);
            if (typeof requestIdleCallback === 'function') requestIdleCallback(startObserving, { timeout: 1000 });
            else setTimeout(startObserving, 250);
        }
    } catch (e) {
        ebdsWarn('infinite scroll initialization', e);
    }

    // Restore state across page loads, including search-result pagination.
    try { if (shouldRestoreGalleryOpen()) openGallery(); }
    catch (e) { ebdsWarn('restore gallery open state', e); }
})();