E-Hentai - UX Tweaks

Numerous features to enrich your browsing experience

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         E-Hentai - UX Tweaks
// @namespace    brazenvoid
// @version      2.2.8
// @author       brazenvoid
// @license      GPL-3.0-only
// @description  Numerous features to enrich your browsing experience
// @match        https://e-hentai.org/*
// @match        https://exhentai.org/*
// @require      https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js
// @require      https://update.greasyfork.org/scripts/375557/1875051/Brazen%20Framework%20-%20Utilities.js
// @require      https://update.greasyfork.org/scripts/416104/1875150/Brazen%20Framework%20-%20View%20Layer.js
// @require      https://update.greasyfork.org/scripts/418665/1875786/Brazen%20Framework%20-%20Configuration%20Manager.js
// @require      https://update.greasyfork.org/scripts/429587/1847139/Brazen%20Framework%20-%20Item%20Attributes%20Resolver.js
// @require      https://update.greasyfork.org/scripts/416105/1875149/Brazen%20Framework%20-%20Framework.js
// @grant        GM_addStyle
// @grant        GM_download
// @run-at       document-end
// ==/UserScript==

GM_addStyle(
  String.raw`#bv-ui{font-size:1rem;min-width:310px;width:310px}#bv-ui textarea.bv-input,#bv-ui .bv-textarea-group>label.bv-label,#bv-ui .bv-stat-group :is(.bv-label,.bv-stat-label){font-family:var(--bv-font-family,ui-sans-serif,system-ui,sans-serif);font-size:1rem;font-weight:normal}#bv-ui textarea.bv-input{line-height:1.35}.adult-tag::after{content:'\2713';position:absolute;top:-8px;right:0;font-size:10px;color:aquamarine}.disliked-tag{background-color:lightcoral !important;color:white !important}.disliked-tag:hover{background-color:indianred !important}.disliked-tag > a{color:white !important}.disliked-tag.favourite-tag{background-color:orange !important}.disliked-tag.favourite-tag:hover{background-color:darkorange !important}.favourite-tag{background-color:mediumseagreen !important;color:white !important}.favourite-tag:hover{background-color:forestgreen !important}.favourite-tag > a{color:white !important}.underage-tag::after{content:'\2717';position:absolute;top:-8px;right:0;font-size:10px;color:red}.unknown-age-tag::after{content:'\003F';position:absolute;top:-8px;right:0;font-size:10px;color:yellow}`)

const ITEM_RATED_BLUE = 'ratedBlue'
const ITEM_RATED_GREEN = 'ratedGreen'
const ITEM_RATED_RED = 'ratedRed'
const ITEM_TAGS = 'tags'
const ITEM_WATCHED = 'watched'

const FILTER_RATED_VIDEOS = 'hide-rated-galleries'
const FILTER_UNDERAGE_CHARACTERS_OPTION = 'enable-underage-females-filter'
const FILTER_WATCHED_FROM_SEARCH = 'hide-watched-galleries'

const STYLE_GALLERY_HIGHLIGHT = 'gallery-highlight'

const UI_DEFAULTS_PAGE_RANGE = 'page-range'
const UI_DEFAULTS_PAGE_RANGE_ENABLE = 'enable-page-range-filter'
const UI_DEFAULTS_RATING = 'rating'
const UI_DEFAULTS_RATING_ENABLE = 'enable-rating-filter'
const UI_DEFAULTS_TAGS = 'tags'
const UI_DEFAULTS_TAGS_ENABLE = 'enable-default-tags'

const UI_OPEN_GALLERIES_AUTO_NEXT = 'search-auto-next'
const UI_OPEN_GALLERY_PAGES_AUTO_NEXT = 'gallery-auto-next'
const UI_OPEN_GALLERY_PAGES_THROTTLING = 'throttling-bypass'
const UI_OPEN_GALLERY_PAGES_DOWNLOAD = 'download-the-image'

const UI_EMBED_TORRENTS = 'embed-torrent-downloads'
const UI_VISITED_HIGHLIGHT = 'highlight-visited'
const UI_GALLERY_HIGHLIGHTS = 'gallery-highlights'
const UI_GALLERY_HIGHLIGHTS_COLOUR = 'highlight-colour'

const UI_TAG_HIGHLIGHTS_ADULT_CHARACTER = 'adult-characters'
const UI_TAG_HIGHLIGHTS_DISLIKED = 'disliked-tags'
const UI_TAG_HIGHLIGHTS_FAVOURITE = 'favourite-tags'
const UI_TAG_HIGHLIGHTS_UNDERAGE_CHARACTER = 'underage-characters'
const UI_TAG_HIGHLIGHTS_UNKNOWN_AGE_CHARACTER = 'unknown-age-characters'

const LEGACY_CONFIG_KEY_MAP = [
  ['Hide Rated Galleries', FILTER_RATED_VIDEOS],
  ['Enable Underage Females Filter', FILTER_UNDERAGE_CHARACTERS_OPTION],
  ['Hide Watched Galleries', FILTER_WATCHED_FROM_SEARCH],
  ['Page Range', UI_DEFAULTS_PAGE_RANGE],
  ['Enable Page Range Filter', UI_DEFAULTS_PAGE_RANGE_ENABLE],
  ['Rating', UI_DEFAULTS_RATING],
  ['Enable Rating Filter', UI_DEFAULTS_RATING_ENABLE],
  ['Tags', UI_DEFAULTS_TAGS],
  ['Enable Default Tags', UI_DEFAULTS_TAGS_ENABLE],
  ['Search Auto Next', UI_OPEN_GALLERIES_AUTO_NEXT],
  ['Gallery Auto Next', UI_OPEN_GALLERY_PAGES_AUTO_NEXT],
  ['Throttling Bypass', UI_OPEN_GALLERY_PAGES_THROTTLING],
  ['Download The Image', UI_OPEN_GALLERY_PAGES_DOWNLOAD],
  ['Embed Torrent Downloads', UI_EMBED_TORRENTS],
  ['Highlight Visited', UI_VISITED_HIGHLIGHT],
  ['Gallery Highlights', UI_GALLERY_HIGHLIGHTS],
  ['Highlight Colour', UI_GALLERY_HIGHLIGHTS_COLOUR],
  ['Adult Characters', UI_TAG_HIGHLIGHTS_ADULT_CHARACTER],
  ['Disliked Tags', UI_TAG_HIGHLIGHTS_DISLIKED],
  ['Favourite Tags', UI_TAG_HIGHLIGHTS_FAVOURITE],
  ['Underage Characters', UI_TAG_HIGHLIGHTS_UNDERAGE_CHARACTER],
  ['Unknown Age Characters', UI_TAG_HIGHLIGHTS_UNKNOWN_AGE_CHARACTER],
]

