Brazen Framework - Download Manager

Cross-tab download queue, resolution pipeline, and immediate downloads for Brazen user scripts

このスクリプトは単体で利用できません。右のようなメタデータを含むスクリプトから、ライブラリとして読み込まれます: // @require https://update.greasyfork.org/scripts/587126/1877284/Brazen%20Framework%20-%20Download%20Manager.js

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

Advertisement:

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

Advertisement:

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         Brazen Framework - Download Manager
// @namespace    brazenvoid
// @version      1.1.3
// @author       brazenvoid
// @license      GPL-3.0-only
// @description  Cross-tab download queue, resolution pipeline, and immediate downloads for Brazen user scripts
// @grant        GM_download
// ==/UserScript==

const OPTION_ENABLE_DOWNLOAD_MANAGER = 'enable-download-manager'
const OPTION_DOWNLOAD_SELECTION_MODE_DEFAULT = 'download-selection-mode-default'
const DOCK_TAG_DISCOVERY_MODE = 'dock-tag-discovery-mode'
const DOCK_SELECTION_MODE = 'dock-selection-mode'
const DOCK_ADD_TO_QUEUE = 'dock-add-to-queue'
const DOCK_DOWNLOAD_START_PAUSE = 'dock-download-start-pause'
const DOCK_CLEAR_DOWNLOAD_QUEUE = 'dock-clear-download-queue'

const RESOLUTION_TERMINAL = new Set(['failed', 'done'])
const DOWNLOAD_TERMINAL = new Set(['done', 'skipped', 'duplicate', 'failed'])

/** Leader heartbeat interval while this tab owns {@link processingTabId}. */
const PROCESSING_HEARTBEAT_INTERVAL_MS = 3000
/** Steal the processor lock if the owner has not heartbeated within this window (covers refresh / crash). */
const PROCESSING_LOCK_STALE_MS = 12000

class BrazenDownloadManager
{
  // -------------------------------------------------------------------------
  // Static public methods
  // -------------------------------------------------------------------------

  /**
   * True when this script's shared human-interaction block is mirrored in localStorage.
   * Usable in Phase-1 page ops before Download Manager initialize().
   * @param {string|null|undefined} scriptPrefix
   * @return {boolean}
   */
  static peekHumanInteractionBlockedMirror(scriptPrefix)
  {
    try {
      return localStorage.getItem((scriptPrefix ?? 'brazen-') + 'dm-human-interaction-blocked') === '1'
    } catch (e) {
      return false
    }
  }

  /**
   * True when shared DM state has `humanInteractionBlocked` (IndexedDB).
   * Usable in Phase-1 before Download Manager initialize() — survives CF stripping
   * `brazen_hi` and Chrome's default `noopener` on `window.open`.
   * @param {string|null|undefined} scriptPrefix
   * @return {Promise<boolean>}
   */
  static async peekHumanInteractionBlockedIdb(scriptPrefix)
  {
    let prefix = scriptPrefix ?? 'brazen-'
    try {
      if (typeof BrazenStorageRepositories !== 'function') {
        return false
      }
      let repos = new BrazenStorageRepositories(prefix)
      if (!repos.storage.available) {
        return false
      }
      await repos.storage.open()
      let state = await repos.downloadManagerState.get()
      return !!state?.humanInteractionBlocked
    } catch (e) {
      return false
    }
  }

