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