/**
 * @param {string[]} rules
 * @return {string[][]}
 */
function optimizeTagRulesAsTags(rules)
{
  let orTags, iteratedRuleset
  let optimizedRuleset = []

  let expandRuleset = (ruleset, tags) => {
    let grownRuleset = []
    for (let tag of tags) {
      let cleanedTag = tag.trim()
      for (let rule of ruleset) {
        grownRuleset.push([...rule, cleanedTag])
      }
    }
    return grownRuleset
  }

  let growRuleset = (ruleset, tagToAdd) => {
    if (ruleset.length) {
      tagToAdd = tagToAdd.trim()
      for (let rule of ruleset) {
        rule.push(tagToAdd)
      }
    } else {
      let tags = typeof tagToAdd === 'string' ? [tagToAdd] : tagToAdd
      for (let tag of tags) {
        ruleset.push([tag.trim()])
      }
    }
  }

  for (let rule of rules) {
    iteratedRuleset = []
    for (let andTag of rule.split(' // ')[0].split('&')) {
      orTags = andTag.split('|')
      if (orTags.length === 1) {
        growRuleset(iteratedRuleset, andTag)
      } else if (iteratedRuleset.length) {
        iteratedRuleset = expandRuleset(iteratedRuleset, orTags)
      } else {
        growRuleset(iteratedRuleset, orTags)
      }
    }
    optimizedRuleset = optimizedRuleset.concat(iteratedRuleset)
  }

  return optimizedRuleset.sort((a, b) => a.length - b.length)
}

class EHentaiSearchAndUITweaks extends BrazenFramework
{
constructor()
{
  super({
    downloadsDelay: 2,
    isUserLoggedIn: false,
    itemDeepAnalysisSelector: 'div.gm',
    itemLinkSelector: {
      extended: '.gl2e > div > a',
      compact: '.gl3c.glname > a',
      minimal: '.gl3m.glname > a',
      thumbnail: '.gl3t > a',
    },
    itemListSelectors: {
      extended: '.itg.glte > tbody',
      compact: '.itg.gltc > tbody',
      minimal: '.itg.gltm > tbody',
      thumbnail: '.itg.gld',
    },
    itemNameSelector: {
      extended: '.gl4e.glname > div.glink',
      compact: '.gl3c.glname > a > div.glink',
      minimal: '.gl3m.glname > a > div.glink',
      thumbnail: '.gl4t.glname',
    },
    itemSelectors: {
      extended: 'tr',
      compact: 'tr',
      minimal: 'tr',
      thumbnail: 'div.gl1t',
    },
    itemSelectionMethod: 'children',
    requestDelay: 0,
    scriptPrefix: 'e-hentai-ux-',
    tagSelectorGenerator: (tag) => {
      tag = tag.trim()
      if (this.isPage('gallery')) {
        let tagAttribute = tag.replaceAll(' ', '_')
        return 'div[id="td_' + tagAttribute + '"]'
      }
      return 'div.gt[title="' + tag + '"], div.gtl[title="' + tag + '"]'
    },
  })

  this.definePages({
    smallWindow: () => $('.stuffbox').length > 0,
    search: {
      detect: () => $('#f_search').length > 0,
      layout: () => {
        if ($('table.itg.glte').length > 0) {
          return 'extended'
        }
        if ($('table.itg.gltm').length > 0) {
          return 'minimal'
        }
        if ($('table.itg.gltc').length > 0) {
          return 'compact'
        }
        if ($('div.itg.gld').length > 0) {
          return 'thumbnail'
        }
        return null
      },
    },
    tagSearch: () => location.pathname.startsWith('/tag'),
    uploaderSearch: () => location.pathname.startsWith('/uploader'),
    watched: () => document.querySelectorAll('.ido > div > p.ip')?.length > 0,
    gallery: () => $('#gdt').length > 0,
    image: () => location.pathname.startsWith('/s/'),
  })

  this.setCompliancePages(['search'])

  this._onValidateInit = () => !this.isPage('smallWindow')

  this._setupFeatures()
  this._setupFilters()
  this._onBeforeFullInit.unshift(() => {
    this._migrateLegacyConfigKeys()
    // Phase 0 resolves layout-map itemLinkSelector on _config; the Item Attributes Resolver
    // still holds the raw map from construct time — sync so deep fetch can find hrefs.
    if (this._config.itemLinkSelector !== undefined) {
      this._itemAttributesResolver._itemLinkSelector = this._config.itemLinkSelector
    }
    this._registerItemTagAttribute()
  })
  this._onBeforeFullInit.push(() => {
    this._setupUI()
    this._setupEvents()
  })
}

/**
 * @private
 */
_migrateLegacyConfigKeys()
{
  let settingsKey = this._config.scriptPrefix + 'settings'
  let storedStore = localStorage.getItem(settingsKey)
  if (!storedStore || storedStore === '') {
    return
  }

  let settings
  try {
    settings = JSON.parse(storedStore)
  } catch {
    return
  }
  if (!settings || typeof settings !== 'object') {
    return
  }

  let changed = false
  for (let [oldKey, newKey] of LEGACY_CONFIG_KEY_MAP) {
    if (settings[oldKey] !== undefined && settings[newKey] === undefined) {
      settings[newKey] = settings[oldKey]
      delete settings[oldKey]
      changed = true
    }
  }

  if (changed) {
    localStorage.setItem(settingsKey, JSON.stringify(settings))
  }
}

/**
 * Register tag attribute after page/layout detection.
 * Thumbnail and minimal search layouts have no on-page tag chips — deep-fetch gallery HTML.
 * @private
 */
_registerItemTagAttribute()
{
  let searchLayout = this.getLayout('search')
  let deepAttribute = searchLayout === 'thumbnail' || searchLayout === 'minimal'
  this._addItemTagAttribute(
      ITEM_TAGS,
      deepAttribute,
      false,
      (item) => this._gatherItemTags(item))
}

/**
 * @param {string} configKey
 * @return {string[][]}
 * @private
 */
_getTagOnlyRulesFromField(configKey)
{
  let field = this._configurationManager.getField(configKey)
  if (!field?.value?.length) {
    return []
  }
  let rules = field.value
  if (typeof rules === 'string') {
    rules = rules.split('\n').map((line) => line.trim()).filter(Boolean)
  }
  if (field.formatter) {
    rules = field.formatter(rules)
  }
  return optimizeTagRulesAsTags(rules)
}

/**
 * @return {string}
 * @private
 */
_resolveDownloadFolderFromTitle()
{
  return ((f) => (f.includes('|') ? (f.match(/^(\s*\[[^\]]+\])/)?.[1] || '') + f.split('|')[1] : f))(document.title.replace(' --- Porn', ''))
}