  /**
   * True when the URL still carries a queue-verification marker (`brazen_hi` query or hash).
   * @return {boolean}
   */
  static hasBrazenHumanInteractionMarker()
  {
    try {
      let liveParams = new URLSearchParams(location.search)
      if (liveParams.get('brazen_hi') === '1') {
        return true
      }
      let hash = location.hash || ''
      if (/(^|[&#?])brazen_hi=1\b/.test(hash) || hash === '#brazen_hi') {
        return true
      }
    } catch (e) {
      // ignore
    }
    return false
  }

  /**
   * True when this tab was opened by same-origin `window.open` / `<a rel="opener">`
   * (queue verification). Chrome's default `noopener` on `window.open(_blank)` breaks this
   * unless the opener used {@link BrazenDownloadManager#_openHumanInteractionTab}.
   * @return {boolean}
   */
  static isQueueOpenedVerificationTab()
  {
    try {
      let opener = window.opener
      if (!opener || opener.closed) {
        return false
      }
      void opener.location.href
      return true
    } catch (e) {
      return false
    }
  }

  /**
   * Whether a mediaCloudflare / challenge page should stay silent (queue owns the leader pane).
   * Fail closed toward silence. Usable in Phase-1 before `initialize()`.
   * @param {string|null|undefined} scriptPrefix
   * @param {BrazenDownloadManager|null|undefined} [instance] optional live instance for sync state
   * @return {Promise<boolean>}
   */
  static async shouldSilenceMediaCloudflarePrompt(scriptPrefix, instance = null)
  {
    if (BrazenDownloadManager.hasBrazenHumanInteractionMarker()) {
      return true
    }
    if (BrazenDownloadManager.peekHumanInteractionBlockedMirror(scriptPrefix)) {
      return true
    }
    if (instance?.isHumanInteractionBlockedSync?.()) {
      return true
    }
    if (BrazenDownloadManager.isQueueOpenedVerificationTab()) {
      return true
    }
    if (await BrazenDownloadManager.peekHumanInteractionBlockedIdb(scriptPrefix)) {
      return true
    }
    return false
  }

  // -------------------------------------------------------------------------
  // Constructor
  // -------------------------------------------------------------------------

  /**
   * @param {BrazenFramework} framework
   * @param {BrazenConfigurationManager} configurationManager
   * @param {object} config
   */
  constructor(framework, configurationManager, config)
  {
    this._framework = framework
    this._cm = configurationManager
    this._config = config
    this._tabId = Utilities.generateId('dm-tab-')
    this._processing = false
    /** @type {boolean} Set when `_runProcessors` is kicked while already running so the pass re-checks work. */
    this._processorsWakeRequested = false
    this._immediateQueue = []
    this._immediateProcessing = false
    this._selectionClickHandler = null
    this._selectionModeActive = false
    this._progressElement = null
    this._tagDiscoveryPanel = null
    this._humanInteractionPanel = null
    this._trackedItemElements = new Map()
    this._cachedResolutionCount = 0
    this._cachedDownloadCount = 0
    this._processingHeartbeatTimer = null
    this._resolutionGapMs = config.resolutionInitiationGapMs ?? 2000
    this._downloadGapMs = config.downloadInitiationGapMs ?? 2000
    /** @type {number} In-tab clock — avoids IDB lost-update races wiping the download gap. */
    this._lastDownloadInitiationAt = 0
    /** @type {number} */
    this._lastResolutionInitiationAt = 0
    /** @type {Promise<void>} Serializes DM state mutations that must re-read before write. */
    this._stateWriteTail = Promise.resolve()
    this._registerConfigFields()
    this._setupDock()
  }

  /**
   * @private
   */
  _registerConfigFields()
  {
    if (this._config.enabled !== true) {
      let enableKey = this._config.enableConfigKey ?? OPTION_ENABLE_DOWNLOAD_MANAGER
      if (!this._cm.getField(enableKey)) {
        this._cm.addFlagField(enableKey).
            setTitle('Enable Download Manager').
            setHelpText('Show batch download queue controls on the dock and allow selection-mode enqueue.').
            setDefault(!!this._config.enableDefault)
      }
    }

    let selectionDefaultKey = this._config.selectionModeDefaultConfigKey ?? OPTION_DOWNLOAD_SELECTION_MODE_DEFAULT
    if (!this._cm.getField(selectionDefaultKey)) {
      this._cm.addFlagField(selectionDefaultKey).
          setTitle('Start in Selection Mode').
          setHelpText(
              'On load, turn selection mode on for search pages that support batch enqueue. ' +
              'You can still toggle it off from the dock for this visit.',
          ).
          setDefault(false)
    }
  }

  /**
   * @return {Promise<void>}
   */
  async initialize()
  {
    await this._ensureState()
    this._selectionModeActive = this._shouldStartSelectionMode()
    try {
      sessionStorage.removeItem((this._cm._scriptPrefix ?? 'brazen-') + 'dm-selection')
    } catch (error) {
      // ignore unavailable sessionStorage
    }
    this._setupCrossTabSync()
    if (this._cm.canPersist()) {
      this._hydrateInitiationClocksFromState(await this._getState())
    }
    this._framework.onConfigurationChange((event) => {
      let source = event?.source
      // Queue / DM state / ledger puts fire on every processor step — UI updates are owned
      // by task-complete hooks, not by this broadcast.
      if (source === 'downloadResolutionQueue' || source === 'downloadQueue' ||
          source === 'downloadManagerState' || source === 'ledger') {
        return
      }
      this._refreshTagDiscoveryPanel()
      this._refreshDockProgress()
      this._refreshSelectionMarks()
      if (this.isDownloadPageRole('selection')) {
        this._bindSelectionHandlers()
      }
    })
    this._framework.onTagSubstitutionUiChange(() => {
      this._refreshTagDiscoveryPanel()
    })

    if (this.isDownloadPageRole('selection')) {
      this._bindSelectionHandlers()
    }

    await this._syncFromStorage()
    await this._tryClaimProcessingLeadership()
    if (this._getStateSync()?.humanInteractionBlocked) {
      void this._promptHumanInteractionResume()
    }
    await this._restoreTagDiscoveryPanelIfNeeded()
    if (this._shouldRunProcessors()) {
      void this._runProcessors()
    }
  }

  /**
   * @return {boolean}
   * @private
   */
  _shouldStartSelectionMode()
  {
    if (!this.isDownloadManagerEnabled() || !this.isDownloadPageRole('selection')) {
      return false
    }
    let key = this._config.selectionModeDefaultConfigKey ?? OPTION_DOWNLOAD_SELECTION_MODE_DEFAULT
    return !!this._framework._getConfig(key)
  }

  // -------------------------------------------------------------------------
  // Public API
  // -------------------------------------------------------------------------

  /**
   * @param {{mediaElement: HTMLElement, removeMediaOnSuccess?: boolean, source?: string}} options
   * @return {Promise<void>}
   */
  async downloadImmediate(options)
  {
    if (!this.isDownloadPageRole('immediateDownload') && options.source !== 'immediate') {
      return
    }

    let paths = this._config.downloadPaths
    let mediaElement = options.mediaElement
    let data = paths.extractMediaData(mediaElement)
    let tagGroups = paths.extractTagGroups()
    let tagIncidences = typeof paths.extractTagIncidences === 'function' ?
        (paths.extractTagIncidences() ?? {}) :
        {}
    let downloadId = this._framework._getDownloadDuplicateLedgerId(mediaElement)

    await this._registerResolvedTagGroups(tagGroups)

    if (this._isTagDiscoveryActive()) {
      let lists = await this._partitionDiscoveryTags(tagGroups, tagIncidences)
      if (lists.unknown.length) {
        let state = await this._getState()
        state.resolutionBlocked = true
        state.discoveryPanelTags = this._mergeDiscoveryTags(state.discoveryPanelTags, lists.unknown)
        state.discoveryPanelKnownTags = lists.known
        state.pendingImmediateDownload = {
          mediaElement,
          removeMediaOnSuccess: !!options.removeMediaOnSuccess,
          data,
          tagGroups,
          tagIncidences,
          downloadId,
          sourceUrl: location.href,
        }
        await this._putState(state)
        await this._showTagDiscoveryPanel(state.discoveryPanelTags, state.discoveryPanelKnownTags)
        return
      }
    }

    await this._executeImmediateDownload({
      mediaElement,
      removeMediaOnSuccess: !!options.removeMediaOnSuccess,
      data,
      tagGroups,
      downloadId,
    })
  }

  /**
   * Adds a post to the resolution queue when it is not already in the pipeline.
   * Dedupe checks both queues: resolution (pending/in-progress resolve) and download
   * (already resolved, waiting or downloading). Items already in the download queue
   * are never re-enqueued for resolution.
   *
   * @param {object} context
   * @return {Promise<boolean>} whether the item was added to the queue
   */
  async enqueueDownload(context)
  {
    let itemId = this._config.getQueueItemId(context)
    if (!itemId) {
      return false
    }
    if (await this.isQueued(itemId)) {
      return false
    }
    // Reset before enqueue so a prior session's completed counts do not inflate dock totals
    // (e.g. 4 prior + 1 new looking like a 5-item queue).
    await this._resetProgressCountersIfIdle()
    await this._clearTerminalQueueRows(itemId)

    let row = {
      itemId,
      downloadType: context.downloadType ?? this._getActivePageConfig()?.defaultDownloadType ?? 'post',
      sourceUrl: context.sourceUrl ?? '',
      status: 'queued',
      originTabId: this._tabId,
      resolveContext: context.resolveContext ?? {},
      pendingTagGroups: null,
      addedAt: Date.now(),
      error: null,
    }
    await this._repos().downloadResolutionQueue.put(row)
    await this._tryClaimProcessingLeadership()
    await this._syncFromStorage()
    if (this._shouldRunProcessors()) {
      void this._runProcessors()
    }
    return true
  }

  /**
   * @param {string} itemId
   * @return {Promise<void>}
   * @private
   */
  async _clearTerminalQueueRows(itemId)
  {
    let resolution = await this._repos().downloadResolutionQueue.get(itemId)
    if (resolution && RESOLUTION_TERMINAL.has(resolution.status)) {
      await this._repos().downloadResolutionQueue.remove(itemId)
    }
    let download = await this._repos().downloadQueue.get(itemId)
    if (download && DOWNLOAD_TERMINAL.has(download.status)) {
      await this._repos().downloadQueue.remove(itemId)
    }
  }

  /**
   * @param {string} itemId
   * @return {Promise<void>}
   */
  async dequeueDownload(itemId)
  {
    await this._repos().downloadResolutionQueue.remove(itemId)
    await this._repos().downloadQueue.remove(itemId)
    await this._syncFromStorage()
    this._refreshSelectionMarks()
  }

  /**
   * Whether a post is anywhere in the active download pipeline (either queue).
   * Resolution queue: queued, resolving, tagReview, etc.
   * Download queue: queued or downloading (already resolved — no re-resolution).
   *
   * @param {string} itemId
   * @return {Promise<boolean>}
   */
  async isQueued(itemId)
  {
    let resolution = await this._repos().downloadResolutionQueue.get(itemId)
    if (resolution && !RESOLUTION_TERMINAL.has(resolution.status)) {
      return true
    }
    let download = await this._repos().downloadQueue.get(itemId)
    return !!(download && !DOWNLOAD_TERMINAL.has(download.status))
  }

  /**
   * @return {Promise<void>}
   */
  async confirmTagDiscoveryMappings()
  {
    let state = await this._getState()
    if (!state.resolutionBlocked) {
      return
    }

    await this._confirmDiscoveryPanelTagTypes(state.discoveryPanelTags)

    if (state.pendingImmediateDownload) {
      let pending = state.pendingImmediateDownload
      state.resolutionBlocked = false
      state.discoveryPanelTags = null
      state.discoveryPanelKnownTags = null
      state.pendingImmediateDownload = null
      state.tagDiscoveryPanelTabId = null
      await this._putState(state)
      this._hideTagDiscoveryPanel()
      await this._executeImmediateDownload(pending)
      if (this._shouldRunProcessors()) {
        void this._runProcessors()
      }
      return
    }

    let itemId = state.resolutionBlockedItemId
    if (!itemId) {
      state.resolutionBlocked = false
      state.discoveryPanelTags = null
      state.discoveryPanelKnownTags = null
      state.tagDiscoveryPanelTabId = null
      await this._putState(state)
      this._hideTagDiscoveryPanel()
      return
    }

    let item = await this._repos().downloadResolutionQueue.get(itemId)
    if (!item?.pendingTagGroups) {
      state.resolutionBlocked = false
      state.resolutionBlockedItemId = null
      state.discoveryPanelTags = null
      state.discoveryPanelKnownTags = null
      state.tagDiscoveryPanelTabId = null
      await this._putState(state)
      this._hideTagDiscoveryPanel()
      return
    }

    await this._promoteToDownloadQueue(item, item.pendingTagGroups)
    item.status = 'queued'
    item.pendingTagGroups = null
    await this._repos().downloadResolutionQueue.remove(itemId)

    state.resolutionBlocked = false
    state.resolutionBlockedItemId = null
    state.discoveryPanelTags = null
    state.discoveryPanelKnownTags = null
    state.tagDiscoveryPanelTabId = null
    await this._putState(state)
    this._hideTagDiscoveryPanel()
    await this._syncFromStorage()
    this._refreshItemProgress(itemId)
    if (this._shouldRunProcessors()) {
      void this._runProcessors()
    }
  }

  /**
   * Skip the current resolution item waiting on tag review (do not promote to download;
   * do not confirm tag types so discovery can fire again later).
   * @return {Promise<void>}
   */
  async skipTagDiscoveryInclusion()
  {
    let state = await this._getState()
    if (!state.resolutionBlocked) {
      return
    }

    this._hideTagDiscoveryPanel()

    let itemId = state.resolutionBlockedItemId
    let clearImmediate = !!state.pendingImmediateDownload
    if (itemId) {
      await this._repos().downloadResolutionQueue.remove(itemId)
      // Clear progress on the element before untracking — `_renderItemProgress` no-ops
      // when the tile is already missing from `_trackedItemElements`.
      let element = this._trackedItemElements.get(String(itemId))
      this._untrackItemElement(itemId)
      if (element) {
        this._setSelectionMark(element, false)
        this._setItemProgress(element, null)
      }
    }

    // Serialize gate clear through `_withState` so a concurrent heartbeat put cannot
    // resurrect `resolutionBlocked` from a stale snapshot (raw `_putState` race).
    await this._withState((next) => {
      next.resolutionBlocked = false
      next.resolutionBlockedItemId = null
      next.discoveryPanelTags = null
      next.discoveryPanelKnownTags = null
      next.tagDiscoveryPanelTabId = null
      if (clearImmediate) {
        next.pendingImmediateDownload = null
      }
    })
    await this._syncFromStorage()
    if (this._shouldRunProcessors()) {
      void this._runProcessors()
    }
  }

  /**
   * Open the media page for the item currently under tag review.
   * @return {Promise<void>}
   */
  async openTagDiscoveryMedia()
  {
    let state = await this._getState()
    if (!state.resolutionBlocked) {
      return
    }
    let openUrl = state.pendingImmediateDownload?.sourceUrl ?? null
    if (!openUrl && state.resolutionBlockedItemId) {
      let item = await this._repos().downloadResolutionQueue.get(state.resolutionBlockedItemId)
      openUrl = item?.sourceUrl
          || item?.pendingTagGroups?.metadata?.sourceUrl
          || null
    }
    if (openUrl) {
      window.open(openUrl, '_blank')
    }
  }

  /**
   * @return {Promise<void>}
   */
  async toggleDownloadManagerPaused()
  {
    let state = await this._getState()
    if (state.humanInteractionBlocked) {
      void this._promptHumanInteractionResume()
      return
    }
    // Start/Pause only affects the download queue — never resolution.
    if ((await this._getPendingDownloadCount()) <= 0) {
      return
    }
    state.paused = !state.paused
    await this._putState(state)
    if (!state.paused) {
      await this._tryClaimProcessingLeadership()
    }
    await this._syncFromStorage()
    if (!state.paused) {
      void this._runProcessors()
    }
  }

  /**
   * Clears pending/in-progress download-queue rows only. Does not touch the resolution
   * queue, tag discovery, selection mode, or processor leadership.
   * @return {Promise<void>}
   */
  async clearDownloadQueue()
  {
    let state = await this._getState()
    state.paused = true
    state.completedDownloadCount = 0
    if (state.humanInteractionBlocked && state.humanInteractionContext === 'download') {
      this._hideHumanInteractionPanel()
      state.humanInteractionBlocked = false
      state.humanInteractionContext = null
      state.humanInteractionItemId = null
      state.humanInteractionPromptTabId = null
      state.humanInteractionOpenUrl = null
      this._writeHumanInteractionBlockMirror(false)
    }
    await this._putState(state)
    await this._repos().downloadQueue.clearAll()
    await this._syncFromStorage()
    this._refreshSelectionMarks()
    if (this._shouldRunProcessors()) {
      void this._runProcessors()
    }
  }

  /**
   * @return {Promise<{resolution: {current: number, total: number}, download: {current: number, total: number}}>}
   */
  async getDownloadManagerProgress()
  {
    let downloadRows = await this._repos().downloadQueue.listAll()
    let state = await this._getState()
    let downloadActive = downloadRows.filter((row) => !DOWNLOAD_TERMINAL.has(row.status))
    if (this._isTagDiscoveryActive()) {
      return {
        resolution: {current: 0, total: 0},
        download: {
          current: state.completedDownloadCount,
          total: state.completedDownloadCount + downloadActive.length,
        },
      }
    }
    let resolutionRows = await this._repos().downloadResolutionQueue.listAll()
    let resolutionActive = resolutionRows.filter((row) => !RESOLUTION_TERMINAL.has(row.status))
    return {
      resolution: {
        current: state.completedResolutionCount,
        total: state.completedResolutionCount + resolutionActive.length,
      },
      download: {
        current: state.completedDownloadCount,
        total: state.completedDownloadCount + downloadActive.length,
      },
    }
  }

  /**
   * @return {Promise<number>}
   * @private
   */
  async _getPendingDownloadCount()
  {
    let downloadRows = await this._repos().downloadQueue.listAll()
    return downloadRows.filter((row) => !DOWNLOAD_TERMINAL.has(row.status)).length
  }

  /**
   * @return {number}
   */
  getPendingResolutionCountSync()
  {
    return this._cachedResolutionCount ?? 0
  }

  /**
   * @return {Promise<number>}
   * @private
   */
  async _getPendingResolutionCount()
  {
    let resolutionRows = await this._repos().downloadResolutionQueue.listAll()
    return resolutionRows.filter((row) => !RESOLUTION_TERMINAL.has(row.status)).length
  }

  /**
   * Non-terminal items in both queues (item progress / internal pipeline checks).
   * @return {Promise<number>}
   */
  async getPendingPipelineCount()
  {
    return this._getPendingPipelineCount()
  }

  /**
   * @return {Promise<number>}
   * @private
   */
  async _getPendingPipelineCount()
  {
    let downloadRows = await this._repos().downloadQueue.listAll()
    let downloadCount = downloadRows.filter((row) => !DOWNLOAD_TERMINAL.has(row.status)).length
    let resolutionRows = await this._repos().downloadResolutionQueue.listAll()
    let resolutionCount = resolutionRows.filter((row) => !RESOLUTION_TERMINAL.has(row.status)).length
    return resolutionCount + downloadCount
  }

  /**
   * @return {number}
   */
  getPendingDownloadCountSync()
  {
    return this._cachedDownloadCount ?? 0
  }

  /**
   * @return {Promise<number>}
   */
  async getDownloadQueueCount()
  {
    let downloadRows = await this._repos().downloadQueue.listAll()
    let downloadCount = downloadRows.filter((row) => !DOWNLOAD_TERMINAL.has(row.status)).length
    if (this._isTagDiscoveryActive()) {
      return downloadCount
    }
    let resolutionRows = await this._repos().downloadResolutionQueue.listAll()
    let resolutionCount = resolutionRows.filter((row) => !RESOLUTION_TERMINAL.has(row.status)).length
    return resolutionCount + downloadCount
  }

  /**
   * @return {JQuery}
   */
  getOrCreateProgressSlot()
  {
    if (!this._progressElement?.length) {
      this._progressElement = BrazenViewLayer.createDownloadManagerProgressSlot()
    }
    return this._progressElement
  }

  /**
   * @return {Promise<void>}
   */
  async toggleTagDiscoveryMode()
  {
    if (!this._config.tagDiscovery) {
      return
    }
    let state = await this._getState()
    state.tagDiscoveryEnabled = !state.tagDiscoveryEnabled
    await this._putState(state)
    this._framework.refreshDockInterface(undefined, {layout: false})
  }

  /**
   * @return {void}
   */
  toggleSelectionMode()
  {
    if (!this.isDownloadPageRole('selection')) {
      return
    }
    this._selectionModeActive = !this._selectionModeActive
    this._framework.refreshDockInterface(undefined, {layout: false})
    this._bindSelectionHandlers()
    this._refreshSelectionMarks()
  }

  /**
   * @return {Promise<void>}
   */
  async toggleCurrentMediaQueued()
  {
    if (!this.isDownloadPageRole('enqueueMedia')) {
      return
    }
    let itemId = this._config.getQueueItemId({sourceUrl: location.href})
    if (!itemId) {
      return
    }
    if (await this.isQueued(itemId)) {
      await this.dequeueDownload(itemId)
      return
    }
    await this.enqueueDownload({
      itemId,
      sourceUrl: location.href,
      downloadType: this._getActivePageConfig()?.defaultDownloadType ?? 'post',
      resolveContext: {fromMediaPage: true},
    })
  }

  /**
   * @param {string} role
   * @return {boolean}
   */
  isDownloadPageRole(role)
  {
    let pageName = this._getActivePageName()
    if (!pageName) {
      return false
    }
    let pageConfig = this._config.pages?.[pageName]
    return !!(pageConfig?.roles?.includes(role))
  }

  /**
   * @return {boolean}
   */
  isDownloadManagerEnabled()
  {
    if (!this._cm.isDockActive()) {
      return false
    }
    if (this._config.enabled === true) {
      return true
    }
    let enableKey = this._config.enableConfigKey ?? OPTION_ENABLE_DOWNLOAD_MANAGER
    return !!this._framework._getConfig(enableKey)
  }

  /**
   * @return {boolean}
   */
  isDownloadManagerRunning()
  {
    if (!this.isDownloadManagerEnabled()) {
      return false
    }
    let state = this._getStateSync()
    if (!state) {
      return false
    }
    if (this._processing) {
      return true
    }
    if (state.humanInteractionBlocked || state.resolutionBlocked || state.pendingImmediateDownload) {
      return true
    }
    if (!state.resolutionBlocked && this.getPendingResolutionCountSync() > 0) {
      return true
    }
    if (!state.paused && this.getPendingDownloadCountSync() > 0) {
      return true
    }
    return false
  }

  /**
   * True when this tab owns processor leadership ({@link processingTabId}).
   * @return {boolean}
   */
  isDownloadManagerLeaderTab()
  {
    return this._getStateSync()?.processingTabId === this._tabId
  }

  /**
   * @param {string[]} lines
   * @param {function(string): string} normalizeToken
   * @return {{subject: string, replacement: string}[]}
   */
  parseDownloadTagSubstitutionLines(lines, normalizeToken)
  {
    return this._parseDownloadTagSubstitutionLines(lines, normalizeToken)
  }

  /**
   * @param {{subject: string, replacement: string}[]} rules
   * @return {Map<string, string>}
   */
  buildDownloadTagSubstitutionMap(rules)
  {
    return this._buildDownloadTagSubstitutionMap(rules)
  }

  // -------------------------------------------------------------------------
  // Path creation (moved from BrazenFramework)
  // -------------------------------------------------------------------------

  /**
   * @param {string} text
   * @return {string}
   */
  decodeHtmlEntities(text)
  {
    if (typeof text !== 'string' || text.length === 0) {
      return ''
    }
    if (!text.includes('&')) {
      return text
    }
    let textarea = document.createElement('textarea')
    textarea.innerHTML = text
    return textarea.value
  }

  /**
   * @param {string} segment
   * @return {string}
   */
  sanitizePathSegment(segment)
  {
    return this.decodeHtmlEntities(String(segment)).
        replace(/[<>:"/\\|?*]/g, '-').
        replace(/[.\s]+$/, '').
        trim()
  }

  /**
   * @param {string} folder
   * @param {string} name
   * @return {string}
   */
  buildDownloadPath(folder, name)
  {
    let folderPath = String(folder).split('/').
        map((segment) => this.sanitizePathSegment(segment)).
        filter((segment) => segment.length).
        join('/').
        substring(0, 120).
        replace(/[.\s/]+$/, '')

    let fileName = this.sanitizePathSegment(name) || 'media'
    return folderPath ? folderPath + '/' + fileName : fileName
  }

  /**
   * @param {{}} data
   * @param {{}} tagGroups
   * @return {string}
   */
  buildDownloadPathFromPatterns(data, tagGroups)
  {
    let paths = this._config.downloadPaths
    let resolver = paths.getPatternResolver()
    let filenamePattern = this._framework._getConfig(paths.filenamePatternConfigKey)
    let name = this._resolveDownloadPattern(filenamePattern, data, tagGroups, resolver)
    if (!name) {
      name = paths.nameFallback?.(data) ?? (data.md5 || data.id || 'media')
    }
    if (paths.appendExtension && data.ext) {
      name += '.' + data.ext
    }

    let root = this._framework._getConfig(paths.folderConfigKey) || paths.defaultFolder || ''
    let subfolderPattern = paths.subfolderPatternConfigKey ?
        this._framework._getConfig(paths.subfolderPatternConfigKey) :
        ''
    let subfolder = subfolderPattern ? this._resolveDownloadPattern(subfolderPattern, data, tagGroups, resolver) : ''
    let folder = subfolder ? root + '/' + subfolder : root
    return this.buildDownloadPath(folder, name)
  }

  /**
   * @param {string} pattern
   * @param {{}} data
   * @param {{}} tagGroups
   * @param {object} resolver
   * @return {string}
   * @private
   */
  _resolveDownloadPattern(pattern, data, tagGroups, resolver)
  {
    let resolved = pattern
    let chips = [...resolver.chips].sort((a, b) => b.label.length - a.label.length)
    for (let chip of chips) {
      let value
      if (resolver.tagTypes.includes(chip.token)) {
        value = this._resolveDownloadTagTypeTokenForPath(pattern, tagGroups[chip.token] ?? [], chip.token, resolver.ignore, resolver)
      } else {
        value = data[chip.token] ?? ''
      }
      resolved = resolved.replaceAll(chip.label, value)
    }
    return resolved.replace(/\s+/g, ' ').trim()
  }

  /**
   * @param {string} pattern
   * @param {string[]} tags
   * @param {string} type
   * @param {Set<string>} ignore
   * @param {object} resolver
   * @return {string}
   * @private
   */
  _resolveDownloadTagTypeTokenForPath(pattern, tags, type, ignore, resolver)
  {
    let value = this._joinTagsForDownloadPath(tags, type, ignore, resolver)
    if (!value && this._patternEmploysDownloadTagTypeToken(pattern, type, resolver.chips, resolver.unknownTypes)) {
      return resolver.unknownDefault
    }
    return value
  }

  /**
   * @param {string} pattern
   * @param {string} type
   * @param {{token: string, label: string}[]} chips
   * @param {Set<string>} unknownTypes
   * @return {boolean}
   * @private
   */
  _patternEmploysDownloadTagTypeToken(pattern, type, chips, unknownTypes)
  {
    if (!unknownTypes.has(type)) {
      return false
    }
    for (let chip of chips) {
      if (chip.token === type && pattern.includes(chip.label)) {
        return true
      }
    }
    return false
  }

  /**
   * @param {string[]} tags
   * @param {string|null} type
   * @param {Set<string>|null} ignore
   * @param {object} options
   * @return {string}
   * @private
   */
  _joinTagsForDownloadPath(tags, type, ignore, options)
  {
    if (options.tagRuntime?._warmed) {
      tags = options.tagRuntime.applyDownloadAttributesSync(tags, options.stripCharacterSeries)
    } else {
      tags = this._applyDownloadTagSubstitutions(tags, type, options.substitutions, options.stripCharacterSeries)
      if (ignore?.size) {
        tags = tags.filter((tag) => !ignore.has(tag))
      }
    }

    let seen = new Set()
    let unique = []
    for (let tag of tags) {
      let formatted = this._formatTagForDownloadPath(tag, type, options.stripCharacterSeries)
      if (!formatted || seen.has(formatted)) {
        continue
      }
      seen.add(formatted)
      unique.push(formatted)
    }
    return unique.
        sort((left, right) => left.localeCompare(right, undefined, {sensitivity: 'base'})).
        join(options.multiTagSeparator)
  }

  /**
   * @param {string[]} tags
   * @param {string|null} type
   * @param {Map<string, string>} substitutions
   * @param {boolean} stripCharacterSeries
   * @return {string[]}
   * @private
   */
  _applyDownloadTagSubstitutions(tags, type, substitutions, stripCharacterSeries)
  {
    if (!substitutions.size) {
      return tags
    }
    return tags.map((tag) => this._resolveDownloadTagSubstitution(tag, type, substitutions, stripCharacterSeries))
  }

  /**
   * @param {string} tag
   * @param {string|null} type
   * @param {Map<string, string>} substitutions
   * @param {boolean} stripCharacterSeries
   * @return {string}
   * @private
   */
  _resolveDownloadTagSubstitution(tag, type, substitutions, stripCharacterSeries)
  {
    if (substitutions.has(tag)) {
      return substitutions.get(tag)
    }
    if (type === 'character' && stripCharacterSeries) {
      let stripped = tag.replace(/_\([^)]*\)$/, '')
      if (stripped !== tag && substitutions.has(stripped)) {
        return substitutions.get(stripped)
      }
    }
    return tag
  }

  /**
   * @param {string} tag
   * @param {string|null} type
   * @param {boolean} stripCharacterSeries
   * @return {string}
   * @private
   */
  _formatTagForDownloadPath(tag, type, stripCharacterSeries)
  {
    let formatted = this.decodeHtmlEntities(tag)
    if (type === 'character' && stripCharacterSeries) {
      formatted = formatted.replace(/_\([^)]*\)$/, '')
    }
    return formatted.replaceAll('_', ' ')
  }

  /**
   * @param {string[]|{subject: string, replacement: string}[]} lines
   * @param {function(string): string} normalizeToken
   * @return {{subject: string, replacement: string}[]}
   * @private
   */
  _parseDownloadTagSubstitutionLines(lines, normalizeToken)
  {
    let rules = []
    for (let line of lines) {
      if (typeof line === 'object' && line?.subject && line?.replacement) {
        let subject = normalizeToken(line.subject)
        let replacement = normalizeToken(line.replacement)
        if (subject.length && replacement.length) {
          rules.push({subject, replacement})
        }
        continue
      }
      if (typeof line !== 'string') {
        continue
      }
      let match = line.match(/^(.+?)\s+-\s+(.+)$/)
      if (!match) {
        continue
      }
      let subject = normalizeToken(match[1])
      let replacement = normalizeToken(match[2])
      if (subject.length && replacement.length) {
        rules.push({subject, replacement})
      }
    }
    return rules
  }

  /**
   * @param {{subject: string, replacement: string}[]} rules
   * @return {Map<string, string>}
   * @private
   */
  _buildDownloadTagSubstitutionMap(rules)
  {
    let map = new Map()
    for (let rule of rules) {
      map.set(rule.subject, rule.replacement)
    }
    return map
  }

  // -------------------------------------------------------------------------
  // Download execution
  // -------------------------------------------------------------------------

  /**
   * @param {object} pending
   * @return {Promise<void>}
   * @private
   */
  async _executeImmediateDownload(pending)
  {
    let before = this._config.onBeforeDownload ?
        await Utilities.callEventHandler(this._config.onBeforeDownload, [{
          mediaElement: pending.mediaElement,
          removeMediaOnSuccess: pending.removeMediaOnSuccess,
        }], null) :
        null

    let mediaElement = before?.mediaElement ?? pending.mediaElement
    let downloadUrl = before?.downloadUrl ?? mediaElement?.src ?? pending.downloadUrl ?? ''
    let restoreOnFailure = before?.restoreOnFailure ?? pending.restoreOnFailure ?? false
    if (!downloadUrl) {
      return
    }

    let path = this.buildDownloadPathFromPatterns(pending.data, pending.tagGroups)
    let downloadId = pending.downloadId ?? this._framework._getDownloadDuplicateLedgerId(mediaElement)

    await this._framework._reloadDownloadDuplicateLedgerFromStorage()
    if (this._framework._isDownloadDuplicate(downloadId)) {
      this._framework._handleDuplicateDownloadSkipped({name: path, restorePictureOnFailure: restoreOnFailure})
      return
    }
    if (downloadId && this._framework._shouldClaimDownloadDuplicateLedger() &&
        !(await this._framework._claimDownloadDuplicateLedgerSlot(downloadId))) {
      this._framework._handleDuplicateDownloadSkipped({name: path, restorePictureOnFailure: restoreOnFailure})
      return
    }

    // Defer GM_download until the immediate queue paces — do not start downloads at push time.
    this._immediateQueue.push({
      name: path,
      element: null,
      url: downloadUrl,
      downloadId,
      restorePictureOnFailure: restoreOnFailure,
    })
    void this._processImmediateQueue()
  }

  /**
   * @param {{name: string|null, element: JQuery|null, url: string, downloadId?: string|null, restorePictureOnFailure?: boolean}} download
   * @return {Promise<void>}
   * @private
   */
  _wrapDownloadTask(download)
  {
    let path = download.name
    if (!path) {
      alert('Download failed: missing file path.')
      return Promise.resolve()
    }

    let conflictAction = this._framework._isDownloadDuplicateLedgerActive() ?
        DOWNLOAD_DUPLICATE_LEDGER_CONFLICT_ACTION :
        'uniquify'

    return new Promise((resolve) => {
      download.element?.remove()
      GM_download({
        url: download.url,
        name: path,
        conflictAction,
        onload: () => resolve(),
        onerror: (error) => {
          this._framework._handleDownloadFailed(download, error)
          resolve()
        },
      })
    })
  }

  /**
   * @return {Promise<void>}
   * @private
   */
  async _processImmediateQueue()
  {
    if (this._immediateProcessing) {
      return
    }
    this._immediateProcessing = true
    while (this._immediateQueue.length > 0) {
      await this._paceDownloadInitiation()
      let download = this._immediateQueue.shift()
      await this._wrapDownloadTask(download)
    }
    this._immediateProcessing = false
  }

  /**
   * @param {object} item
   * @return {Promise<void>}
   * @private
   */
  async _processDownloadItem(item)
  {
    try {
      // Status is already `downloading` from `_claimDownloadQueueItem`.

      let payload = item.resolvedPayload ?? {}
      let path = this.buildDownloadPathFromPatterns(payload.data ?? {}, payload.tagGroups ?? {})
      let downloadUrl = payload.mediaUrl ?? ''
      let downloadId = payload.downloadId ?? item.downloadId ?? item.itemId

      // Claim once per queue item. Retries after lock steal / requeue must not re-claim —
      // the ledger is reserved before GM_download (Tampermonkey often never fires onload).
      if (!item.ledgerClaimed) {
        await this._framework._reloadDownloadDuplicateLedgerFromStorage()
        if (this._framework._isDownloadDuplicate(downloadId)) {
          item.status = 'duplicate'
          await this._repos().downloadQueue.put(item)
          await this._incrementDownloadProgress()
          return
        }
        if (downloadId && this._framework._shouldClaimDownloadDuplicateLedger() &&
            !(await this._framework._claimDownloadDuplicateLedgerSlot(downloadId))) {
          item.status = 'duplicate'
          await this._repos().downloadQueue.put(item)
          await this._incrementDownloadProgress()
          return
        }
        if (this._framework._shouldClaimDownloadDuplicateLedger() && downloadId) {
          item.ledgerClaimed = true
          await this._repos().downloadQueue.put(item)
        }
      }

      if (!downloadUrl) {
        item.status = 'failed'
        item.error = 'Missing media URL'
        await this._repos().downloadQueue.put(item)
        await this._incrementDownloadProgress()
        return
      }

      try {
        await this._paceDownloadInitiation()
        await this._wrapDownloadTask({name: path, element: null, url: downloadUrl, downloadId})
        item.status = 'done'
      } catch (error) {
        let rateLimit = await this._handleRateLimit('download', error, {sourceUrl: downloadUrl, resolvingUrl: downloadUrl}, item)
        if (rateLimit === 'blocked') {
          item.status = 'queued'
          item.error = null
          await this._repos().downloadQueue.put(item)
          return
        }
        item.status = 'failed'
        item.error = String(error)
      }
      await this._repos().downloadQueue.put(item)
      await this._incrementDownloadProgress()
    } finally {
      await this._onDownloadTaskComplete(item.itemId)
    }
  }

  // -------------------------------------------------------------------------
  // Resolution pipeline
  // -------------------------------------------------------------------------

  /**
   * @param {string} url
   * @return {Promise<Document>}
   * @private
   */
  async _resolvePage(url)
  {
    return new Promise((resolve, reject) => {
      let rejectWithPayload = (xhr, fallbackMessage) => {
        let text = xhr?.responseText ?? ''
        let doc = text ? new DOMParser().parseFromString(text, 'text/html') : null
        reject(Object.assign(new Error(fallbackMessage), {
          status: xhr?.status ?? 0,
          doc,
          url,
        }))
      }
      $.ajax({
        url,
        dataType: 'html',
        timeout: 30000,
        xhrFields: {withCredentials: true},
      })
        .done((html, _textStatus, xhr) => {
          let text = typeof html === 'string' ? html : xhr.responseText ?? ''
          let doc = new DOMParser().parseFromString(text, 'text/html')
          // Keep status on reject so timedReload / humanInteraction detectors can use it;
          // still attach parsed HTML (site 429 / Cloudflare bodies often arrive as 200 or 4xx).
          if (xhr.status === 429) {
            reject(Object.assign(new Error('HTTP 429'), {status: 429, doc, url}))
            return
          }
          if (xhr.status >= 400) {
            reject(Object.assign(new Error('HTTP ' + xhr.status), {status: xhr.status, doc, url}))
            return
          }
          resolve(doc)
        })
        .fail((xhr, _status, error) => {
          rejectWithPayload(xhr, 'Page resolution failed: ' + url + ' — ' + error)
        })
    })
  }

  /**
   * @param {object} item
   * @return {Promise<void>}
   * @private
   */
  async _processResolutionItem(item)
  {
    try {
      // Status is already `resolving` from `_claimResolutionQueueItem`.
      let typeHandler = this._config.downloadTypes?.[item.downloadType]
      if (!typeHandler) {
        item.status = 'failed'
        item.error = 'Unknown download type: ' + item.downloadType
        await this._repos().downloadResolutionQueue.put(item)
        await this._incrementResolutionProgress()
        return
      }

      try {
        let ctx = {
          ...item.resolveContext,
          sourceUrl: item.sourceUrl,
          itemId: item.itemId,
          downloadType: item.downloadType,
          resolvingUrl: item.sourceUrl,
        }

        if (ctx.fromMediaPage && typeHandler.resolveFromMedia) {
          let resolved = await Utilities.callEventHandler(typeHandler.resolveFromMedia, [document, ctx], null)
          await this._handleResolvedPayload(item, resolved)
          return
        }

        let searchStep = typeHandler.resolveFromSearch ?
            await Utilities.callEventHandler(typeHandler.resolveFromSearch, [ctx], null) :
            {nextUrl: item.sourceUrl, downloadType: item.downloadType}
        let nextUrl = searchStep?.nextUrl ?? item.sourceUrl
        ctx.resolvingUrl = nextUrl

        let doc
        while (true) {
          // Pace every attempt (including timedReload retries and post–human-interaction
          // resume) so a long Cloudflare pause cannot leave the clock stale relative to
          // the next burst of resolves.
          await this._paceResolutionInitiation()
          try {
            doc = await this._resolvePage(nextUrl)
          } catch (error) {
            let rateLimit = await this._handleRateLimit('resolution', error, ctx, item)
            if (rateLimit === 'retry') {
              continue
            }
            if (rateLimit === 'blocked') {
              item.status = 'queued'
              await this._repos().downloadResolutionQueue.put(item)
              return
            }
            throw error
          }

          let rateLimit = await this._handleRateLimit('resolution', doc, ctx, item)
          if (rateLimit === 'retry') {
            continue
          }
          if (rateLimit === 'blocked') {
            item.status = 'queued'
            await this._repos().downloadResolutionQueue.put(item)
            return
          }
          break
        }

        if (typeHandler.resolveFromMedia) {
          let resolved = await Utilities.callEventHandler(typeHandler.resolveFromMedia, [doc, ctx], null)
          if (resolved?.childUrls?.length) {
            await this._fanOutChildUrls(item, resolved.childUrls, resolved.downloadType ?? 'post')
            item.status = 'done'
            await this._repos().downloadResolutionQueue.remove(item.itemId)
            await this._incrementResolutionProgress()
            return
          }
          await this._handleResolvedPayload(item, resolved)
          return
        }

        item.status = 'failed'
        item.error = 'No resolveFromMedia handler'
        await this._repos().downloadResolutionQueue.put(item)
        await this._incrementResolutionProgress()
      } catch (error) {
        item.status = 'failed'
        item.error = String(error?.message ?? error)
        await this._repos().downloadResolutionQueue.put(item)
        await this._incrementResolutionProgress()
      }
    } finally {
      await this._onResolutionTaskComplete(item.itemId)
    }
  }

  /**
   * @param {object} item
   * @param {object|null} resolved
   * @return {Promise<void>}
   * @private
   */
  async _handleResolvedPayload(item, resolved)
  {
    if (!resolved?.mediaUrl) {
      item.status = 'failed'
      item.error = 'Resolution produced no media URL'
      await this._repos().downloadResolutionQueue.put(item)
      await this._incrementResolutionProgress()
      return
    }

    let tagGroups = resolved.tagGroups ?? {}
    let tagIncidences = resolved.tagIncidences ?? {}
    await this._registerResolvedTagGroups(tagGroups)
    if (this._isTagDiscoveryActive() && this._shouldRunTagDiscovery('mediaPost')) {
      let lists = await this._partitionDiscoveryTags(tagGroups, tagIncidences)
      if (lists.unknown.length) {
        item.status = 'tagReview'
        item.pendingTagGroups = resolved
        await this._repos().downloadResolutionQueue.put(item)
        let state = await this._getState()
        state.resolutionBlocked = true
        state.resolutionBlockedItemId = item.itemId
        state.discoveryPanelTags = this._mergeDiscoveryTags(state.discoveryPanelTags, lists.unknown)
        state.discoveryPanelKnownTags = lists.known
        await this._putState(state)
        await this._showTagDiscoveryPanel(state.discoveryPanelTags, state.discoveryPanelKnownTags)
        return
      }
    }

    await this._promoteToDownloadQueue(item, resolved)
    await this._repos().downloadResolutionQueue.remove(item.itemId)
    await this._incrementResolutionProgress()
  }

  /**
   * @param {object} item
   * @param {object} resolved
   * @return {Promise<void>}
   * @private
   */
  async _promoteToDownloadQueue(item, resolved)
  {
    let row = {
      itemId: item.itemId,
      downloadType: item.downloadType,
      resolvedPayload: resolved,
      status: 'queued',
      downloadId: resolved.downloadId ?? null,
      ledgerClaimed: false,
      addedAt: Date.now(),
      error: null,
    }
    await this._repos().downloadQueue.put(row)
  }

  /**
   * @param {object} item
   * @param {string[]} childUrls
   * @param {string} childType
   * @return {Promise<void>}
   * @private
   */
  async _fanOutChildUrls(item, childUrls, childType)
  {
    for (let sourceUrl of childUrls) {
      let childId = this._config.getQueueItemId({sourceUrl})
      if (!childId) {
        continue
      }
      await this.enqueueDownload({
        itemId: childId,
        sourceUrl,
        downloadType: childType,
        resolveContext: {parentItemId: item.itemId},
      })
    }
  }

  // -------------------------------------------------------------------------
  // Rate limits
  // -------------------------------------------------------------------------

  /**
   * @param {'resolution'|'download'} context
   * @param {*} signal
   * @param {object} ctx
   * @param {object} item
   * @return {Promise<'retry'|'blocked'|false>}
   * @private
   */
  async _handleRateLimit(context, signal, ctx, item)
  {
    let handlers = this._config.rateLimitHandlers?.[context]
    if (!handlers) {
      return false
    }

    // Prefer humanInteraction over timedReload. Cloudflare challenges often arrive as
    // HTTP 429 with a CAPTCHA body — a bare status===429 must not auto-retry those.
    let payload = signal?.doc ?? signal

    if (handlers.humanInteraction) {
      let detect = handlers.humanInteraction.detect
      let matched = detect ?
          Utilities.callEventHandler(detect, [payload, ctx], false) :
          signal?.status === 429
      if (matched) {
        let openUrl = handlers.humanInteraction.openUrl?.(ctx) ?? ctx.resolvingUrl ?? ctx.sourceUrl
        await this._beginHumanInteractionRateLimit(context, item, openUrl)
        return 'blocked'
      }
    }

    if (handlers.timedReload) {
      let detect = handlers.timedReload.detect
      if (Utilities.callEventHandler(detect, [payload, ctx], false)) {
        let delayMs = typeof handlers.timedReload.delayMs === 'function' ?
            handlers.timedReload.delayMs(ctx) :
            handlers.timedReload.delayMs
        await Utilities.sleep(delayMs ?? 5000)
        return 'retry'
      }
    }

    return false
  }

  /**
   * @param {'resolution'|'download'} context
   * @param {object|null|undefined} item
   * @param {string|null|undefined} openUrl
   * @return {Promise<void>}
   * @private
   */
  async _beginHumanInteractionRateLimit(context, item, openUrl)
  {
    let openUrlMarked = openUrl ? this._markHumanInteractionOpenUrl(openUrl) : null
    // Sync mirror before window.open so Phase-1 mediaCloudflare on that tab stays silent.
    this._writeHumanInteractionBlockMirror(true)
    // Serialize through `_withState` so a concurrent heartbeat/progress put cannot wipe
    // initiation clocks (or clear this block) via a stale raw `_putState` snapshot.
    await this._withState((state) => {
      state.humanInteractionBlocked = true
      state.humanInteractionContext = context
      state.humanInteractionItemId = item?.itemId ?? null
      // Pin the confirm dialog to this processor tab before opening the verification
      // tab so that tab (and any later tabs) cannot claim the prompt on init/focus.
      state.humanInteractionPromptTabId = this._tabId
      state.humanInteractionOpenUrl = openUrlMarked
    })
    this._refreshAllItemProgress()
    // Show the dock panel while this tab is still focused. window.open usually steals
    // focus immediately, and a visibility gate would skip the panel until the user returns.
    await this._promptHumanInteractionResume({force: true})
    if (openUrlMarked) {
      this._openHumanInteractionTab(openUrlMarked)
    }
    await this._syncFromStorage()
  }

  /**
   * Marks verification URLs so the opened mediaCloudflare tab stays silent
   * (queue confirmation stays on the processor dock panel).
   * Query `brazen_hi=1` plus hash `#brazen_hi=1` — CF often replaceState-strips
   * search params but leaves the hash.
   * @param {string} url
   * @return {string}
   * @private
   */
  _markHumanInteractionOpenUrl(url)
  {
    try {
      let parsed = new URL(url, location.href)
      parsed.searchParams.set('brazen_hi', '1')
      if (!/(^|[&#])brazen_hi=1\b/.test(parsed.hash)) {
        parsed.hash = 'brazen_hi=1'
      }
      return parsed.href
    } catch {
      return url
    }
  }

  /**
   * Open the verification tab while keeping `window.opener` set.
   * Chrome 88+ treats `window.open(url, '_blank')` as `noopener` by default, which
   * made queue CF tabs look "standalone" and revived the old grey confirm dialog.
   * @param {string} url
   * @private
   */
  _openHumanInteractionTab(url)
  {
    let anchor = document.createElement('a')
    anchor.href = url
    anchor.target = '_blank'
    anchor.rel = 'opener'
    anchor.style.display = 'none'
    document.documentElement.appendChild(anchor)
    anchor.click()
    anchor.remove()
  }

  /**
   * @return {string}
   * @private
   */
  _humanInteractionBlockMirrorKey()
  {
    return (this._cm._scriptPrefix ?? 'brazen-') + 'dm-human-interaction-blocked'
  }

  /**
   * @param {boolean} blocked
   * @private
   */
  _writeHumanInteractionBlockMirror(blocked)
  {
    try {
      let key = this._humanInteractionBlockMirrorKey()
      if (blocked) {
        localStorage.setItem(key, '1')
      } else {
        localStorage.removeItem(key)
      }
    } catch (e) {
      // ignore quota / private-mode localStorage failures
    }
  }

  /**
   * @return {boolean}
   */
  isHumanInteractionBlockedSync()
  {
    return !!this._getStateSync()?.humanInteractionBlocked
  }

  /**
   * Phase-1 mediaCloudflare (or equivalent) page operation.
   * Queue verification tabs stay silent — the processor-leader dock pane owns confirm.
   * Standalone browsing shows the same themed dock pane with reload (never a grey modal).
   * @param {{title?: string, message?: string, confirmLabel?: string}} [options]
   * @return {Promise<void>}
   */
  async handleMediaCloudflarePage(options = {})
  {
    let scriptPrefix = this._cm?._scriptPrefix ?? 'brazen-'
    if (await BrazenDownloadManager.shouldSilenceMediaCloudflarePrompt(scriptPrefix, this)) {
      return
    }
    this._showStandaloneCloudflareReloadPanel(options)
  }

  /**
   * @return {Promise<boolean>}
   */
  async shouldSilenceMediaCloudflarePrompt()
  {
    let scriptPrefix = this._cm?._scriptPrefix ?? 'brazen-'
    return BrazenDownloadManager.shouldSilenceMediaCloudflarePrompt(scriptPrefix, this)
  }

  /**
   * Show the human-interaction dock panel on the processor-leader tab only.
   * Verification / other tabs must not show it.
   * @param {{force?: boolean}} [options] `force` skips the visibility gate (used when opening
   *   the verification window — focus will leave this tab immediately after).
   * @return {Promise<void>}
   * @private
   */
  async _promptHumanInteractionResume(options = {})
  {
    let state = await this._getState()
    if (!state?.humanInteractionBlocked) {
      return
    }
    if (!options.force && document.visibilityState !== 'visible') {
      return
    }
    if (!this._canOwnHumanInteractionPrompt(state)) {
      return
    }
    if (state.humanInteractionPromptTabId !== this._tabId) {
      state.humanInteractionPromptTabId = this._tabId
      await this._putState(state)
    }
    let context = state.humanInteractionContext ?? 'resolution'
    let handlers = this._config.rateLimitHandlers?.[context]?.humanInteraction ?? {}
    this._ensureHumanInteractionPanel()
    BrazenViewLayer.updateHumanInteractionPanelContent(this._humanInteractionPanel, {
      title: handlers.confirmTitle ?? 'Verification required',
      message: handlers.confirmMessage ??
          'Complete the verification in the new tab, then resume the queue here.',
      confirmLabel: handlers.confirmLabel ?? 'Done — resume',
      reopenLabel: handlers.reopenLabel ?? 'Reopen verification tab',
    })
    if (!BrazenViewLayer.isDockSlidePanelVisible(this._humanInteractionPanel)) {
      this._framework._showDockSlidePanel(this._humanInteractionPanel)
    } else {
      this._framework._syncDockPanelPosition()
    }
  }

  /**
   * @private
   */
  _ensureHumanInteractionPanel()
  {
    if (this._humanInteractionPanel) {
      return
    }
    this._humanInteractionPanel = BrazenViewLayer.createHumanInteractionPanel({
      onConfirm: () => { void this._confirmHumanInteractionResume() },
      onReopen: () => { void this._reopenHumanInteractionTab() },
    })
    BrazenViewLayer.appendToBody($(this._humanInteractionPanel))
  }

  /**
   * Themed dock pane on a browsed challenge page (not queue-driven). Same chrome as the
   * leader verification panel — never a grey modal overlay.
   * @param {{title?: string, message?: string, confirmLabel?: string}} [options]
   * @private
   */
  _showStandaloneCloudflareReloadPanel(options = {})
  {
    if (document.getElementById('bv-human-interaction-panel')) {
      return
    }
    let panel = BrazenViewLayer.createHumanInteractionPanel({
      showReopen: false,
      title: options.title ?? 'Cloudflare challenge',
      message: options.message ??
          'Complete the CAPTCHA on this page, then confirm to reload and continue.',
      confirmLabel: options.confirmLabel ?? 'Done — reload',
      onConfirm: () => location.reload(),
    })
    BrazenViewLayer.appendToBody($(panel))
    this._framework._showDockSlidePanel(panel)
  }

  /**
   * @return {Promise<void>}
   * @private
   */
  async _confirmHumanInteractionResume()
  {
    this._hideHumanInteractionPanel()
    await this._clearHumanInteractionBlock()
    if (this._shouldRunProcessors()) {
      void this._runProcessors()
    }
  }

  /**
   * Re-open the verification URL if the user closed that tab by mistake.
   * @return {Promise<void>}
   * @private
   */
  async _reopenHumanInteractionTab()
  {
    let state = await this._getState()
    let openUrl = state?.humanInteractionOpenUrl
    if (!openUrl) {
      return
    }
    this._openHumanInteractionTab(this._markHumanInteractionOpenUrl(openUrl))
  }

  /**
   * @private
   */
  _hideHumanInteractionPanel()
  {
    if (!this._humanInteractionPanel) {
      return
    }
    this._framework._hideDockSlidePanel(this._humanInteractionPanel)
  }

  /**
   * True when this tab may show the human-interaction panel — only the
   * processor-leader tab (where the resolution/download queue runs).
   * @param {object} state
   * @return {boolean}
   * @private
   */
  _canOwnHumanInteractionPrompt(state)
  {
    return state.processingTabId === this._tabId
  }

  /**
   * @return {Promise<void>}
   * @private
   */
  async _clearHumanInteractionBlock()
  {
    this._hideHumanInteractionPanel()
    this._writeHumanInteractionBlockMirror(false)
    let state = await this._getState()
    let context = state?.humanInteractionContext === 'download' ? 'download' : 'resolution'
    // Stamp only the blocked pipeline's clock so resolution (2s) and download (1s) gaps stay independent.
    this._stampInitiationClock(context, Date.now())
    await this._withState((next) => {
      next.humanInteractionBlocked = false
      next.humanInteractionContext = null
      next.humanInteractionItemId = null
      next.humanInteractionPromptTabId = null
      next.humanInteractionOpenUrl = null
      let now = Date.now()
      if (context === 'download') {
        next.lastDownloadInitiationAt = Math.max(next.lastDownloadInitiationAt ?? 0, now)
      } else {
        next.lastResolutionInitiationAt = Math.max(next.lastResolutionInitiationAt ?? 0, now)
      }
    })
    this._refreshAllItemProgress()
    await this._syncFromStorage()
    // Sync reloads `_cachedState` only — re-apply local stamp in case IDB lagged.
    this._stampInitiationClock(context, Date.now())
  }

  /**
   * Advance one pipeline's in-tab initiation clock to at least `at`.
   * @param {'resolution'|'download'} kind
   * @param {number} at
   * @private
   */
  _stampInitiationClock(kind, at)
  {
    let ts = at ?? Date.now()
    let localKey = kind === 'download' ? '_lastDownloadInitiationAt' : '_lastResolutionInitiationAt'
    let stateKey = kind === 'download' ? 'lastDownloadInitiationAt' : 'lastResolutionInitiationAt'
    this[localKey] = Math.max(this[localKey] ?? 0, ts)
    if (this._cachedState) {
      this._cachedState[stateKey] = Math.max(this._cachedState[stateKey] ?? 0, ts)
    }
  }

  // -------------------------------------------------------------------------
  // Tag discovery
  // -------------------------------------------------------------------------

  /**
   * Write extracted typed tag groups into the tag registry.
   * Discovery on → register-on-seen (`media`, lastSeenTypeEntryId only).
   * Discovery off → confirm types as-is (`resolution`, sets typeEntryId).
   *
   * @param {Record<string, string[]>|null|undefined} tagGroups
   * @return {Promise<void>}
   * @private
   */
  async _registerResolvedTagGroups(tagGroups)
  {
    if (!this._cm.canPersist() || !tagGroups || typeof tagGroups !== 'object') {
      return
    }
    let hasNames = Object.values(tagGroups).some((names) => Array.isArray(names) && names.some(Boolean))
    if (!hasNames) {
      return
    }
    let source = this._isTagDiscoveryActive() ? 'media' : 'resolution'
    await this._cm.registerTypedTagGroups(tagGroups, source)
  }

  /**
   * @return {boolean}
   */
  isTagDiscoveryEnabled()
  {
    return !!this._getStateSync()?.tagDiscoveryEnabled
  }

  /**
   * @return {boolean}
   * @private
   */
  _isTagDiscoveryActive()
  {
    return !!this._config.tagDiscovery && this.isTagDiscoveryEnabled()
  }

  /**
   * @param {string} step
   * @return {boolean}
   * @private
   */
  _shouldRunTagDiscovery(step)
  {
    return this._config.tagDiscovery?.discoverAt?.includes(step) ?? false
  }

  /**
   * Promote seen types to confirmed {@link TagEntry.typeEntryId} after discovery review.
   * @param {Array<{name: string, type: string|null}>|null} tags
   * @return {Promise<void>}
   * @private
   */
  async _confirmDiscoveryPanelTagTypes(tags)
  {
    if (!tags?.length) {
      return
    }
    let tagRuntime = this._cm.getTagRuntime()
    if (!tagRuntime) {
      return
    }
    for (let tag of tags) {
      let entry = await tagRuntime.getTag(tag.name)
      if (entry?.typeEntryId != null) {
        continue
      }
      let typeEntryId = entry?.meta?.lastSeenTypeEntryId ?? null
      if (typeEntryId == null && tag.type) {
        typeEntryId = await this._repos().tags.resolveCanonicalTypeEntryId(tag.type)
      }
      if (typeEntryId != null) {
        await tagRuntime.patchAttributes(tag.name, {typeEntryId})
      }
    }
    await tagRuntime.warmCache()
  }

  /**
   * @param {{}} tagGroups
   * @param {Record<string, number|string>|null|undefined} [tagIncidences]
   * @return {Promise<{unknown: Array<{name: string, type: string|null, count: number|null}>, known: Array<{name: string, type: string|null, count: number|null}>}>}
   * @private
   */
  async _partitionDiscoveryTags(tagGroups, tagIncidences = {})
  {
    let empty = {unknown: [], known: []}
    if (!this._config.tagDiscovery) {
      return empty
    }
    let resolver = this._config.downloadPaths.getPatternResolver()
    let patterns = this._getActiveDownloadPatterns()
    let relevantTypes = new Set()
    for (let chip of resolver.chips) {
      if (!resolver.tagTypes.includes(chip.token)) {
        continue
      }
      for (let pattern of patterns) {
        if (pattern.includes(chip.label)) {
          relevantTypes.add(chip.token)
        }
      }
    }

    let tagTypes = this._config.tagDiscovery.tagTypes ?? [...relevantTypes]
    if (!tagTypes.length) {
      return empty
    }
    let unknown = []
    let known = []
    let incidences = tagIncidences && typeof tagIncidences === 'object' ? tagIncidences : {}
    for (let type of tagTypes) {
      for (let tag of tagGroups[type] ?? []) {
        if (!tag) {
          continue
        }
        let count = this._resolveTagIncidence(tag, incidences)
        let entry = {name: tag, type, count}
        if (await this._isTagKnownForDiscovery(tag)) {
          known.push(entry)
        } else {
          unknown.push(entry)
        }
      }
    }
    return {unknown, known}
  }

  /**
   * @param {string} tagName
   * @param {Record<string, number|string>} incidences
   * @return {number|null}
   * @private
   */
  _resolveTagIncidence(tagName, incidences)
  {
    if (!incidences || typeof incidences !== 'object') {
      return null
    }
    let raw = incidences[tagName]
    if (raw == null) {
      let lower = String(tagName).toLowerCase()
      for (let [key, value] of Object.entries(incidences)) {
        if (String(key).toLowerCase() === lower) {
          raw = value
          break
        }
      }
    }
    if (raw == null || raw === '') {
      return null
    }
    let count = typeof raw === 'number' ? raw : parseInt(String(raw).replace(/[^\d]/g, ''), 10)
    return Number.isFinite(count) ? count : null
  }

  /**
   * @param {{}} tagGroups
   * @return {Promise<Array<{name: string, type: string|null, count: number|null}>|null>}
   * @private
   */
  async _findNewDiscoveryTags(tagGroups)
  {
    let lists = await this._partitionDiscoveryTags(tagGroups, {})
    return lists.unknown.length ? lists.unknown : null
  }

  /**
   * Whether a tag is registered with a resolved type ({@link TagRepository.typeEntryId} set).
   * Script {@link tagDiscovery.isTagKnown} overrides; default uses {@link TagRepository.getByName}.
   *
   * @param {string} tagName
   * @return {Promise<boolean>}
   * @private
   */
  async _isTagKnownForDiscovery(tagName)
  {
    if (this._config.tagDiscovery?.isTagKnown) {
      let result = Utilities.callEventHandler(this._config.tagDiscovery.isTagKnown, [tagName], false)
      return !!await result
    }
    if (!this._cm.canPersist()) {
      return false
    }
    let tag = await this._repos().tags.getByName(tagName)
    return !!(tag && tag.typeEntryId != null)
  }

  /**
   * @param {Array|null} existing
   * @param {Array} discovered
   * @return {Array}
   * @private
   */
  _mergeDiscoveryTags(existing, discovered)
  {
    let merged = [...(existing ?? [])]
    let indexByKey = new Map(merged.map((tag, index) => [tag.name + '\0' + (tag.type ?? ''), index]))
    for (let tag of discovered) {
      let key = tag.name + '\0' + (tag.type ?? '')
      if (indexByKey.has(key)) {
        let prior = merged[indexByKey.get(key)]
        if ((prior.count == null || prior.count === '') && tag.count != null && tag.count !== '') {
          prior.count = tag.count
        }
        continue
      }
      indexByKey.set(key, merged.length)
      merged.push(tag)
    }
    return merged
  }

  /**
   * @return {string[]}
   * @private
   */
  _getActiveDownloadPatterns()
  {
    let paths = this._config.downloadPaths
    let patterns = []
    if (paths.filenamePatternConfigKey) {
      patterns.push(this._framework._getConfig(paths.filenamePatternConfigKey) ?? '')
    }
    if (paths.subfolderPatternConfigKey) {
      patterns.push(this._framework._getConfig(paths.subfolderPatternConfigKey) ?? '')
    }
    return patterns
  }

  /**
   * @param {Array} tags
   * @param {Array|null|undefined} [knownTags]
   * @return {Promise<void>}
   * @private
   */
  async _showTagDiscoveryPanel(tags, knownTags = null)
  {
    if (document.visibilityState !== 'visible') {
      return
    }
    await this._claimTagDiscoveryPanel(async (state) => {
      if (state.tagDiscoveryPanelTabId && state.tagDiscoveryPanelTabId !== this._tabId) {
        return
      }
      state.tagDiscoveryPanelTabId = this._tabId
      if (knownTags != null) {
        state.discoveryPanelKnownTags = knownTags
      }
      await this._putState(state)
      this._renderTagDiscoveryPanel(tags, state.discoveryPanelKnownTags ?? knownTags ?? [])
    })
  }

  /**
   * @param {Function} callback
   * @return {Promise<void>}
   * @private
   */
  async _claimTagDiscoveryPanel(callback)
  {
    let state = await this._getState()
    if (state.tagDiscoveryPanelTabId && state.tagDiscoveryPanelTabId !== this._tabId && document.visibilityState !== 'visible') {
      return
    }
    await callback(state)
  }

  /**
   * @param {Array} tags
   * @param {Array|null|undefined} [knownTags]
   * @private
   */
  _renderTagDiscoveryPanel(tags, knownTags = null)
  {
    if (!this._config.tagDiscovery) {
      return
    }
    if (!this._tagDiscoveryPanel) {
      this._tagDiscoveryPanel = BrazenViewLayer.createTagDiscoveryPanel({
        onConfirm: () => { void this.confirmTagDiscoveryMappings() },
        onSkip: () => { void this.skipTagDiscoveryInclusion() },
        onOpenMedia: () => { void this.openTagDiscoveryMedia() },
      })
      BrazenViewLayer.appendToBody($(this._tagDiscoveryPanel))
    }
    let panelOptions = {
      ...(this._config.tagDiscovery?.panel ?? {}),
      knownTags: knownTags ?? this._getStateSync()?.discoveryPanelKnownTags ?? [],
    }
    BrazenViewLayer.renderTagDiscoveryPanelContent(
        this._tagDiscoveryPanel,
        tags,
        (row, tag, actionsElement) => {
          this._appendTagDiscoveryFrameworkActions(row, tag, actionsElement)
        },
        panelOptions,
    )
    if (BrazenViewLayer.isDockSlidePanelVisible(this._tagDiscoveryPanel)) {
      // Content-only refresh — keep the open panel still; only re-stack positions.
      this._framework._syncDockPanelPosition()
      return
    }
    this._framework._showDockSlidePanel(this._tagDiscoveryPanel)
  }

  /**
   * Framework owns tag-attribute actions; discovery panel maps them internally.
   * @param {HTMLElement} row
   * @param {{name: string, type: string|null}} tag
   * @param {HTMLElement} actionsElement
   * @private
   */
  _appendTagDiscoveryFrameworkActions(row, tag, actionsElement)
  {
    let discovery = this._config.tagDiscovery ?? {}
    let actions = discovery.actions ?? {}
    let $row = $(row)
    this._framework.appendTagAttributeActions(actionsElement, tag, {
      className: actions.className ?? discovery.actionButtonClass,
      iconClass: actions.iconClass ?? discovery.actionIconClass,
      ignoreClassName: actions.ignoreClassName,
      normalize: actions.normalize ?? discovery.normalizeTag,
      bookmark: actions.bookmark,
      ignore: actions.ignore,
      substitute: actions.substitute ?? {fieldKey: discovery.substitutionFieldKey ?? 'filename-tag-substitutions'},
      blacklist: actions.blacklist,
      explore: actions.explore,
    })
    let state = actions.getRowState?.(tag) ?? discovery.getRowState?.(tag)
    if (!state) {
      return
    }
    $row.
        toggleClass('bv-tag-muted', !!state.muted).
        toggleClass('bv-tag-hidden', !!state.hidden)
    for (let className of actions.extraMutedClasses ?? discovery.extraMutedClasses ?? []) {
      $row.toggleClass(className, !!state.muted)
    }
    for (let className of actions.extraHiddenClasses ?? discovery.extraHiddenClasses ?? []) {
      $row.toggleClass(className, !!state.hidden)
    }
  }

  /**
   * @private
   */
  _refreshTagDiscoveryPanel()
  {
    let state = this._getStateSync()
    if (!state?.resolutionBlocked || !state.discoveryPanelTags?.length || state.tagDiscoveryPanelTabId !== this._tabId) {
      return
    }
    // Never re-animate an already-open panel on config / action refreshes.
    this._renderTagDiscoveryPanel(state.discoveryPanelTags, state.discoveryPanelKnownTags ?? [])
  }

  /**
   * Re-render tag discovery panel rows (e.g. after bookmark/rule toggles).
   */
  refreshTagDiscoveryPanel()
  {
    this._refreshTagDiscoveryPanel()
  }

  /**
   * Re-open the tag discovery panel after reload / tab focus when review is still pending.
   * @return {Promise<void>}
   * @private
   */
  async _restoreTagDiscoveryPanelIfNeeded()
  {
    if (!this._isTagDiscoveryActive()) {
      return
    }
    let state = await this._getState()
    if (state.resolutionBlocked && state.discoveryPanelTags?.length) {
      await this._showTagDiscoveryPanel(state.discoveryPanelTags, state.discoveryPanelKnownTags ?? [])
      return
    }
    let resolutionRows = await this._repos().downloadResolutionQueue.listAll()
    let stuck = resolutionRows.find((row) => row.status === 'tagReview' && row.pendingTagGroups)
    if (!stuck) {
      return
    }
    let pending = stuck.pendingTagGroups
    let tagGroups = pending.tagGroups ?? pending
    let tagIncidences = pending.tagIncidences ?? {}
    let lists = await this._partitionDiscoveryTags(tagGroups, tagIncidences)
    state.resolutionBlocked = true
    state.resolutionBlockedItemId = stuck.itemId
    state.discoveryPanelTags = this._mergeDiscoveryTags(state.discoveryPanelTags, lists.unknown)
    state.discoveryPanelKnownTags = lists.known
    await this._putState(state)
    if (state.discoveryPanelTags?.length) {
      await this._showTagDiscoveryPanel(state.discoveryPanelTags, state.discoveryPanelKnownTags)
    }
  }

  /**
   * @private
   */
  _hideTagDiscoveryPanel()
  {
    if (!this._tagDiscoveryPanel) {
      return
    }
    this._framework._hideDockSlidePanel(this._tagDiscoveryPanel)
  }

  // -------------------------------------------------------------------------
  // Processors & sync
  // -------------------------------------------------------------------------

  /**
   * @param {object|null} state
   * @return {boolean}
   * @private
   */
  _isProcessingLockStale(state)
  {
    if (!state?.processingTabId) {
      return true
    }
    let lastBeat = state.processingHeartbeatAt ?? 0
    return (Date.now() - lastBeat) > PROCESSING_LOCK_STALE_MS
  }

  /**
   * Sync companion to IndexedDB `processingTabId`. Cleared on `pagehide` so a refresh can
   * steal immediately even when the async IDB release never finishes.
   * @return {string}
   * @private
   */
  _processingLockStorageKey()
  {
    return (this._cm._scriptPrefix ?? 'brazen-') + 'dm-processing-lock'
  }

  /**
   * @return {{tabId: string, at: number}|null}
   * @private
   */
  _readProcessingLockMirror()
  {
    try {
      let raw = localStorage.getItem(this._processingLockStorageKey())
      if (!raw) {
        return null
      }
      let parsed = JSON.parse(raw)
      if (!parsed?.tabId) {
        return null
      }
      return parsed
    } catch (e) {
      return null
    }
  }

  /**
   * @private
   */
  _writeProcessingLockMirror()
  {
    try {
      localStorage.setItem(this._processingLockStorageKey(), JSON.stringify({
        tabId: this._tabId,
        at: Date.now(),
      }))
    } catch (e) {
      // ignore quota / private-mode localStorage failures
    }
  }

  /**
   * @private
   */
  _clearProcessingLockMirrorIfOwned()
  {
    try {
      let key = this._processingLockStorageKey()
      let mirror = this._readProcessingLockMirror()
      if (mirror?.tabId === this._tabId) {
        localStorage.removeItem(key)
      }
    } catch (e) {
      // ignore
    }
  }

  /**
   * True when another tab still looks like a live processor owner (fresh IDB heartbeat and
   * matching sync localStorage mirror). Missing/mismatched mirror means the owner unloaded.
   * @param {object|null} state
   * @return {boolean}
   * @private
   */
  _isProcessingOwnerAlive(state)
  {
    if (!state?.processingTabId || state.processingTabId === this._tabId) {
      return false
    }
    if (this._isProcessingLockStale(state)) {
      return false
    }
    let mirror = this._readProcessingLockMirror()
    return !!(mirror && mirror.tabId === state.processingTabId)
  }

  /**
   * Claim or renew the cross-tab processor lock. Steals a lock whose heartbeat is stale
   * or whose sync mirror was cleared on unload (owner refreshed) and requeues in-flight work.
   * @return {Promise<boolean>}
   * @private
   */
  async _tryClaimProcessingLeadership()
  {
    if (!this._cm.canPersist() || !this.isDownloadManagerEnabled()) {
      return false
    }
    let state = await this._getState()
    if (state.processingTabId === this._tabId) {
      this._hydrateInitiationClocksFromState(state)
      // Heartbeat renew via `_withState` — a raw put of a stale snapshot can wipe
      // `lastResolutionInitiationAt` / human-interaction flags written by the processor.
      await this._withState((next) => {
        if (next.processingTabId !== this._tabId) {
          return
        }
        next.processingHeartbeatAt = Date.now()
      })
      this._writeProcessingLockMirror()
      this._startProcessingHeartbeat()
      return true
    }
    if (this._isProcessingOwnerAlive(state)) {
      this._stopProcessingHeartbeat()
      return false
    }

    await this._requeueInterruptedPipelineWork()
    state = await this._getState()
    if (this._isProcessingOwnerAlive(state)) {
      return false
    }
    state.processingTabId = this._tabId
    state.processingHeartbeatAt = Date.now()
    if (state.tagDiscoveryPanelTabId && state.tagDiscoveryPanelTabId !== this._tabId) {
      state.tagDiscoveryPanelTabId = null
    }
    this._hydrateInitiationClocksFromState(state)
    await this._putState(state)
    this._writeProcessingLockMirror()
    this._startProcessingHeartbeat()
    return true
  }

  /**
   * @return {Promise<void>}
   * @private
   */
  async _requeueInterruptedPipelineWork()
  {
    let resolutionRows = await this._repos().downloadResolutionQueue.listAll()
    for (let row of resolutionRows) {
      if (row.status === 'resolving') {
        row.status = 'queued'
        row.error = null
        await this._repos().downloadResolutionQueue.put(row)
      }
    }
    let downloadRows = await this._repos().downloadQueue.listAll()
    for (let row of downloadRows) {
      if (row.status === 'downloading') {
        row.status = 'queued'
        row.error = null
        // Keep ledgerClaimed — the slot was already reserved before GM_download.
        await this._repos().downloadQueue.put(row)
      }
    }
  }

  /**
   * @return {Promise<void>}
   * @private
   */
  async _releaseProcessingLeadership()
  {
    this._stopProcessingHeartbeat()
    this._clearProcessingLockMirrorIfOwned()
    if (!this._cm.canPersist()) {
      return
    }
    let state = await this._getState()
    if (state.processingTabId !== this._tabId) {
      return
    }
    state.processingTabId = null
    state.processingHeartbeatAt = 0
    await this._putState(state)
  }

  /**
   * @private
   */
  _startProcessingHeartbeat()
  {
    if (this._processingHeartbeatTimer) {
      return
    }
    this._processingHeartbeatTimer = setInterval(() => {
      void this._withState((state) => {
        if (state.processingTabId !== this._tabId) {
          this._stopProcessingHeartbeat()
          this._clearProcessingLockMirrorIfOwned()
          return
        }
        state.processingHeartbeatAt = Date.now()
        this._writeProcessingLockMirror()
      })
    }, PROCESSING_HEARTBEAT_INTERVAL_MS)
  }

  /**
   * @private
   */
  _stopProcessingHeartbeat()
  {
    if (!this._processingHeartbeatTimer) {
      return
    }
    clearInterval(this._processingHeartbeatTimer)
    this._processingHeartbeatTimer = null
  }

  /**
   * @return {Promise<void>}
   * @private
   */
  async _runProcessors()
  {
    // Take the lock synchronously before any await so concurrent call sites
    // (enqueue + sync, two void starts, etc.) cannot both claim the same queued item.
    // If already running, request another pass so Start/unpause is not dropped mid-loop.
    if (this._processing) {
      this._processorsWakeRequested = true
      return
    }
    this._processing = true
    try {
      do {
        this._processorsWakeRequested = false
        if (!await this._tryClaimProcessingLeadership()) {
          break
        }
        if (!this._shouldRunProcessors()) {
          break
        }
        while (this._shouldRunProcessors()) {
          await this._bumpProcessingHeartbeat()
          let state = await this._getState()
          if (state.humanInteractionBlocked) {
            break
          }

          let processed = false

          if (!state.resolutionBlocked) {
            let resolutionRows = await this._repos().downloadResolutionQueue.listAll()
            let nextResolution = resolutionRows.find((row) => row.status === 'queued')
            if (nextResolution) {
              let claimed = await this._claimResolutionQueueItem(nextResolution.itemId)
              if (claimed) {
                this._refreshItemProgress(claimed.itemId)
                await this._processResolutionItem(claimed)
                await this._reloadCachedStateQuiet()
              }
              processed = true
            }
          }

          if (!processed && !state.paused && !state.resolutionBlocked) {
            let downloadRows = await this._repos().downloadQueue.listAll()
            let nextDownload = downloadRows.find((row) => row.status === 'queued')
            if (nextDownload) {
              let claimed = await this._claimDownloadQueueItem(nextDownload.itemId)
              if (claimed) {
                this._refreshItemProgress(claimed.itemId)
                await this._processDownloadItem(claimed)
                await this._reloadCachedStateQuiet()
              }
              processed = true
            }
          }

          if (!processed) {
            break
          }
        }
      } while (this._processorsWakeRequested)
    } finally {
      this._processing = false
      await this._pauseDownloadQueueIfIdle()
      this._refreshDockProgress()
      if (this._processorsWakeRequested) {
        this._processorsWakeRequested = false
        void this._runProcessors()
      }
    }
  }

  /**
   * Flip a resolution row from `queued` → `resolving`. Returns null if another worker already claimed it.
   * @param {string} itemId
   * @return {Promise<object|null>}
   * @private
   */
  async _claimResolutionQueueItem(itemId)
  {
    let row = await this._repos().downloadResolutionQueue.get(itemId)
    if (!row || row.status !== 'queued') {
      return null
    }
    row.status = 'resolving'
    row.error = null
    await this._repos().downloadResolutionQueue.put(row)
    return row
  }

  /**
   * Flip a download row from `queued` → `downloading`. Returns null if another worker already claimed it.
   * @param {string} itemId
   * @return {Promise<object|null>}
   * @private
   */
  async _claimDownloadQueueItem(itemId)
  {
    let row = await this._repos().downloadQueue.get(itemId)
    if (!row || row.status !== 'queued') {
      return null
    }
    row.status = 'downloading'
    row.error = null
    await this._repos().downloadQueue.put(row)
    return row
  }

  /**
   * @return {Promise<void>}
   * @private
   */
  async _bumpProcessingHeartbeat()
  {
    await this._withState((state) => {
      if (state.processingTabId !== this._tabId) {
        return
      }
      state.processingHeartbeatAt = Date.now()
      this._writeProcessingLockMirror()
    })
  }

  /**
   * @return {boolean}
   * @private
   */
  _shouldRunProcessors()
  {
    if (!this._cm.canPersist()) {
      return false
    }
    let state = this._getStateSync()
    if (!state) {
      return false
    }
    if (state.humanInteractionBlocked) {
      return false
    }
    if (this._isProcessingOwnerAlive(state)) {
      return false
    }
    return this.isDownloadManagerEnabled()
  }

  /**
   * @param {number} lastAt
   * @param {number} gapMs
   * @return {Promise<number>}
   * @private
   */
  async _awaitInitiationGap(lastAt, gapMs)
  {
    let remaining = gapMs - (Date.now() - (lastAt ?? 0))
    if (remaining > 0) {
      await Utilities.sleep(remaining)
    }
    return Date.now()
  }

  /**
   * Seed in-tab initiation clocks from shared state (leadership claim / renew).
   * @param {object|null|undefined} state
   * @private
   */
  _hydrateInitiationClocksFromState(state)
  {
    this._lastDownloadInitiationAt = Math.max(
        this._lastDownloadInitiationAt ?? 0,
        state?.lastDownloadInitiationAt ?? 0,
    )
    this._lastResolutionInitiationAt = Math.max(
        this._lastResolutionInitiationAt ?? 0,
        state?.lastResolutionInitiationAt ?? 0,
    )
  }

  /**
   * Serialize IDB state mutations that re-read before write (avoids lost updates).
   * @param {function(object): (void|Promise<void>)} mutator
   * @return {Promise<object>}
   * @private
   */
  _withState(mutator)
  {
    if (!this._cm.canPersist()) {
      return Promise.resolve(this._cachedState)
    }
    let run = this._stateWriteTail.then(async () => {
      let state = await this._repos().downloadManagerState.get()
      await mutator(state)
      state.id = 'state'
      await this._repos().downloadManagerState.put(state)
      this._cachedState = state
      return state
    })
    this._stateWriteTail = run.then(() => undefined, () => undefined)
    return run
  }

  /**
   * Pace the next GM_download / resolution fetch initiation.
   * Uses an in-tab clock so concurrent heartbeat/progress puts cannot wipe lastAt in IDB.
   * @param {'download'|'resolution'} kind
   * @return {Promise<void>}
   * @private
   */
  async _paceInitiation(kind)
  {
    let gapMs = kind === 'resolution' ? this._resolutionGapMs : this._downloadGapMs
    let localKey = kind === 'resolution' ? '_lastResolutionInitiationAt' : '_lastDownloadInitiationAt'
    let stateKey = kind === 'resolution' ? 'lastResolutionInitiationAt' : 'lastDownloadInitiationAt'
    let lastAt = Math.max(this[localKey] ?? 0, this._cachedState?.[stateKey] ?? 0)
    let nextAt = await this._awaitInitiationGap(lastAt, gapMs)
    this[localKey] = nextAt
    // Keep the sync cache in lockstep with the in-tab clock so a later
    // `_reloadCachedStateQuiet` of a briefly stale IDB row cannot undercut pacing.
    if (this._cachedState) {
      this._cachedState[stateKey] = Math.max(this._cachedState[stateKey] ?? 0, nextAt)
    }
    if (!this._cm.canPersist()) {
      return
    }
    await this._withState((state) => {
      state[stateKey] = Math.max(state[stateKey] ?? 0, nextAt)
    })
  }

  /**
   * @return {Promise<void>}
   * @private
   */
  async _paceDownloadInitiation()
  {
    await this._paceInitiation('download')
  }

  /**
   * @return {Promise<void>}
   * @private
   */
  async _paceResolutionInitiation()
  {
    await this._paceInitiation('resolution')
  }

  /**
   * Zero dock progress counters when both queues are idle so the next run starts at 0/N
   * instead of carrying over completed counts from a previous batch.
   * @return {Promise<void>}
   * @private
   */
  async _resetProgressCountersIfIdle()
  {
    let downloadPending = await this._getPendingDownloadCount()
    let resolutionPending = await this._getPendingResolutionCount()
    if (downloadPending > 0 || resolutionPending > 0) {
      return
    }
    let state = await this._getState()
    if (!state.completedDownloadCount && !state.completedResolutionCount) {
      return
    }
    state.completedDownloadCount = 0
    state.completedResolutionCount = 0
    await this._putState(state)
  }

  /**
   * When the download queue has no pending work, return to idle (Start required) so the
   * next batch does not auto-run because Start was left on from the previous batch.
   * @return {Promise<void>}
   * @private
   */
  async _pauseDownloadQueueIfIdle()
  {
    if ((await this._getPendingDownloadCount()) > 0) {
      return
    }
    let state = await this._getState()
    if (state.paused) {
      return
    }
    state.paused = true
    await this._putState(state)
  }

  /**
   * @return {Promise<void>}
   * @private
   */
  async _incrementResolutionProgress()
  {
    let state = await this._getState()
    state.completedResolutionCount += 1
    await this._putState(state)
    await this._resetProgressCountersIfIdle()
  }

  /**
   * @return {Promise<void>}
   * @private
   */
  async _incrementDownloadProgress()
  {
    let state = await this._getState()
    state.completedDownloadCount += 1
    await this._putState(state)
    await this._pauseDownloadQueueIfIdle()
    await this._resetProgressCountersIfIdle()
  }

  /**
   * Reload cached DM state without UI rebuilds (used between processor tasks).
   * @return {Promise<void>}
   * @private
   */
  async _reloadCachedStateQuiet()
  {
    if (!this._cm.canPersist()) {
      return
    }
    this._cachedState = await this._repos().downloadManagerState.get()
  }

  /**
   * One UI reset after a resolution task finishes (success, fail, block, or requeue).
   * @param {string} itemId
   * @return {Promise<void>}
   * @private
   */
  async _onResolutionTaskComplete(itemId)
  {
    await this._reloadCachedStateQuiet()
    this._refreshItemProgress(itemId)
    this._refreshDockProgress({refreshAllItems: false})
  }

  /**
   * One UI reset after a download task finishes (success, fail, duplicate, or requeue).
   * @param {string} itemId
   * @return {Promise<void>}
   * @private
   */
  async _onDownloadTaskComplete(itemId)
  {
    await this._reloadCachedStateQuiet()
    this._refreshItemProgress(itemId)
    this._refreshDockProgress({refreshAllItems: false})
  }

  /**
   * @return {Promise<void>}
   * @private
   */
  async _ensureState()
  {
    if (!this._cm.canPersist()) {
      return
    }
    let state = await this._repos().downloadManagerState.get()
    if (!state?.id) {
      await this._repos().downloadManagerState.reset()
    }
  }

  /**
   * @return {object|null}
   * @private
   */
  _getStateSync()
  {
    return this._cachedState ?? null
  }

  /**
   * @return {Promise<object>}
   * @private
   */
  async _getState()
  {
    let state = await this._repos().downloadManagerState.get()
    this._cachedState = state
    return state
  }

  /**
   * @param {object} state
   * @return {Promise<object>}
   * @private
   */
  async _putState(state)
  {
    let row = await this._repos().downloadManagerState.put(state)
    this._cachedState = row
    return row
  }

  /**
   * @return {Promise<void>}
   * @private
   */
  async _syncFromStorage()
  {
    if (!this._cm.canPersist()) {
      return
    }
    this._cachedState = await this._repos().downloadManagerState.get()
    await this._resetProgressCountersIfIdle()
    this._framework.refreshDockInterface(undefined, {layout: false})
    this._refreshDockProgress()
    this._refreshSelectionMarks()
    this._refreshAllItemProgress()
  }

  /**
   * @private
   */
  _setupCrossTabSync()
  {
    $(document).off('visibilitychange.brazenDownloadManager').on('visibilitychange.brazenDownloadManager', () => {
      if (document.visibilityState === 'visible') {
        void this._syncFromStorage()
            .then(() => this._tryClaimProcessingLeadership())
            .then(() => this._restoreTagDiscoveryPanelIfNeeded())
            .then(() => {
              if (this._getStateSync()?.humanInteractionBlocked) {
                void this._promptHumanInteractionResume()
              }
              if (this._shouldRunProcessors()) {
                void this._runProcessors()
              }
            })
      }
    })
    $(window).off('pagehide.brazenDownloadManager beforeunload.brazenDownloadManager').
        on('pagehide.brazenDownloadManager beforeunload.brazenDownloadManager', () => {
          // Clear the sync mirror immediately; async IDB release often does not finish on refresh.
          this._clearProcessingLockMirrorIfOwned()
          void this._releaseProcessingLeadership()
        })
  }

  // -------------------------------------------------------------------------
  // Search item progress
  // -------------------------------------------------------------------------

  /**
   * @return {{stepCount: number, indices: {queued: number, resolving: number, tagReview: number, downloadQueued: number, downloading: number}}}
   * @private
   */
  _getPipelineStepLayout()
  {
    if (this._isTagDiscoveryActive()) {
      return {
        stepCount: 5,
        indices: {queued: 0, resolving: 1, tagReview: 2, downloadQueued: 3, downloading: 4},
      }
    }
    return {
      stepCount: 4,
      indices: {queued: 0, resolving: 1, tagReview: -1, downloadQueued: 2, downloading: 3},
    }
  }

  /**
   * @param {string|number} itemId
   * @param {HTMLElement|null|undefined} element
   * @private
   */
  _trackItemElement(itemId, element)
  {
    if (itemId == null || !element) {
      return
    }
    this._trackedItemElements.set(String(itemId), element)
  }

  /**
   * @param {string|number} itemId
   * @private
   */
  _untrackItemElement(itemId)
  {
    if (itemId == null) {
      return
    }
    this._trackedItemElements.delete(String(itemId))
  }

  /**
   * @param {string|number} itemId
   * @private
   */
  _refreshItemProgress(itemId)
  {
    void this._renderItemProgress(itemId)
  }

  /**
   * @private
   */
  _refreshAllItemProgress()
  {
    for (let itemId of this._trackedItemElements.keys()) {
      void this._renderItemProgress(itemId)
    }
  }

  /**
   * @private
   */
  _clearAllItemProgress()
  {
    for (let element of this._trackedItemElements.values()) {
      this._setItemProgress(element, null)
    }
    this._trackedItemElements.clear()
  }

  /**
   * @param {string|number} itemId
   * @return {Promise<void>}
   * @private
   */
  async _renderItemProgress(itemId)
  {
    let element = this._trackedItemElements.get(String(itemId))
    if (!element) {
      return
    }
    let progress = await this._resolveItemPipelineView(itemId)
    this._setItemProgress(element, progress)
  }

  /**
   * @param {string|number} itemId
   * @return {Promise<{label: string, stepIndex: number, stepCount: number, failed?: boolean, complete?: boolean}|null>}
   * @private
   */
  async _resolveItemPipelineView(itemId)
  {
    let layout = this._getPipelineStepLayout()
    let state = await this._getState()
    let resolution = await this._repos().downloadResolutionQueue.get(String(itemId))
    let download = await this._repos().downloadQueue.get(String(itemId))
    let blocked = !!state?.resolutionBlocked
    let blockedItemId = state?.resolutionBlockedItemId != null ? String(state.resolutionBlockedItemId) : null
    let humanBlocked = !!state?.humanInteractionBlocked
    let humanItemId = state?.humanInteractionItemId != null ? String(state.humanInteractionItemId) : null
    let itemKey = String(itemId)

    if (humanBlocked) {
      if (humanItemId === itemKey) {
        return {
          label: 'Verify',
          stepIndex: state?.humanInteractionContext === 'download' ?
              layout.indices.downloading :
              layout.indices.resolving,
          stepCount: layout.stepCount,
        }
      }
      if (humanItemId) {
        return {
          label: 'Waiting',
          stepIndex: layout.indices.queued,
          stepCount: layout.stepCount,
        }
      }
    }

    if (download) {
      if (download.status === 'downloading') {
        return {
          label: 'Downloading',
          stepIndex: layout.indices.downloading,
          stepCount: layout.stepCount,
        }
      }
      if (download.status === 'queued') {
        return {
          // paused gates starting downloads; queued items stay Ready until Start (not "Paused")
          label: 'Ready',
          stepIndex: layout.indices.downloadQueued,
          stepCount: layout.stepCount,
        }
      }
      if (download.status === 'done') {
        return {
          label: 'Done',
          stepIndex: layout.indices.downloading,
          stepCount: layout.stepCount,
          complete: true,
        }
      }
      if (download.status === 'duplicate') {
        return {
          label: 'Duplicate',
          stepIndex: layout.indices.downloading,
          stepCount: layout.stepCount,
          complete: true,
        }
      }
      if (download.status === 'skipped') {
        return {
          label: 'Skipped',
          stepIndex: layout.indices.downloading,
          stepCount: layout.stepCount,
          complete: true,
        }
      }
      if (download.status === 'failed') {
        return {
          label: 'Failed',
          stepIndex: layout.indices.downloading,
          stepCount: layout.stepCount,
          failed: true,
        }
      }
    }

    if (resolution) {
      if (resolution.status === 'failed') {
        return {
          label: 'Failed',
          stepIndex: layout.indices.resolving,
          stepCount: layout.stepCount,
          failed: true,
        }
      }
      if (resolution.status === 'tagReview') {
        return {
          label: blocked && blockedItemId === itemKey ? 'Tags' : 'Waiting',
          stepIndex: layout.indices.tagReview >= 0 ? layout.indices.tagReview : layout.indices.downloadQueued,
          stepCount: layout.stepCount,
        }
      }
      if (resolution.status === 'resolving') {
        return {
          label: 'Resolving',
          stepIndex: layout.indices.resolving,
          stepCount: layout.stepCount,
        }
      }
      if (resolution.status === 'queued') {
        if (blocked && blockedItemId && blockedItemId !== itemKey) {
          return {
            label: 'Waiting',
            stepIndex: layout.indices.queued,
            stepCount: layout.stepCount,
          }
        }
        return {
          label: 'Queued',
          stepIndex: layout.indices.queued,
          stepCount: layout.stepCount,
        }
      }
    }

    return null
  }

  /**
   * @param {HTMLElement|null|undefined} element
   * @param {{label: string, stepIndex: number, stepCount: number, failed?: boolean, complete?: boolean}|null} progress
   * @private
   */
  _setItemProgress(element, progress)
  {
    let pageConfig = this._getActivePageConfig()
    if (pageConfig?.setItemProgress) {
      Utilities.callEventHandler(pageConfig.setItemProgress, [element, progress], null)
      return
    }
    if (progress) {
      BrazenViewLayer.updateDownloadManagerItemProgress(element, progress)
      return
    }
    BrazenViewLayer.clearDownloadManagerItemProgress(element)
  }

  // -------------------------------------------------------------------------
  // Selection mode
  // -------------------------------------------------------------------------

  /**
   * @return {boolean}
   * @private
   */
  _isSelectionModeActive()
  {
    return !!this._selectionModeActive
  }

  /**
   * @private
   */
  _bindSelectionHandlers()
  {
    let pageConfig = this._getActivePageConfig()
    if (!pageConfig?.resolveItem || !pageConfig.itemSelector) {
      return
    }

    $(document).off('click.brazenDmSelection', pageConfig.itemSelector)
    if (!this._isSelectionModeActive() || !this.isDownloadManagerEnabled()) {
      return
    }

    this._selectionClickHandler = (event) => {
      let target = $(event.target).closest(pageConfig.itemSelector)[0]
      if (!target) {
        return
      }
      event.preventDefault()
      event.stopPropagation()
      let resolved = Utilities.callEventHandler(pageConfig.resolveItem, [$(target)], null)
      if (!resolved?.itemId) {
        return
      }
      void this._toggleSelectionItem(resolved, target)
    }
    $(document).on('click.brazenDmSelection', pageConfig.itemSelector, this._selectionClickHandler)
  }

  /**
   * @param {object} resolved
   * @param {HTMLElement} element
   * @return {Promise<void>}
   * @private
   */
  async _toggleSelectionItem(resolved, element)
  {
    if (await this.isQueued(resolved.itemId)) {
      await this.dequeueDownload(resolved.itemId)
      this._untrackItemElement(resolved.itemId)
      this._setSelectionMark(element, false)
      this._setItemProgress(element, null)
      return
    }
    let added = await this.enqueueDownload(resolved)
    if (added) {
      this._trackItemElement(resolved.itemId, element)
      this._setSelectionMark(element, true)
      await this._renderItemProgress(resolved.itemId)
      return
    }
    this._setSelectionMark(element, false)
  }

  /**
   * @param {HTMLElement|null|undefined} element
   * @param {boolean} selected
   * @private
   */
  _setSelectionMark(element, selected)
  {
    let pageConfig = this._getActivePageConfig()
    if (pageConfig?.setSelectionMark) {
      Utilities.callEventHandler(pageConfig.setSelectionMark, [element, selected], null)
      return
    }
    BrazenViewLayer.setDownloadManagerSelectionMark(element, selected)
  }

  /**
   * @private
   */
  _refreshSelectionMarks()
  {
    let pageConfig = this._getActivePageConfig()
    if (!pageConfig?.itemSelector || !this.isDownloadPageRole('selection')) {
      return
    }
    void (async () => {
      let selectionActive = this._isSelectionModeActive()
      for (let element of document.querySelectorAll(pageConfig.itemSelector)) {
        let resolved = Utilities.callEventHandler(pageConfig.resolveItem, [$(element)], null)
        if (!resolved?.itemId) {
          continue
        }
        let itemKey = String(resolved.itemId)
        let queued = await this.isQueued(resolved.itemId)
        let selected = selectionActive && queued
        this._setSelectionMark(element, selected)
        // Keep DOM↔item mapping warm so processor claim/complete refreshes find the tile.
        if (selected) {
          this._trackItemElement(resolved.itemId, element)
          await this._renderItemProgress(resolved.itemId)
          continue
        }
        if (this._trackedItemElements.has(itemKey)) {
          await this._renderItemProgress(resolved.itemId)
          if (!queued) {
            let progress = await this._resolveItemPipelineView(resolved.itemId)
            if (!progress) {
              this._untrackItemElement(resolved.itemId)
              this._setItemProgress(element, null)
            }
          }
        }
      }
    })()
  }

  // -------------------------------------------------------------------------
  // Dock UI
  // -------------------------------------------------------------------------

  /**
   * @private
   */
  _setupDock()
  {
    if (this._config.tagDiscovery) {
      this._cm.addActionField(DOCK_TAG_DISCOVERY_MODE).
          setTitle('Tag Discovery Mode').
          setHelpText('When on, downloads pause when unknown tags appear until you confirm mappings in the discovery panel.').
          setAction(() => { void this.toggleTagDiscoveryMode() }).
          setDockButton({
            icon: 'discovery',
            getState: () => this._getStateSync()?.tagDiscoveryEnabled ? 'bv-dock-btn-active' : '',
            tooltip: () => this._getStateSync()?.tagDiscoveryEnabled ?
                'Tag discovery: on — unknown tags pause downloads until you confirm them' :
                'Tag discovery: off — click to pause on unknown tags and review mappings',
            include: function() {
              return this.isDownloadManagerEnabled() && this.isDownloadPageRole('tagDiscoveryToggle')
            },
          })
    }

    this._cm.addActionField(DOCK_SELECTION_MODE).
        setTitle('Selection Mode').
        setHelpText('Click search items to add or remove them from the download resolution queue.').
        setAction(() => this.toggleSelectionMode()).
        setDockButton({
          icon: 'select',
          getState: () => this._isSelectionModeActive() ? 'bv-dock-btn-active' : '',
          tooltip: () => this._isSelectionModeActive() ?
              'Selection mode: on — click items to queue' :
              'Selection mode: off — click to select items for download queue',
          include: function() {
            return this.isDownloadManagerEnabled() && this.isDownloadPageRole('selection')
          },
        })

    this._cm.addActionField(DOCK_ADD_TO_QUEUE).
        setTitle('Add to Download Queue').
        setHelpText('Adds or removes the current media page from the download resolution queue.').
        setAction(() => { void this.toggleCurrentMediaQueued() }).
        setDockButton({
          icon: 'queue-add',
          getState: () => '',
          tooltip: 'Add or remove this post from the download queue',
          include: function() {
            return this.isDownloadManagerEnabled() && this.isDownloadPageRole('enqueueMedia')
          },
        })

    this._cm.addActionField(DOCK_CLEAR_DOWNLOAD_QUEUE).
        setTitle('Clear Download Queue').
        setHelpText('Removes pending download-queue items only. Resolution, tag discovery, and selection mode are left alone.').
        setAction(() => {
          if (confirm('Clear pending downloads? Resolution work and selection mode are not cleared.')) {
            void this.clearDownloadQueue()
          }
        }).
        setDockButton({
          icon: 'clear',
          tooltip: 'Clear download queue',
          include: () => this.isDownloadManagerEnabled() && this.getPendingDownloadCountSync() > 0,
        })

    this._cm.addActionField(DOCK_DOWNLOAD_START_PAUSE).
        setTitle('Download Queue Start/Pause').
        setHelpText('Starts or pauses the download queue only. Resolution always runs unless rate-limited.').
        setAction(() => { void this.toggleDownloadManagerPaused() }).
        setDockButton({
          icon: () => {
            let state = this._getStateSync()
            if (state?.humanInteractionBlocked) {
              return 'play'
            }
            if (this.getPendingDownloadCountSync() <= 0) {
              return 'play'
            }
            return state?.paused ? 'play' : 'pause'
          },
          getState: () => {
            let state = this._getStateSync()
            if (state?.humanInteractionBlocked) {
              return ''
            }
            if (this.getPendingDownloadCountSync() <= 0) {
              return ''
            }
            return state?.paused ? '' : 'bv-dock-btn-active'
          },
          isDisabled: () => {
            let state = this._getStateSync()
            if (state?.humanInteractionBlocked) {
              return false
            }
            // Resolution-only work must not enable Start/Pause — that control is download-queue only.
            return this.getPendingDownloadCountSync() <= 0
          },
          tooltip: () => {
            let state = this._getStateSync()
            if (state?.humanInteractionBlocked) {
              return 'Verification required — click to confirm and resume'
            }
            if (this.getPendingDownloadCountSync() <= 0) {
              return 'Download queue empty — resolution runs on its own; downloads start here when ready'
            }
            if (state?.paused) {
              return 'Download queue paused — click to start downloads'
            }
            return 'Download queue running — click to pause downloads'
          },
          slideOutWhen: () => this._isQueueDockSlideOutOpen(),
          slideOutPinnedWhen: () => this._isQueueDockSlideOutPinned(),
          include: function() {
            return this.isDownloadManagerEnabled()
          },
        }).
        setDockSlideOut(this._getQueueDockSlideOutChildKeys()).
        setDockSlideOutNodes(() => this._getQueueDockSlideOutNodes())

    // Skip Duplicate ships its dock template from Framework; DM only attaches Hide as slide-out.
    let skipDuplicateKey = this._framework._downloadDuplicateLedgerConfig?.enableConfigKey ??
        (typeof OPTION_ENABLE_DOWNLOAD_DUPLICATE_LEDGER !== 'undefined' ? OPTION_ENABLE_DOWNLOAD_DUPLICATE_LEDGER : null)
    let skipDuplicateField = skipDuplicateKey ? this._cm.getField(skipDuplicateKey) : null
    if (skipDuplicateField) {
      let hideDownloadedKey = typeof OPTION_HIDE_DOWNLOADED_MEDIA !== 'undefined' ?
          OPTION_HIDE_DOWNLOADED_MEDIA :
          'hide-downloaded-media'
      let hideField = this._cm.getField(hideDownloadedKey)
      if (hideField && !hideField.dock) {
        hideField.applyDockTemplate('hideDownloaded')
      }
      skipDuplicateField.setDockSlideOut([hideDownloadedKey])
    }
  }

  /**
   * @return {boolean}
   * @private
   */
  _isQueueDockSlideOutOpen()
  {
    let slideOut = this._config.queueDockSlideOut
    if (slideOut?.slideOutWhen) {
      return !!Utilities.callEventHandler(slideOut.slideOutWhen, [this], null)
    }
    return this.getPendingDownloadCountSync() > 0
  }

  /**
   * @return {boolean}
   * @private
   */
  _isQueueDockSlideOutPinned()
  {
    let slideOut = this._config.queueDockSlideOut
    if (slideOut?.slideOutPinnedWhen) {
      return !!Utilities.callEventHandler(slideOut.slideOutPinnedWhen, [this], null)
    }
    return this._isQueueDockSlideOutOpen()
  }

  /**
   * @return {JQuery[]}
   * @private
   */
  _getQueueDockSlideOutNodes()
  {
    let slideOut = this._config.queueDockSlideOut
    if (slideOut?.getSlideOutNodes) {
      let nodes = Utilities.callEventHandler(slideOut.getSlideOutNodes, [this, this._cm], null)
      return Array.isArray(nodes) ? nodes.filter((node) => node?.length) : []
    }
    return [this.getOrCreateProgressSlot()]
  }

  /**
   * @return {string[]}
   * @private
   */
  _getQueueDockSlideOutChildKeys()
  {
    let slideOut = this._config.queueDockSlideOut
    if (Array.isArray(slideOut?.childFields)) {
      return slideOut.childFields
    }
    return [DOCK_CLEAR_DOWNLOAD_QUEUE]
  }

  /**
   * @param {{refreshAllItems?: boolean}} [options]
   * @private
   */
  _refreshDockProgress(options = {})
  {
    let refreshAllItems = options.refreshAllItems !== false
    void (async () => {
      let progressElement = this.getOrCreateProgressSlot()
      let downloadCount = await this._getPendingDownloadCount()
      let resolutionCount = await this._getPendingResolutionCount()
      this._cachedDownloadCount = downloadCount
      this._cachedResolutionCount = resolutionCount
      let showProgress = downloadCount > 0 && this.isDownloadManagerEnabled()
      BrazenViewLayer.setDownloadManagerProgressSlotVisible(progressElement, showProgress)
      if (showProgress) {
        let progress = await this.getDownloadManagerProgress()
        BrazenViewLayer.updateDownloadManagerProgressSlot(progressElement, progress)
      }
      if (refreshAllItems) {
        this._refreshAllItemProgress()
      }
      this._framework.refreshDockInterface(undefined, {layout: false})
    })()
  }

  // -------------------------------------------------------------------------
  // Page helpers
  // -------------------------------------------------------------------------

  /**
   * @return {string|null}
   * @private
   */
  _getActivePageName()
  {
    for (let pageName of Object.keys(this._config.pages ?? {})) {
      if (this._framework.isPage(pageName)) {
        return pageName
      }
    }
    return null
  }

  /**
   * @return {object|null}
   * @private
   */
  _getActivePageConfig()
  {
    let pageName = this._getActivePageName()
    return pageName ? this._config.pages[pageName] : null
  }

  /**
   * @return {BrazenStorageRepositories}
   * @private
   */
  _repos()
  {
    return this._cm.getRepos()
  }
}