/**
 * @param {ItemTagHighlightsConfiguration} config
 * @protected
 */
_ageFilterHighlighter(config)
{
  this.registerHighlightStyleClass(config.styleClass)

  this._configurationManager.addTagRulesetField(config.configKey, true).
      setRows(config.rows ?? 5).
      setHelpText(config.helpText).
      setFormatter((rules) => this._morphCharacterNamesToTags(rules)).
      setSortRules(true)

  let highlightsHandler = (section) => this._paintAgeTagHighlights(section, config.configKey, config.styleClass, config.removeClasses)
  this._onItemShow.push((item) => highlightsHandler(item))
}

/**
 * Paint favourite/disliked tag-ruleset highlights (CSS selectors in field.optimized).
 * @param {JQuery} section
 * @param {string} configKey
 * @param {string} styleClass
 * @param {string} [removeClasses]
 * @private
 */
_paintTagRulesetHighlights(section, configKey, styleClass, removeClasses)
{
  let optimizedRuleset = this._configurationManager.getField(configKey)?.optimized
  if (!optimizedRuleset) {
    return
  }

  let ruleApplies, subjectTags
  for (let rule of optimizedRuleset) {
    ruleApplies = true
    subjectTags = section.find(rule.join(', '))

    for (let tagSelector of rule) {
      if (section.find(tagSelector).length === 0) {
        ruleApplies = false
        break
      }
    }
    if (ruleApplies) {
      subjectTags.addClass(styleClass)
      if (removeClasses !== undefined) {
        subjectTags.removeClass(removeClasses)
      }
    } else {
      subjectTags.removeClass(styleClass)
    }
  }
}

/**
 * Paint age character/parody pair highlights.
 * @param {JQuery} section
 * @param {string} configKey
 * @param {string} styleClass
 * @param {string} [removeClasses]
 * @private
 */
_paintAgeTagHighlights(section, configKey, styleClass, removeClasses)
{
  let optimized = this._configurationManager.getField(configKey)?.optimized
  if (!optimized) {
    return
  }

  let characterTag, characterTagExists, parodyTagExists
  for (let rule of optimized) {
    characterTag = section.find(rule[0])
    characterTagExists = characterTag.length === 1
    parodyTagExists = section.find(rule[1]).length === 1

    if (characterTagExists && parodyTagExists) {
      characterTag.addClass(styleClass)
      if (removeClasses !== undefined) {
        characterTag.removeClass(removeClasses)
      }
    }
  }
}

/**
 * Gallery `#taglist` chip highlights (wired after page detection — not in constructor).
 * @param {JQuery} taglist
 * @private
 */
_paintGalleryPageTagHighlights(taglist)
{
  if (!taglist?.length) {
    return
  }
  this._paintTagRulesetHighlights(taglist, UI_TAG_HIGHLIGHTS_FAVOURITE, 'favourite-tag')
  this._paintTagRulesetHighlights(taglist, UI_TAG_HIGHLIGHTS_DISLIKED, 'disliked-tag')
  this._paintAgeTagHighlights(taglist, UI_TAG_HIGHLIGHTS_UNDERAGE_CHARACTER, 'underage-tag', 'favourite-tag disliked-tag')
  this._paintAgeTagHighlights(taglist, UI_TAG_HIGHLIGHTS_UNKNOWN_AGE_CHARACTER, 'unknown-age-tag')
  this._paintAgeTagHighlights(taglist, UI_TAG_HIGHLIGHTS_ADULT_CHARACTER, 'adult-tag')
}

/**
 * Append synthetic `type:…` when real tags are already on the item (shallow path, or after deep load).
 * Do not seed a type-only bag while deep fetch is pending — that races with deep overwrite.
 * @param {JQuery} item
 * @private
 */
_appendGalleryTypeTag(item)
{
  let bag = item[0]?.scriptAttributes
  if (!bag || !Array.isArray(bag.tags)) {
    return
  }

  let galleryType = item.find('.cn,.cs').first().text()
  let galleryTypeMap = new Map([
    ['Artist CG', 'acg'],
    ['Asian Porn', 'aporn'],
    ['Cosplay', 'cosplay'],
    ['Doujinshi', 'dj'],
    ['Game CG', 'gcg'],
    ['Image Set', 'img'],
    ['Manga', 'manga'],
    ['Misc', 'misc'],
    ['Non-H', 'nonh'],
    ['Western', 'west'],
  ])
  let mapped = galleryTypeMap.get(galleryType)
  if (!mapped) {
    return
  }

  let typeTag = 'type:' + mapped
  if (!bag.tags.includes(typeTag)) {
    bag.tags.push(typeTag)
  }
}

/**
 * @param {string} tag
 * @return {string}
 * @private
 */
_formatTag(tag)
{
  if (tag.includes(':') && !tag.includes('"') && (tag.includes(' ') || tag.includes('+'))) {
    tag = tag.replace(':', ':"') + '"'
  }
  return tag
}

/**
 *
 * @param {JQuery} item
 * @return {string[]}
 * @private
 */
_gatherItemTags(item)
{
  let tags = []
  item.find('.gt,.gtl').each((_i, e) => {
    let element = $(e)
    let title = element.attr('title')
    if (title) {
      tags.push(title)
      return
    }
    let tagID = element.find('a').attr('id') ?? element.attr('id')
    if (!tagID) {
      return
    }
    if (tagID.startsWith('ta_')) {
      tagID = tagID.replace('ta_', '')
    }
    if (tagID.startsWith('td_')) {
      tagID = tagID.replace('td_', '')
    }
    tags.push(tagID.replaceAll('_', ' '))
  })
  return tags
}

/**
 * @param {{}} range
 * @param {URLSearchParams} queryParams
 * @private
 */
_handleDefaultPageRangeFilter(range, queryParams)
{
  if (range.minimum > 0) {
    queryParams.set('f_spf', range.minimum)
  }
  if (range.maximum > 0) {
    queryParams.set('f_spt', range.maximum)
  }
}

/**
 * @param {string} rating
 * @param {URLSearchParams} queryParams
 * @private
 */
_handleDefaultRatingsFilter(rating, queryParams)
{
  queryParams.set('f_srdd', rating)
}

/**
 * @param {string[]} tags
 * @param {URLSearchParams} queryParams
 * @private
 */
_handleDefaultTags(tags, queryParams)
{
  let existingTags = queryParams.get('f_search')
  let updatedTags = existingTags
  let include = true

  for (let tag of tags) {
    if (existingTags.includes(tag)) {
      include = false
      break
    } else {
      updatedTags += '+' + this._formatTag(tag)
    }
  }

  if (include) {
    queryParams.set('f_search', updatedTags)
  }
}

/**
 * @private
 */
_handleDefaults()
{
  let queryParams = new URLSearchParams(location.search)
  let existingParams = queryParams.toString()

  if (!queryParams.has('next') &&
      (this._getConfig(UI_DEFAULTS_PAGE_RANGE_ENABLE) || this._getConfig(UI_DEFAULTS_RATING_ENABLE) || this._getConfig(UI_DEFAULTS_TAGS_ENABLE))) {

    if (!queryParams.has('f_search')) {

      let existingTag = ''
      let urlSegments = location.pathname.split('/')

      if (this.isPage('tagSearch')) {
        existingTag = urlSegments.pop().trim()
      } else if (this.isPage('uploaderSearch')) {
        existingTag = 'uploader:' + urlSegments.pop().trim()
      }
      queryParams.set('f_search', existingTag.length ? this._formatTag(existingTag) : '')
    }

    if (!queryParams.has('advsearch')) {
      queryParams.set('advsearch', '1')
    }

    let validatePageRange = (range, defaultValidator) => defaultValidator(range) && !queryParams.has('f_spf') &&
        !queryParams.has('f_spt')

    this._performTogglableComplexOperation(UI_DEFAULTS_PAGE_RANGE_ENABLE, UI_DEFAULTS_PAGE_RANGE, validatePageRange,
        (range) => {
          this._handleDefaultPageRangeFilter(range, queryParams)
        })

    let validateRatingFilter = (range, defaultValidator) => defaultValidator(range) && !queryParams.has('f_srdd')

    this._performTogglableComplexOperation(UI_DEFAULTS_RATING_ENABLE, UI_DEFAULTS_RATING, validateRatingFilter,
        (rating) => {
          this._handleDefaultRatingsFilter(rating, queryParams)
        })

    this._performTogglableOperation(UI_DEFAULTS_TAGS_ENABLE, UI_DEFAULTS_TAGS, (tags) => {
      this._handleDefaultTags(tags, queryParams)
    })

    let updatedParams = queryParams.toString().replaceAll('%2B', '+')
    if (updatedParams !== existingParams) {
      if (this.isPage('tagSearch') || this.isPage('uploaderSearch')) {
        location.href = location.origin + '?' + updatedParams
      } else {
        location.href = location.origin + location.pathname + '?' + updatedParams
      }
    }
  }
}

  /**
   * The site sets `#img.src` twice: first a decoy `*.hath.network/om/.../x/0/...` URL with no
   * `keystamp` token (404s), then the real `*.hath.network/h/...keystamp=...;xres=org/...` URL after
   * its P2P resolution. Only the latter is downloadable.
   *
   * @param {*} src
   * @return {boolean}
   * @private
   */
  _isValidImageSource(src)
  {
    // Real image URLs are the original (`hath.network/h/...keystamp=`) or a resample
    // (`hath.network/om/.../<width>/...`, e.g. `/1280/`). The downloader honeypot is an `/om/` URL
    // whose resample slot is the placeholder `/x/0/` (no real resample) — that one is the decoy and
    // must be skipped. Every other hath image URL is downloadable, including `/om/` resamples (which
    // never swap to a `/h/` URL, so we must NOT reject `/om/` wholesale).
    if (typeof src !== 'string' || !/hath\.network(:\d+)?\/(h|om)\//.test(src)) {
      return false
    }
    return !src.includes('/x/0/')
  }

  /**
   * Fires `callback` as soon as `#img` carries a valid (non-decoy) `src`, WITHOUT waiting for `load`.
   *
   * Resolving immediately is the whole point: the download must start the instant the tab opens (not
   * after the image finishes loading), which also keeps it reliable when many tabs are opened in quick
   * succession. `#img` may hold the real URL up front, or start as the `/x/0/` decoy and swap to the
   * real one; `_isValidImageSource` rejects the decoy, and a poll + observer catch the swap (the poll
   * is the reliable part — a `MutationObserver` alone can miss the update under heavy tab churn).
   *
   * @param {function(HTMLImageElement): void} callback
   * @private
   */
  _resolveImageForDownload(callback)
  {
    let resolved = false
    let observer = null
    let poll = null
    let timeout = null

    let cleanup = () => {
      observer?.disconnect()
      if (poll) {
        clearInterval(poll)
      }
      if (timeout) {
        clearTimeout(timeout)
      }
    }

    let attempt = () => {
      if (resolved) {
        return
      }
      let image = document.querySelector('#img')
      if (!(image instanceof HTMLImageElement) || !this._isValidImageSource(image.src)) {
        return
      }
      resolved = true
      cleanup()
      callback(image)
    }

    attempt()
    if (resolved) {
      return
    }

    observer = new MutationObserver(attempt)
    observer.observe(document.documentElement, {subtree: true, childList: true, attributes: true, attributeFilter: ['src']})
    poll = setInterval(attempt, 150)
    timeout = setTimeout(cleanup, 30000)
  }

  /**
   * @param {boolean} removeElement
   * @private
   */
  _handleDownloadMedia(removeElement)
  {
    this._resolveImageForDownload((image) => {
      let folder = this._resolveDownloadFolderFromTitle()
      let filename = image.src.split('/').pop()
      // Queue the download FIRST, while `#img` is still in the DOM with its valid `src`. `_addDownload`
      // fires `GM_download` synchronously (eager Promise executor), so the download starts immediately.
      // Guarded so a throw can't skip the node removal below.
      try {
        this._addDownload(image, folder, filename, false)
      } catch (error) {
        console.error('e-hentai download failed to enqueue:', error)
      }
      // Then remove the node DIRECTLY (plain detach, never `src=''`) so the image is not displayed and
      // its memory is freed in this tab. A direct detach does not trip `#img`'s `onerror -> nl()` retry.
      if (removeElement) {
        image.remove()
      }
    })
  }

/**
 * @return {string[][]}
 * @private
 */
_getFavouriteTagMatchRules()
{
  return this._getTagOnlyRulesFromField(UI_TAG_HIGHLIGHTS_FAVOURITE)
}

/**
 * @param {JQuery} item
 * @private
 */
_handleGalleryHighlights(item)
{
  let mode = this._getConfig(UI_GALLERY_HIGHLIGHTS)
  let itemHasHighlight = item.hasClass(STYLE_GALLERY_HIGHLIGHT)

  if (mode !== 'Disabled') {

    let itemTags = this._get(item, ITEM_TAGS), doHighlight = false, tag
    if (itemTags) {

      for (let rule of this._getFavouriteTagMatchRules()) {

        doHighlight = true
        for (let tag of rule) {

          if ((mode === 'All' && !itemTags.includes(tag)) ||
              (mode === 'Source' && ((!tag.startsWith('artist:') && !tag.startsWith('group:')) || !itemTags.includes(tag)))) {
            doHighlight = false
            break
          }
        }

        if (doHighlight) {
          if (!itemHasHighlight) {
            item.addClass(STYLE_GALLERY_HIGHLIGHT)
          }
          break
        }
      }

      if (!doHighlight && itemHasHighlight) {
        item.removeClass(STYLE_GALLERY_HIGHLIGHT)
      }
    }

  } else if (itemHasHighlight) {
    item.removeClass(STYLE_GALLERY_HIGHLIGHT)
  }
}

_handleOpenGalleries()
{
  let links = document.querySelectorAll('.' + CLASS_COMPLIANT_ITEM + ' > ' + this._config.itemLinkSelector)
  if (links.length > 0) {
    links.forEach(itemLink => window.open(itemLink.href))

    if (this._getConfig(UI_OPEN_GALLERIES_AUTO_NEXT)) {
      document.querySelector('#unext').click()
    }
  }
}

/**
 * @private
 */
async _handleOpenGalleryImages()
{
  let bypassThrottling = this._getConfig(UI_OPEN_GALLERY_PAGES_THROTTLING)
  let images = $('#gdt > a')
  let firstPageNumber = images.first().attr('href').split('-').pop()
  let maxPages = firstPageNumber + images.length - 1

  for (let page = images.length - 1; page >= 0; page--) {

    window.open(images.eq(page).attr('href'))

    if (bypassThrottling && page !== 0) {
      await Utilities.sleep(2000)
    }
  }

  if (this._getConfig(UI_OPEN_GALLERY_PAGES_AUTO_NEXT)) {

    let page = location.href.split('=')[1] ?? 0
    let pageNavs = $('.ptt td')

    maxPages = Number.parseInt(pageNavs.eq(pageNavs.length - 2).children('a').text()) - 1
    if (page < maxPages) {

      let uri = location.href
      if (page === 0) {
        uri += '?p=1'
      } else {
        uri = uri.replace('?p=' + page++, '?p=' + page)
      }
      location.href = uri
    }
  }
}

/**
 * @param {JQuery} item
 * @param {string} option
 * @private
 */
_handleRatedGalleries(item, option)
{
  let doesntComply
  switch (option) {
    case 'Blue':
      doesntComply = this._get(item, ITEM_RATED_BLUE)
      break
    case 'Green':
      doesntComply = this._get(item, ITEM_RATED_GREEN)
      break
    case 'Red':
      doesntComply = this._get(item, ITEM_RATED_RED)
      break
    case 'All':
      doesntComply = this._get(item, ITEM_RATED_BLUE) || this._get(item, ITEM_RATED_GREEN) || this._get(item, ITEM_RATED_RED)
      break
  }
  return !doesntComply
}

/**
 * @private
 */
_handleTorrentDownloadsEmbedding()
{
  let link = $('#gd5 > .g2 > a').eq(1)
  if (!link.text().endsWith('(0)')) {

    let container = $('<div class="gm"></div>').insertBefore('#cdiv')
    container.load(link.attr('onclick').replace('return popUp(\'', '').replace('\',610,590)', '') + ' form', () => {
      container.prepend('<h1 style="font-size:10pt; font-weight:bold; margin:3px; text-align:center">Torrents</h1>')
      link.parent().remove()
    })
  }
}

_handleUnderageFilter()
{
  this._configurationManager.addFlagField(FILTER_UNDERAGE_CHARACTERS_OPTION).
      setTitle('Enable Underage Females Filter').
      setHelpText('Applies the underage filter.')

  this._ageFilterHighlighter({
    configKey: UI_TAG_HIGHLIGHTS_UNDERAGE_CHARACTER,
    styleClass: 'underage-tag',
    removeClasses: 'favourite-tag disliked-tag',
    helpText: "Filter and mark character tags that are not adults in any canon timelines.\nFormat: [character tag] from [parody tag] // [comments]\nExample: lynae from wuthering waves",
  })

  this._ageFilterHighlighter({
    configKey: UI_TAG_HIGHLIGHTS_UNKNOWN_AGE_CHARACTER,
    styleClass: 'unknown-age-tag',
    helpText: "Specify characters which don't have officially declared age.\nFormat: [character tag] from [parody tag]\nExample: chiori from genshin impact",
  })

  this._ageFilterHighlighter({
    configKey: UI_TAG_HIGHLIGHTS_ADULT_CHARACTER,
    styleClass: 'adult-tag',
    helpText: "Specify characters checked to be adults.\nFormat: [character tag] from [parody tag] // [comments]\nExample: cantarella from wuthering waves",
  })

  this._addItemComplexComplianceFilter(
      UI_TAG_HIGHLIGHTS_UNDERAGE_CHARACTER,
      (rules) => this._getConfig(FILTER_UNDERAGE_CHARACTERS_OPTION) && rules.length,
      (item) => {
        let itemTags = this._get(item, ITEM_TAGS)

        if (itemTags !== null && itemTags.length) {
          for (let rule of this._getTagOnlyRulesFromField(UI_TAG_HIGHLIGHTS_UNDERAGE_CHARACTER)) {

            let isBlacklisted = true
            for (let tag of rule) {

              if (!itemTags.includes(tag)) {
                isBlacklisted = false
                break
              }
            }
            if (isBlacklisted) {
              return false
            }
          }
        }
        return true
      })

  this._configurationManager.getField(UI_TAG_HIGHLIGHTS_UNDERAGE_CHARACTER)?.setTitle('Underage Characters')
  this._configurationManager.getField(UI_TAG_HIGHLIGHTS_UNKNOWN_AGE_CHARACTER)?.setTitle('Unknown Age Characters')
  this._configurationManager.getField(UI_TAG_HIGHLIGHTS_ADULT_CHARACTER)?.setTitle('Adult Characters')
}

/**
 * @param {string[]} rules
 * @return {string[]}
 * @private
 */
_morphCharacterNamesToTags(rules)
{
  let morphedRules = [], tags
  for (let rule of rules) {
    tags = rule.split(' // ')[0].split(' from ')
    if (tags.length === 2) {
      morphedRules.push('character:' + tags[0] + ' & parody:' + tags[1])
    }
  }
  return morphedRules
}

/**
 * @private
 */
_setupEvents()
{
  this._onBeforeUIBuild.push(() => {

    this._performOperation(UI_VISITED_HIGHLIGHT, () => {
      GM_addStyle(`td.gl2e > div > a:visited > .glname > .glink {color: black;}`)
    })

    this._forPage('search', () => {
      this._handleDefaults(this._getConfig(UI_GALLERY_HIGHLIGHTS_COLOUR))
      GM_addStyle('.gallery-highlight,.gallery-highlight>td,.gallery-highlight>div{background-color:' + this._getConfig(UI_GALLERY_HIGHLIGHTS_COLOUR) + ' !important}.gallery-highlight{border:whitesmoke 2px solid}')
    })

    this._forPage('gallery', () => {
      this._paintGalleryPageTagHighlights($('#taglist'))
    })

    this._forPage('image', () => {
      if (this._getConfig(UI_OPEN_GALLERY_PAGES_DOWNLOAD)) {
        // Disable the UI first so it is never built/embedded on an auto-download image page,
        // regardless of the (deferred) download wiring.
        this._disableUI = true
        this._handleDownloadMedia(true)
      }
    })
  })

  this._onAfterUIBuild.push(() => {

    this._uiGen.getSelectedSection()[0].userScript = this

    this._forPage('gallery', () => {
      this._performOperation(UI_EMBED_TORRENTS, () => this._handleTorrentDownloadsEmbedding())
    })
  })

  this._onFirstHitBeforeCompliance.push((item) => this._appendGalleryTypeTag(item))
  this._onBeforeCompliance.push((item) => this._appendGalleryTypeTag(item))

  this._onItemHide = (item) => {
    if (item.is('td.gl2e')) {
      item.parent().addClass('noncompliant-item')
      item.parent().hide()
    } else {
      item.removeClass('noncompliant-item')
      item.hide()
    }
  }

  this._onItemShow.push((item) => {
    if (item.is('td.gl2e')) {
      item.parent().removeClass('noncompliant-item')
      item.parent().show()
    } else {
      item.removeClass('noncompliant-item')
      item.show()
    }
  })

  this._forPage('search', () => {
    this._onItemShow.push((item) => this._handleGalleryHighlights(item))
  })
}

/**
 * @private
 */
_setupFeatures()
{
  this._configurationManager.addColorField(UI_GALLERY_HIGHLIGHTS_COLOUR).
      setTitle('Highlight Colour').
      setHelpText('Colour to highlight the galleries with. Requires refresh to change.')
  this._configurationManager.addFlagField(FILTER_WATCHED_FROM_SEARCH).
      setTitle('Hide Watched Galleries').
      setHelpText('Hides watched galleries from searches initiated other than the watched page.')
  this._configurationManager.addFlagField(UI_OPEN_GALLERIES_AUTO_NEXT).
      setTitle('Search Auto Next').
      setHelpText('Automatically navigates to the next page after opening all galleries.')
  this._configurationManager.addFlagField(UI_OPEN_GALLERY_PAGES_AUTO_NEXT).
      setTitle('Gallery Auto Next').
      setHelpText('Automatically navigates to the next page after opening all images.')
  this._configurationManager.addFlagField(UI_OPEN_GALLERY_PAGES_DOWNLOAD).
      setTitle('Download The Image').
      setHelpText('Download image on a gallery page to a folder named after the gallery.')
  this._configurationManager.addFlagField(UI_OPEN_GALLERY_PAGES_THROTTLING).
      setTitle('Throttling Bypass').
      setHelpText('Paces the page opening logic to not trigger the site\'s throttling mechanism which limits resolution to 1280x.')
  this._configurationManager.addFlagField(UI_DEFAULTS_PAGE_RANGE_ENABLE).
      setTitle('Enable Page Range Filter').
      setHelpText('Always set these page limits in searches. Ignored if you set your own values on the page.')
  this._configurationManager.addFlagField(UI_DEFAULTS_RATING_ENABLE).
      setTitle('Enable Rating Filter').
      setHelpText('Enable default rating filter in searches')
  this._configurationManager.addFlagField(UI_DEFAULTS_TAGS_ENABLE).
      setTitle('Enable Default Tags').
      setHelpText('Enable default tags in searches.')
  this._configurationManager.addFlagField(UI_EMBED_TORRENTS).
      setTitle('Embed Torrent Downloads').
      setHelpText('Embed torrent downloads in gallery pages.')
  this._configurationManager.addFlagField(UI_VISITED_HIGHLIGHT).
      setTitle('Highlight Visited').
      setHelpText('Colours the visited gallery links black, to make them distinct.')
  this._configurationManager.addRadiosGroup(FILTER_RATED_VIDEOS, [
    ['Disabled', 'Disabled'],
    ['Red', 'Red'],
    ['Blue', 'Blue'],
    ['Green', 'Green'],
    ['All', 'All'],
  ]).
      setTitle('Hide Rated Galleries').
      setHelpText('Hides galleries rated by you with the colour set in site settings or all.')
  this._configurationManager.addRadiosGroup(UI_DEFAULTS_RATING, [
    ['2 stars', '2'],
    ['3 stars', '3'],
    ['4 stars', '4'],
    ['5 stars', '5'],
  ]).
      setTitle('Rating').
      setHelpText('Always set this rating filter in searches. Ignored if you set your own value on the page.')
  this._configurationManager.addRadiosGroup(UI_GALLERY_HIGHLIGHTS, [
    ['Disabled', 'Disabled'],
    ['All Favourite Tags', 'All'],
    ['Only Group / Artist Tags', 'Source'],
  ]).
      setTitle('Gallery Highlights').
      setHelpText('Highlights favourite galleries in search results with at least one matching tag.')
  this._configurationManager.addRangeField(UI_DEFAULTS_PAGE_RANGE, 0, 2000).
      setTitle('Page Range').
      setHelpText('Enable default page range filter in searches.')
  this._configurationManager.addRulesetField(UI_DEFAULTS_TAGS).
      setTitle('Tags').
      setRows(4).
      setHelpText('Always add the following tags in search. Can be overridden with at least one tag present.')

  this._itemAttributesResolver.
      addAttribute(ITEM_WATCHED, (item) => item.find('.gt[style],.gtl[style]').attr('style')?.startsWith('color:#f1f1f1') ?? false).
      addAttribute(ITEM_RATED_BLUE, (item) => item.find('.irb').length > 0).
      addAttribute(ITEM_RATED_GREEN, (item) => item.find('.irg').length > 0).
      addAttribute(ITEM_RATED_RED, (item) => item.find('.irr').length > 0)
}

_setupFilters()
{
  // Gallery `#taglist` highlights are painted in `_setupEvents` after page detection
  // (`isPage('gallery')` is false during the constructor).

  this._addItemComplexComplianceFilter(
      FILTER_RATED_VIDEOS,
      (option) => option !== 'Disabled',
      (item, option) => this._handleRatedGalleries(item, option))

  this._addItemComplexComplianceFilter(
      FILTER_WATCHED_FROM_SEARCH,
      (enabled) => !this.isPage('gallery') && !this.isPage('watched') && enabled,
      (item) => !this._get(item, ITEM_WATCHED))

  this._addItemTagHighlights({
    configKey: UI_TAG_HIGHLIGHTS_FAVOURITE,
    styleClass: 'favourite-tag',
    rows: 10,
    helpText: 'Specify favourite tags to highlight.',
  })
  this._configurationManager.getField(UI_TAG_HIGHLIGHTS_FAVOURITE)?.setTitle('Favourite Tags')

  this._addItemTagHighlights({
    configKey: UI_TAG_HIGHLIGHTS_DISLIKED,
    styleClass: 'disliked-tag',
    rows: 10,
    helpText: 'Specify disliked tags to highlight.',
  })
  this._configurationManager.getField(UI_TAG_HIGHLIGHTS_DISLIKED)?.setTitle('Disliked Tags')

  this._addItemTagBlacklistFilter(ITEM_TAGS, false, 20)
  this._addItemBlacklistFilter('Hide galleries with specified phrases in their names.', 10)

  this._handleUnderageFilter()
}

/**
 * @private
 */
_setupUI()
{
  let pageSpecificButton, statistics = []
  if (this.isPage('gallery')) {

    pageSpecificButton = this._uiGen.createFormButton(
        'Open Gallery Images',
        'Opens all images on current page of this gallery.',
        () => this._handleOpenGalleryImages())

  } else if (this.isPage('image')) {

    pageSpecificButton = this._uiGen.createFormButton(
        'Download Image',
        'Downloads the image on the page.',
        () => this._handleDownloadMedia(false))

  } else {

    pageSpecificButton = this._uiGen.createFormButton(
        'Open All Galleries',
        'Opens all galleries on current page.',
        () => this._handleOpenGalleries())
    statistics = [
      this._uiGen.createStatisticsFormGroup(FILTER_TEXT_BLACKLIST, 'Text Blacklist'),
      this._uiGen.createStatisticsFormGroup(FILTER_TAG_BLACKLIST, 'Tag Blacklist'),
      this._uiGen.createStatisticsFormGroup(FILTER_RATED_VIDEOS, 'Hide Rated Galleries'),
      this._uiGen.createStatisticsFormGroup(UI_TAG_HIGHLIGHTS_UNDERAGE_CHARACTER, 'Underage Characters'),
      this.isPage('watched') ? '' : this._uiGen.createStatisticsFormGroup(FILTER_WATCHED_FROM_SEARCH, 'Hide Watched Galleries'),
    ]
  }

  this._userInterface = [
    this._uiGen.createTabsSection(['Filters', 'Filters 2', 'Underage Filter', 'Galleries', 'Tag Highlights', 'Search Defaults', 'Extras'], [
      this._uiGen.createTabPanel('Filters', true).append([
        this._configurationManager.createElement(FILTER_WATCHED_FROM_SEARCH),
        this._configurationManager.createElement(OPTION_ENABLE_TAG_BLACKLIST),
        this._configurationManager.createElement(FILTER_TAG_BLACKLIST),
        this._configurationManager.createElement(OPTION_DISABLE_COMPLIANCE_VALIDATION),
      ]),
      this._uiGen.createTabPanel('Filters 2').append([
        this._configurationManager.createElement(FILTER_RATED_VIDEOS),
        this._uiGen.createSeparator(),
        this._configurationManager.createElement(OPTION_ENABLE_TEXT_BLACKLIST),
        this._configurationManager.createElement(FILTER_TEXT_BLACKLIST),
      ]),
      this._uiGen.createTabPanel('Galleries').append([
        this._uiGen.createTitle('Open Galleries'),
        this._configurationManager.createElement(UI_OPEN_GALLERIES_AUTO_NEXT),
        this._uiGen.createSeparator(),
        this._uiGen.createTitle('Open Images'),
        this._configurationManager.createElement(UI_OPEN_GALLERY_PAGES_AUTO_NEXT),
        this._configurationManager.createElement(UI_OPEN_GALLERY_PAGES_THROTTLING),
        this._uiGen.createSeparator(),
        this._uiGen.createTitle('Image Pages'),
        this._configurationManager.createElement(UI_OPEN_GALLERY_PAGES_DOWNLOAD),
        this._uiGen.createSeparator(),
        this._configurationManager.createElement(UI_GALLERY_HIGHLIGHTS),
        this._uiGen.createBreakSeparator(),
        this._uiGen.createBreakSeparator(),
        this._configurationManager.createElement(UI_GALLERY_HIGHLIGHTS_COLOUR),
      ]),
      this._uiGen.createTabPanel('Tag Highlights').append([
        this._configurationManager.createElement(UI_TAG_HIGHLIGHTS_FAVOURITE),
        this._configurationManager.createElement(UI_TAG_HIGHLIGHTS_DISLIKED),
      ]),
      this._uiGen.createTabPanel('Search Defaults').append([
        this._configurationManager.createElement(UI_DEFAULTS_PAGE_RANGE_ENABLE),
        this._configurationManager.createElement(UI_DEFAULTS_PAGE_RANGE),
        this._uiGen.createSeparator(),
        this._configurationManager.createElement(UI_DEFAULTS_RATING),
        this._uiGen.createBreakSeparator(),
        this._configurationManager.createElement(UI_DEFAULTS_RATING_ENABLE),
        this._uiGen.createSeparator(),
        this._configurationManager.createElement(UI_DEFAULTS_TAGS_ENABLE),
        this._configurationManager.createElement(UI_DEFAULTS_TAGS),
      ]),
      this._uiGen.createTabPanel('Underage Filter').append([
        this._configurationManager.createElement(FILTER_UNDERAGE_CHARACTERS_OPTION),
        this._configurationManager.createElement(UI_TAG_HIGHLIGHTS_UNDERAGE_CHARACTER),
        this._configurationManager.createElement(UI_TAG_HIGHLIGHTS_UNKNOWN_AGE_CHARACTER),
        this._configurationManager.createElement(UI_TAG_HIGHLIGHTS_ADULT_CHARACTER),
      ]),
      this._uiGen.createTabPanel('Extras').append([
        this._configurationManager.createElement(UI_EMBED_TORRENTS),
        this._configurationManager.createElement(UI_VISITED_HIGHLIGHT),
        this._configurationManager.createElement(OPTION_ALWAYS_SHOW_SETTINGS_PANE),
        this._uiGen.createSeparator(),
        this._createSettingsBackupRestoreFormActions(),
      ]),
    ]),
    this._uiGen.createBottomSection([
      ...statistics,
      this._uiGen.createSeparator(),
      pageSpecificButton,
      this._uiGen.createSeparator(),
      this._createSettingsFormActions(),
    ]),
  ]
}
}

void (new EHentaiSearchAndUITweaks).init()