Cross-tab download queue, resolution pipeline, and immediate downloads for Brazen user scripts
Este script não deve ser instalado diretamente. É uma biblioteca destinada a ser incluída por outros scripts através da diretiva de metadados // @require https://update.greasyfork.org/scripts/587126/1901981/Brazen%20Framework%20-%20Download%20Manager.js
// ==UserScript==
// @name Brazen Framework - Download Manager
// @namespace brazenvoid
// @version 3.0.0
// @author brazenvoid
// @license GPL-3.0-only
// @description Cross-tab download queue and resolution pipeline for Brazen user scripts
// @grant GM_download
// ==/UserScript==
/** Build id for Tampermonkey Resource-override checks (must match local `base-scripts` file). */
const BRAZEN_DOWNLOAD_MANAGER_BUILD = 'local-3.0.0'
/** @typedef {'resolution'|'download'} BrazenDownloadManagerLaneId */
/** @typedef {{itemId: string|null, promptTabId: string|null, openUrl: string|null, at: number}} BrazenHumanInteractionLane */
/** @typedef {{resolution: BrazenHumanInteractionLane|null, download: BrazenHumanInteractionLane|null}} BrazenHumanInteractionMap */
const OPTION_ENABLE_DOWNLOAD_MANAGER = 'enable-download-manager'
/** Behaviours: search pages open with selection mode active (toggleable from the dock per visit). */
const OPTION_DOWNLOAD_SELECTION_MODE_DEFAULT = 'download-selection-mode-default'
/** Behaviours: reopen tag discovery when ignored tags empty filename pins. */
const OPTION_REVIEW_IGNORED_FILENAME_PINS = 'review-ignored-filename-pins'
/** Behaviours: skip batch downloads with no tags for any filename/subfolder tag-type pin. */
const OPTION_SKIP_EMPTY_FILENAME_PINS = 'skip-empty-filename-pins'
/** Behaviours: defer tag-discovery review until the resolution queue drains (unattended batches). */
const OPTION_DEFER_TAG_DISCOVERY = 'defer-tag-discovery-unattended'
/** Canonical config field keys for download-path settings (SSOT for DM registration). */
const BRAZEN_DOWNLOAD_PATH_FIELD_KEYS = Object.freeze({
folder: 'download-folder',
filenamePattern: 'filename-pattern',
subfolderPattern: 'subfolder-pattern',
substitutions: 'filename-tag-substitutions',
tagIgnore: 'filename-tag-ignore-list',
stripCharacterSeries: 'strip-series-from-character-tags',
})
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_DOWNLOAD_LEADER = 'dock-download-leader'
const DOCK_CLEAR_DOWNLOAD_QUEUE = 'dock-clear-download-queue'
// Shared with BrazenIndexedDBStorage (loaded before this module).
const RESOLUTION_TERMINAL = RESOLUTION_QUEUE_TERMINAL_SET
const DOWNLOAD_TERMINAL = DOWNLOAD_QUEUE_TERMINAL_SET
/** Max similar tags shown in the discovery panel. */
const SIMILAR_DISCOVERY_LIMIT = 10
/** Page size when scanning registry tags by type for similarity. */
const SIMILAR_DISCOVERY_PAGE_SIZE = 200
/** 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
/** Periodic HI panel restore / stale `promptTabId` reclaim (visible tabs only). */
const HUMAN_INTERACTION_WATCHDOG_MS = 6000
/**
* Default wait before the processor leader clears an HI lane (CF soft block often expires).
* Overridable per lane via `rateLimitHandlers[context].humanInteraction.softExpireMs`.
*/
const HUMAN_INTERACTION_SOFT_EXPIRE_MS = 120000
/** Coalesce bulk-enqueue wake pings so other tabs see one storage event. */
const PROCESSORS_WAKE_SIGNAL_DEBOUNCE_MS = 150
/** Coalesce stacked wake resumes on the receiving tab. */
const PROCESSORS_WAKE_RECEIVE_DEBOUNCE_MS = 150
/** Coalesce dock progress UI pings so non-leader tabs can repaint counters. */
const PROGRESS_UI_WAKE_SIGNAL_DEBOUNCE_MS = 150
/** Coalesce stacked progress UI refreshes on the receiving tab. */
const PROGRESS_UI_WAKE_RECEIVE_DEBOUNCE_MS = 150
/**
* Max active queue item ids mirrored in sessionStorage for instant selection-mark paint.
* Above the cap the truncated set is stored; async reconcile fills the rest.
*/
const SELECTION_MARK_MIRROR_MAX = 4000
/**
* Default detailed help HTML for Download Manager settings and dock actions.
* Consumer scripts may override per field via {@link ConfigurationField#setHelp}.
*
* @type {Readonly<Record<string, string>>}
*/
const DOWNLOAD_MANAGER_FIELD_DETAILED_HELP = Object.freeze({
ENABLE_DOWNLOAD_MANAGER:
'<p>Shows download-manager dock controls (selection mode, queue start/pause, tag discovery, and related actions). ' +
'When off, batch enqueue and queue processing UI are hidden.</p>',
SELECTION_MODE_DEFAULT:
'<p>When enabled, search pages open with selection mode active so you can click posts to enqueue downloads.</p>' +
'<p>Turn selection on/off from the dock for each visit without changing this default. Workflow:</p>' +
'<ol><li>Click each media thumb you want — tiles show queue progress.</li>' +
'<li>When items are Ready, use <strong>Download Queue Start/Pause</strong>.</li></ol>',
REVIEW_IGNORED_FILENAME_PINS:
'<p>When a post would download with an empty filename or folder segment because all matching tags are on the Filename Tag Ignore List, ' +
'Tag Discovery opens with those tags and similar suggestions so you can substitute aliases or remove ignores before the save continues.</p>' +
'<p>Runs after unknown-tag review, or alone when there are no unknown tags.</p>',
SKIP_EMPTY_FILENAME_PINS:
'<p>Skips enqueue/download when your filename or subfolder pattern expects tag-type tokens but the post provides none for those slots. ' +
'The Filename Tag Ignore List does not affect this check. Patterns without tag slots are unaffected.</p>',
DEFER_TAG_DISCOVERY:
'<p>For unattended batches: posts needing tag review wait in a discovery lane until ordinary resolution finishes. ' +
'The resolution progress bar counts both lanes. New enqueue items wait while discovery review is active. Requires Tag Discovery on the dock.</p>',
DOCK_TAG_DISCOVERY_MODE:
'<p>Pauses resolution when a post has tags not yet in your database. Review mappings in the discovery panel, then confirm to continue. ' +
'Downloads already marked Ready keep going unless blocked by other review steps.</p>',
DOCK_SELECTION_MODE:
'<p>Toggle on search pages to click posts for batch enqueue. Selected items enter the resolution queue; click again to remove. ' +
'Works with Start in Selection Mode under Behaviours.</p>',
DOCK_ADD_TO_QUEUE:
'<p>On media pages, enqueue or dequeue the open post for resolution/download processing. ' +
'The button state shows whether this post is already queued.</p>',
DOCK_DOWNLOAD_LEADER:
'<p>Only one browser tab runs download-manager processing at a time. The crown shows whether this tab is leader. ' +
'Click to take leadership when the manager is idle on another tab.</p>',
DOCK_CLEAR_DOWNLOAD_QUEUE:
'<p>Clears pending download saves only. Items still resolving, tag-discovery review, and selection-mode state are unchanged.</p>',
DOCK_DOWNLOAD_START_PAUSE:
'<p>Controls saving of Ready downloads only. Resolution and tag discovery continue unless rate-limited or blocked by review.</p>',
DOWNLOAD_FOLDER:
'<p>Root folder all downloads are saved under. It can be renamed but not removed — clearing it restores the default.</p>' +
'<p>Subfolder and filename patterns build paths inside this folder under your browser download location.</p>',
FILENAME_PATTERN:
'<p>Build the saved file name from the pattern field below. The file extension is added automatically.</p>' +
'<p>Tag substitutions and the Filename Tag Ignore List apply before the path is resolved. ' +
'When a tag-type segment has nothing to use, that part becomes <code>unknown</code>.</p>',
SUBFOLDER_PATTERN:
'<p>Optional path segments inside the download folder. Use the pattern field and <code>/</code> between folder levels.</p>' +
'<p>Leave empty to save directly under the download folder.</p>',
TAG_SUBSTITUTIONS:
'<p>Rename tags for filename and subfolder tokens (Subject → Alias). Use underscores, not spaces.</p>' +
'<p>Add rules in the panel below. Scripts with sidebar tag actions may also add rules from there.</p>' +
'<p>The same mappings can appear in Tag Discovery while resolution is paused for review.</p>',
TAG_IGNORE:
'<p>Tags excluded from tag-type tokens in file names. One tag per row (underscores, not spaces).</p>' +
'<p>Ignored tags stay out of Artists, Characters, and other filename tokens.</p>',
STRIP_CHARACTER_SERIES:
'<p>Shortens disambiguated character tags in filename and subfolder tokens only — for example ' +
'<code>sciel_(clair_obscur_expedition_33)</code> → <code>sciel</code>.</p>' +
'<p>Does not change search tags or compliance rules.</p>',
})
class BrazenDownloadManager
{
// -------------------------------------------------------------------------
// Static public methods
// -------------------------------------------------------------------------
/**
* Prefix-scoped localStorage / sessionStorage key for Download Manager cross-tab signals.
* @param {string|null|undefined} scriptPrefix
* @param {string} suffix
* @return {string}
*/
static storageKey(scriptPrefix, suffix)
{
return (scriptPrefix ?? 'brazen-') + suffix
}
/**
* True when {@link downloadManagerState} still carries pre-3.0 legacy fields.
* @param {object|null|undefined} state
* @return {boolean}
* @private
*/
static _stateNeedsLegacyMigration(state)
{
if (!state) {
return false
}
if ('pendingImmediateDownload' in state) {
return true
}
if (state.humanInteractionBlocked) {
return true
}
for (let key of [
'humanInteractionContext',
'humanInteractionItemId',
'humanInteractionPromptTabId',
'humanInteractionOpenUrl',
]) {
if (key in state) {
return true
}
}
let map = state.humanInteraction
if (map == null) {
return false
}
if (typeof map !== 'object' || !('resolution' in map) || !('download' in map)) {
return true
}
return false
}
/**
* 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(BrazenDownloadManager.storageKey(scriptPrefix, 'dm-human-interaction-blocked')) === '1'
} catch (e) {
return false
}
}
/**
* True when any per-lane human-interaction entry is set (or legacy single-slot block).
* 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 = BrazenDownloadManager.storageKey(scriptPrefix, '')
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 BrazenDownloadManager._anyHumanInteractionState(state)
} catch (e) {
return false
}
}
/**
* Lane entry from DM state (`humanInteraction[ctx]`), or legacy single-slot fold.
* @param {object|null|undefined} state
* @param {BrazenDownloadManagerLaneId} ctx
* @return {BrazenHumanInteractionLane|null}
* @private
*/
static _hiLaneState(state, ctx)
{
if (!state) {
return null
}
let map = state.humanInteraction
if (map && typeof map === 'object' && map[ctx]) {
return map[ctx]
}
// Legacy single-slot schema (pre per-lane humanInteraction map).
if (state.humanInteractionBlocked) {
let legacyCtx = state.humanInteractionContext === 'download' ? 'download' : 'resolution'
if (legacyCtx === ctx) {
return {
itemId: state.humanInteractionItemId ?? null,
promptTabId: state.humanInteractionPromptTabId ?? null,
openUrl: state.humanInteractionOpenUrl ?? null,
at: 0,
}
}
}
return null
}
/**
* True when any lane (or legacy slot) has an active human-interaction block.
* @param {object|null|undefined} state
* @return {boolean}
* @private
*/
static _anyHumanInteractionState(state)
{
if (!state) {
return false
}
if (state.humanInteractionBlocked) {
return true
}
let map = state.humanInteraction
return !!(map && (map.resolution || map.download))
}
/**
* Lane ids that currently have a human-interaction block.
* @param {object|null|undefined} state
* @return {BrazenDownloadManagerLaneId[]}
* @private
*/
static _hiContextsState(state)
{
/** @type {BrazenDownloadManagerLaneId[]} */
let out = []
if (BrazenDownloadManager._hiLaneState(state, 'resolution')) {
out.push('resolution')
}
if (BrazenDownloadManager._hiLaneState(state, 'download')) {
out.push('download')
}
return out
}
/**
* Read `brazen_hi_ctx` from the live URL (query or hash).
* @return {BrazenDownloadManagerLaneId|null}
* @private
*/
static _peekHumanInteractionContextFromLocation()
{
try {
let liveParams = new URLSearchParams(location.search)
let fromQuery = liveParams.get('brazen_hi_ctx')
if (fromQuery === 'download' || fromQuery === 'resolution') {
return fromQuery
}
let hash = location.hash || ''
let match = /(?:^|[&#?])brazen_hi_ctx=(resolution|download)\b/.exec(hash)
if (match) {
return /** @type {BrazenDownloadManagerLaneId} */ (match[1])
}
} catch (e) {
// ignore
}
return null
}
/**
* When `brazen_hi_ctx` was stripped, pick the blocked lane that matches this challenge page.
* @param {object|null|undefined} state
* @return {BrazenDownloadManagerLaneId|null}
* @private
*/
static _resolveChallengeHumanInteractionContext(state)
{
let contexts = BrazenDownloadManager._hiContextsState(state)
if (!contexts.length) {
return null
}
if (contexts.length === 1) {
return contexts[0]
}
// Prefer the lane whose openUrl points at this document (sans brazen_hi markers).
let current = null
try {
current = new URL(location.href)
current.searchParams.delete('brazen_hi')
current.searchParams.delete('brazen_hi_ctx')
current.hash = ''
} catch (e) {
current = null
}
if (current) {
for (let ctx of contexts) {
let openUrl = BrazenDownloadManager._hiLaneState(state, ctx)?.openUrl
if (!openUrl) {
continue
}
try {
let marked = new URL(openUrl, location.href)
marked.searchParams.delete('brazen_hi')
marked.searchParams.delete('brazen_hi_ctx')
marked.hash = ''
if (marked.origin === current.origin && marked.pathname === current.pathname) {
return ctx
}
} catch (e) {
// ignore bad openUrl
}
}
}
return contexts[0]
}
/**
* 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-')
/**
* When true, resolution and download share one interleaved dispatcher (shared pacing).
* Default false — IndependentPolicy: concurrent lanes with own-lane gates.
* @type {boolean}
*/
this._linkQueues = config.linkQueues === true
/** @type {boolean} Interleaved-policy serial dispatcher lock. */
this._processing = false
/** @type {boolean} Set when interleaved dispatcher is kicked while already running. */
this._dispatcherWakeRequested = false
/** @type {boolean} */
this._resolutionProcessing = false
/** @type {boolean} */
this._resolutionWakeRequested = false
/** @type {boolean} */
this._downloadProcessing = false
/** @type {boolean} */
this._downloadWakeRequested = false
/** @type {string|null} In-tab resolution item currently owned by this tab's processor. */
this._activeResolutionItemId = null
/** @type {string|null} In-tab download item currently owned by this tab's processor. */
this._activeDownloadItemId = null
/**
* Bumps on unload / bfcache interrupt so in-flight processor awaits cannot recommit
* after the document has been suspended or restored.
* @type {number}
*/
this._processorEpoch = 0
/**
* True after `pagehide`/`beforeunload` until a live resume clears it. Blocks lock-mirror
* rewrites from late heartbeats while the document is dying or frozen in bfcache.
* @type {boolean}
*/
this._documentSuspended = false
this._selectionClickHandler = null
/** @type {((event: DragEvent) => void)|null} Capture-phase drag guard while selection mode is on. */
this._selectionDragStartHandler = null
this._crossTabVisibilityHandler = null
this._crossTabPageshowHandler = null
this._crossTabPagehideHandler = null
this._crossTabStorageHandler = null
this._selectionModeActive = false
/** @type {boolean} Sync cache for media-page Add/Remove queue dock chrome. */
this._currentMediaQueued = false
this._progressElement = null
this._tagDiscoveryPanel = null
/** @type {boolean} True while Confirm/Skip is finishing work after an immediate hide. */
this._tagDiscoveryActionInFlight = false
/** @type {number} Bumps to cancel stale similar-tag prefetch loads. */
this._similarDiscoveryLoadId = 0
/** @type {number|null} Pending `requestAnimationFrame` id for similar prefetch. */
this._similarDiscoveryRaf = null
/** @type {string|null} Fingerprint of new/known names for the last similar load. */
this._similarDiscoveryFingerprint = null
/** @type {Array<{name: string, type: string|null, count: null}>|null} Last filled similar rows (reuse on attribute refresh). */
this._similarDiscoveryCachedTags = null
/** @type {number|null} Debounced tag-discovery panel refresh (avoids <details> toggle mid-click). */
this._tagDiscoveryRefreshTimer = null
/**
* When set, the next discovery refresh runs only if the open panel lists one of these names.
* `null` means unrestricted; `undefined` means no pending filter from the last schedule.
* @type {string[]|null|undefined}
*/
this._tagDiscoveryRefreshOnlyTags = undefined
/**
* Per-lane human-interaction dock panels.
* @type {{resolution: HTMLElement|null, download: HTMLElement|null}}
*/
this._humanInteractionPanels = {resolution: null, download: null}
/** @type {number|null} Interval id for HI panel restore / stale prompt reclaim. */
this._humanInteractionWatchdogTimer = null
/** @type {number|null} Debounced selection-mark mirror write. */
this._selectionMarkMirrorWriteTimer = null
/**
* True while a navigation-interrupted in-progress download awaits a timed-panel decision.
* Gates the download lane so nothing new starts in parallel.
* @type {boolean}
*/
this._downloadInterruptionPending = false
/**
* Orphan `downloading` rows with `inProgress` awaiting the interruption panel decision.
* @type {object[]|null}
*/
this._downloadInterruptionRows = null
/** @type {HTMLElement|null} */
this._downloadInterruptionPanel = null
/** @type {object|null} Lazy lane descriptors for the dispatcher. */
this._lanes = null
this._trackedItemElements = new Map()
/** @type {Map<string, number>} Bumps per itemId so a later click can cancel an in-flight toggle. */
this._selectionOpEpoch = new Map()
/**
* @type {Set<string>}
* Item ids whose overlay was cleared by a local deselect/cancel. Blocks stale
* `_syncFromStorage` / processor paints until the row is actually gone from the queues.
*/
this._selectionUiHidden = new Set()
/**
* @type {Set<string>}
* Optimistic select in flight (painted, IDB put not settled). Keeps refresh/reconcile
* from clearing the overlay when `isQueued` is still false.
*/
this._selectionUiPending = new Set()
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()
/** @type {Promise<void>} Serializes download initiation pacing (batch + immediate). */
this._downloadInitiateTail = Promise.resolve()
/** @type {Promise<void>} Serializes resolution / same-origin site fetch pacing. */
this._resolutionInitiateTail = Promise.resolve()
/** @type {number|null} Debounce timer for `_signalProcessorsWake` localStorage writes. */
this._processorsWakeSignalTimer = null
/** @type {number|null} Debounce timer for incoming wake / focus resume. */
this._processorsWakeReceiveTimer = null
/** @type {Promise<void>} Serializes `_resumeProcessorsFromExternalWake` work. */
this._processorsWakeResumeTail = Promise.resolve()
/** @type {number|null} Debounce timer for `_signalProgressUiWake` localStorage writes. */
this._progressUiWakeSignalTimer = null
/** @type {number|null} Debounce timer for incoming progress UI wake. */
this._progressUiWakeReceiveTimer = null
/** @type {boolean} Dock progress refresh requested while a paint is in flight. */
this._dockProgressRefreshWanted = false
/** @type {boolean} */
this._dockProgressRefreshRunning = false
/** @type {boolean} OR of refreshAllItems across coalesced refresh requests. */
this._dockProgressRefreshAllItems = false
/** @type {boolean} One-shot latch: orphan downloading requeue while peek has no queued row. */
this._orphanDownloadRequeueAttempted = false
this._registerConfigFields()
this._registerDownloadPathFields()
this._setupDock()
}
/**
* @private
*/
_registerConfigFields()
{
let seeds = {}
if (this._config.enabled !== true) {
let enableKey = this._config.enableConfigKey ?? OPTION_ENABLE_DOWNLOAD_MANAGER
if (!this._cm.getField(enableKey)) {
seeds[enableKey] = !!this._config.enableDefault
this._cm.addFlagField(enableKey).
setTitle('Enable Download Manager').
setHelp(DOWNLOAD_MANAGER_FIELD_DETAILED_HELP.ENABLE_DOWNLOAD_MANAGER)
}
}
let selectionDefaultKey = this._config.selectionModeDefaultConfigKey ?? OPTION_DOWNLOAD_SELECTION_MODE_DEFAULT
if (!this._cm.getField(selectionDefaultKey)) {
seeds[selectionDefaultKey] = false
this._cm.addFlagField(selectionDefaultKey).
setTitle('Start in Selection Mode').
setHelp(DOWNLOAD_MANAGER_FIELD_DETAILED_HELP.SELECTION_MODE_DEFAULT)
}
if (!this._cm.getField(OPTION_REVIEW_IGNORED_FILENAME_PINS)) {
seeds[OPTION_REVIEW_IGNORED_FILENAME_PINS] = false
this._cm.addFlagField(OPTION_REVIEW_IGNORED_FILENAME_PINS).
setTitle('Review Ignored Download Path Tags').
setHelp(DOWNLOAD_MANAGER_FIELD_DETAILED_HELP.REVIEW_IGNORED_FILENAME_PINS)
}
if (!this._cm.getField(OPTION_SKIP_EMPTY_FILENAME_PINS)) {
seeds[OPTION_SKIP_EMPTY_FILENAME_PINS] = false
this._cm.addFlagField(OPTION_SKIP_EMPTY_FILENAME_PINS).
setTitle('Skip Media Without Download Path Tags').
setHelp(DOWNLOAD_MANAGER_FIELD_DETAILED_HELP.SKIP_EMPTY_FILENAME_PINS)
}
if (!this._cm.getField(OPTION_DEFER_TAG_DISCOVERY)) {
seeds[OPTION_DEFER_TAG_DISCOVERY] = false
this._cm.addFlagField(OPTION_DEFER_TAG_DISCOVERY).
setTitle('Defer Tag Discovery Until Resolution Completes').
setHelp(DOWNLOAD_MANAGER_FIELD_DETAILED_HELP.DEFER_TAG_DISCOVERY)
}
if (Object.keys(seeds).length) {
this._cm.registerFieldSeeds(seeds)
}
}
/**
* Registers download-path settings fields when {@link BrazenDownloadManager} config includes `downloadPaths`.
* Consumer scripts may override titles, defaults, or detailed help per field after `configureDownloadManager()`.
*
* @private
*/
_registerDownloadPathFields()
{
let paths = this._config.downloadPaths
if (!paths) {
return
}
let seeds = {}
let folderKey = paths.folderConfigKey || BRAZEN_DOWNLOAD_PATH_FIELD_KEYS.folder
if (!this._cm.getField(folderKey)) {
if (paths.defaultFolder) {
seeds[folderKey] = paths.defaultFolder
}
this._cm.addTextField(folderKey).
setTitle('Download Folder').
setHelp(DOWNLOAD_MANAGER_FIELD_DETAILED_HELP.DOWNLOAD_FOLDER)
}
let filenameKey = paths.filenamePatternConfigKey || BRAZEN_DOWNLOAD_PATH_FIELD_KEYS.filenamePattern
if (!this._cm.getField(filenameKey)) {
seeds[filenameKey] = paths.defaultFilenamePattern ?? 'ID'
this._cm.addTextField(filenameKey).
setTitle('Filename Pattern').
setHelp(DOWNLOAD_MANAGER_FIELD_DETAILED_HELP.FILENAME_PATTERN)
}
let subfolderKey = paths.subfolderPatternConfigKey || BRAZEN_DOWNLOAD_PATH_FIELD_KEYS.subfolderPattern
if (!this._cm.getField(subfolderKey)) {
this._cm.addTextField(subfolderKey).
setTitle('Subfolder Pattern').
setHelp(DOWNLOAD_MANAGER_FIELD_DETAILED_HELP.SUBFOLDER_PATTERN)
}
let substitutionsKey = paths.substitutionsFieldKey || BRAZEN_DOWNLOAD_PATH_FIELD_KEYS.substitutions
if (!this._cm.getField(substitutionsKey)) {
seeds[substitutionsKey] = {
templateId: 'substitution',
templateConfig: {attributeKind: 'requiresTag'},
config: {...DEFAULT_RULESET_USER_CONFIG, autoSort: true},
}
this._cm.addRulesetField(substitutionsKey).
setTitle('Filename Tag Substitutions').
setTemplate('substitution').
setHelp(DOWNLOAD_MANAGER_FIELD_DETAILED_HELP.TAG_SUBSTITUTIONS)
}
let ignoreKey = paths.tagIgnoreFieldKey || BRAZEN_DOWNLOAD_PATH_FIELD_KEYS.tagIgnore
if (!this._cm.getField(ignoreKey)) {
seeds[ignoreKey] = {
templateId: 'tag-sole-ignore',
templateConfig: {attributeKind: 'toggle'},
config: {...DEFAULT_RULESET_USER_CONFIG, autoSort: true},
}
this._cm.addRulesetField(ignoreKey).
setTitle('Filename Tag Ignore List').
setTemplate('tag-sole-ignore').
setHelp(DOWNLOAD_MANAGER_FIELD_DETAILED_HELP.TAG_IGNORE)
}
let stripSeriesKey = paths.stripCharacterSeriesConfigKey || BRAZEN_DOWNLOAD_PATH_FIELD_KEYS.stripCharacterSeries
if (!this._cm.getField(stripSeriesKey)) {
this._cm.addFlagField(stripSeriesKey).
setTitle('Strip Series From Character Tags').
setHelp(DOWNLOAD_MANAGER_FIELD_DETAILED_HELP.STRIP_CHARACTER_SERIES)
}
if (Object.keys(seeds).length) {
this._cm.registerFieldSeeds(seeds)
}
}
/**
* @return {Promise<void>}
*/
async initialize()
{
await this._ensureState()
this._syncDiscoveryTagTypesFromPatterns()
this._selectionModeActive = this._shouldStartSelectionMode()
try {
sessionStorage.removeItem(BrazenDownloadManager.storageKey(this._cm._scriptPrefix, '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
}
if (source === 'tags') {
// Tag attribute chrome — consumer refreshTagActionSurfaces owns discovery repaint.
return
}
this._scheduleTagDiscoveryPanelRefresh()
this._refreshDockProgress()
this._refreshSelectionMarks()
this._syncDiscoveryTagTypesFromPatterns()
if (this.isDownloadPageRole('selection')) {
this._bindSelectionHandlers()
this.registerSelectionDragGuard()
}
})
if (this.isDownloadPageRole('selection')) {
this._bindSelectionHandlers()
this.registerSelectionDragGuard()
// Sync paint from sessionStorage before the async IDB reconcile (avoids blank flash).
this._paintSelectionMarksFromMirror()
}
await this._syncFromStorage()
await this._pruneAllTerminalQueueRows()
await this._tryClaimProcessingLeadership()
// HI restore + dock progress remount run after the dock exists (Framework post-UI).
// Painting progress here targets a detached slot; HI panels also need the dock stack.
this._startHumanInteractionWatchdog()
if (this._downloadInterruptionPending) {
this._showDownloadInterruptionPanel(this._downloadInterruptionRows)
}
if (this._shouldRunProcessors()) {
void this._runProcessors()
}
}
/**
* After Framework mounts the dock: remount progress onto the live slot, restore HI /
* discovery panels. Safe to call once UI exists (or again on focus).
* @return {Promise<void>}
*/
async afterDockReady()
{
// Live `.bv-dock-progress-panel` now exists — repaint counters that early init missed.
this._refreshDockProgress({refreshAllItems: true})
await this._restoreHumanInteractionPanelsIfNeeded()
if (this._downloadInterruptionPending) {
this._showDownloadInterruptionPanel(this._downloadInterruptionRows)
}
}
/**
* Drop terminal queue rows (and their fat payloads) left after crashes / missed finally.
* Safe across tabs — only deletes terminal statuses.
* @return {Promise<void>}
* @private
*/
async _pruneAllTerminalQueueRows()
{
if (!this._cm.canPersist()) {
return
}
try {
await Promise.all([
this._repos().downloadResolutionQueue.pruneTerminal(),
this._repos().downloadQueue.pruneTerminal(),
])
} catch (error) {
console.log('Terminal queue prune failed:', error)
}
}
/**
* @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
// -------------------------------------------------------------------------
/**
* 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)
this._addSelectionMarkMirrorId(itemId)
// Wake an idle leader in another tab (same-tab notify does not cross documents).
this._signalProcessorsWake()
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)
{
// Read before `_syncFromStorage` refreshes the cache from IDB.
let state = this._getStateSync()
let blockedId = state?.resolutionBlockedItemId
let cancelDiscovery = blockedId != null
&& String(itemId) === String(blockedId)
&& !this._tagDiscoveryActionInFlight
let itemKey = String(itemId)
let clearHiContexts = /** @type {BrazenDownloadManagerLaneId[]} */ ([])
for (let ctx of /** @type {BrazenDownloadManagerLaneId[]} */ (['resolution', 'download'])) {
let lane = this._hiLane(state, ctx)
if (lane?.itemId != null && String(lane.itemId) === itemKey) {
clearHiContexts.push(ctx)
}
}
await this._repos().downloadResolutionQueue.remove(itemId)
await this._repos().downloadQueue.remove(itemId)
this._removeSelectionMarkMirrorId(itemId)
if (cancelDiscovery) {
// Deselect of the review subject — same unlock as Skip (no type confirm).
this._hideTagDiscoveryPanel()
await this._withState((next) => {
this._clearDiscoveryReviewState(next)
})
if (String(this._activeResolutionItemId) === String(itemId)
|| String(this._activeDownloadItemId) === String(itemId)) {
this._processorEpoch++
this._activeResolutionItemId = null
this._activeDownloadItemId = null
}
await this._maybeEndDiscoveryLanePhase()
this._resumeProcessorsAfterDiscoveryReview()
}
// Deselect of an HI subject unlocks that lane immediately (do not wait for orphan restore).
for (let ctx of clearHiContexts) {
await this._clearHumanInteractionBlock(ctx)
}
if (clearHiContexts.length) {
this._signalProcessorsWake()
if (this._shouldRunProcessors()) {
void this._runProcessors()
}
}
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()
{
// Hide immediately so Confirm feels instant; reopen later if ignored-pin review is needed.
this._hideTagDiscoveryPanel()
if (this._tagDiscoveryActionInFlight) {
return
}
this._tagDiscoveryActionInFlight = true
try {
let state = await this._getState()
if (!state.resolutionBlocked) {
return
}
let reviewMode = state.discoveryReviewMode ?? 'unknown'
// Type promotion only for unknown-tag review (ignored-pin tags already have typeEntryId).
if (reviewMode !== 'ignoredPins') {
await this._confirmDiscoveryPanelTagTypes(state.discoveryPanelTags)
}
let itemId = state.resolutionBlockedItemId
if (!itemId) {
await this._withState((next) => {
this._clearDiscoveryReviewState(next)
})
await this._maybeEndDiscoveryLanePhase()
this._resumeProcessorsAfterDiscoveryReview()
return
}
let item = await this._repos().downloadResolutionQueue.get(itemId)
if (!item?.pendingTagGroups) {
await this._withState((next) => {
this._clearDiscoveryReviewState(next)
})
await this._maybeEndDiscoveryLanePhase()
this._resumeProcessorsAfterDiscoveryReview()
return
}
// After unknown Confirm: reopen for ignored filename pins if needed.
// After ignoredPins Confirm: re-collect; proceed only when no pin type is still blocking.
let resolved = item.pendingTagGroups
let tagGroups = this._tagGroupsFromResolvedPayload(resolved)
let tagIncidences = resolved.tagIncidences ?? {}
let ignoredPin = await this._collectIgnoredFilenamePinTags(tagGroups, tagIncidences)
if (ignoredPin.pinJoinEmpty && ignoredPin.tags.length) {
let ignoredTags = ignoredPin.tags
await this._withState((next) => {
next.discoveryReviewMode = 'ignoredPins'
next.discoveryPanelTags = ignoredTags
next.discoveryPanelKnownTags = []
})
await this._presentTagDiscoveryReview(ignoredTags, [])
return
}
await this._finishResolutionAfterTagDiscovery(item)
} finally {
this._tagDiscoveryActionInFlight = false
}
}
/**
* Promote or skip-empty after discovery/ignored-pin review when nothing remains to review.
* @param {object} item Resolution queue row with `pendingTagGroups`
* @return {Promise<void>}
* @private
*/
async _finishResolutionAfterTagDiscovery(item)
{
let itemId = item?.itemId ?? null
try {
if (!item?.pendingTagGroups) {
return
}
let resolvedPayload = item.pendingTagGroups
let tagGroups = this._tagGroupsFromResolvedPayload(resolvedPayload)
if (this._shouldSkipEmptyFilenamePins(tagGroups)) {
await this._dropResolvedWithoutDownload(item, {clearDiscoveryGate: false})
return
}
await this._promoteToDownloadQueue(item, resolvedPayload)
await this._repos().downloadResolutionQueue.remove(itemId)
if (itemId) {
this._refreshItemProgress(itemId)
}
await this._incrementResolutionProgress()
} catch (error) {
console.error('[BrazenDownloadManager] finish after tag discovery failed', error)
} finally {
await this._withState((next) => {
this._clearDiscoveryReviewState(next)
})
try {
await this._syncFromStorage()
} catch (syncError) {
/* ignore */
}
await this._maybeEndDiscoveryLanePhase()
this._resumeProcessorsAfterDiscoveryReview()
}
}
/**
* Clear discovery panel gate fields on a state snapshot.
* @param {object} state
* @private
*/
_clearDiscoveryReviewState(state)
{
state.resolutionBlocked = false
state.resolutionBlockedItemId = null
state.discoveryPanelTags = null
state.discoveryPanelKnownTags = null
state.discoveryReviewMode = null
state.tagDiscoveryPanelTabId = null
}
/**
* Skip the current resolution item waiting on tag review (do not promote to download;
* do not set isDiscovered so discovery can fire again later).
* @return {Promise<void>}
*/
async skipTagDiscoveryInclusion()
{
// Hide immediately; finish queue/state cleanup in the background.
this._hideTagDiscoveryPanel()
if (this._tagDiscoveryActionInFlight) {
return
}
this._tagDiscoveryActionInFlight = true
try {
let state = await this._getState()
if (!state.resolutionBlocked) {
return
}
let itemId = state.resolutionBlockedItemId
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._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) => {
this._clearDiscoveryReviewState(next)
})
await this._syncFromStorage()
await this._maybeEndDiscoveryLanePhase()
// Skip does not confirm types — discovery can fire again later for these names.
this._resumeProcessorsAfterDiscoveryReview()
} finally {
this._tagDiscoveryActionInFlight = false
}
}
/**
* After Confirm / Skip on any tab: wake an idle leader elsewhere, and run locally if we can.
* @private
*/
_resumeProcessorsAfterDiscoveryReview()
{
this._signalProcessorsWake()
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 || !state.resolutionBlockedItemId) {
return
}
let item = await this._repos().downloadResolutionQueue.get(state.resolutionBlockedItemId)
let openUrl = item?.sourceUrl
|| item?.pendingTagGroups?.metadata?.sourceUrl
|| null
if (openUrl) {
window.open(openUrl, '_blank')
}
}
/**
* @return {Promise<void>}
*/
async toggleDownloadManagerPaused()
{
let state = await this._getState()
// Linked / interleaved: any HI owns Start. Independent: only download-lane HI.
if (this._isStartPauseShowingHumanInteraction(state)) {
void this._promptHumanInteractionResume('download')
if (this._linkQueues) {
for (let ctx of this._hiContexts(state)) {
if (ctx !== 'download') {
void this._promptHumanInteractionResume(ctx)
}
}
}
return
}
// Start/Pause only affects the download queue — never resolution / tag discovery.
// Allow toggle while resolution is still in-batch (download may be empty mid-batch).
let downloadPending = await this._getPendingDownloadCount()
let resolutionPending = await this._getPendingResolutionCount()
if (this._isDownloadBatchIdle(downloadPending, resolutionPending)) {
return
}
// Patch via `_withState` — a raw put of this snapshot can wipe concurrent progress counts.
let nextPaused = !state.paused
await this._withState((next) => {
next.paused = nextPaused
})
if (!nextPaused) {
this._signalProcessorsWake()
await this._tryClaimProcessingLeadership()
}
await this._syncFromStorage()
if (!nextPaused) {
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()
{
await this._withState((state) => {
state.paused = true
state.completedDownloadCount = 0
this._ensureHumanInteractionMap(state)
if (state.humanInteraction.download) {
this._hideHumanInteractionPanel('download')
state.humanInteraction.download = null
this._writeHumanInteractionBlockMirror(this._anyHumanInteraction(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 downloadActive = await this._getPendingDownloadCount()
let resolutionActive = await this._getPendingResolutionCount()
let state = await this._getState()
let completedResolution = Number(state.completedResolutionCount) || 0
let completedDownload = Number(state.completedDownloadCount) || 0
return {
resolution: {
current: completedResolution,
total: completedResolution + resolutionActive,
},
download: {
current: completedDownload,
total: completedDownload + downloadActive,
},
}
}
/**
* @return {Promise<number>}
* @private
*/
async _getPendingDownloadCount()
{
return this._repos().downloadQueue.countActive()
}
/**
* @return {number}
*/
getPendingResolutionCountSync()
{
return this._cachedResolutionCount ?? 0
}
/**
* @return {Promise<number>}
* @private
*/
async _getPendingResolutionCount()
{
return this._repos().downloadResolutionQueue.countActive()
}
/**
* 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 downloadCount = await this._getPendingDownloadCount()
let resolutionCount = await this._getPendingResolutionCount()
return resolutionCount + downloadCount
}
/**
* @return {number}
*/
getPendingDownloadCountSync()
{
return this._cachedDownloadCount ?? 0
}
/**
* @return {Promise<number>}
*/
async getDownloadQueueCount()
{
let downloadCount = await this._getPendingDownloadCount()
if (this._isTagDiscoveryActive()) {
return downloadCount
}
let resolutionCount = await this._getPendingResolutionCount()
return resolutionCount + downloadCount
}
/**
* @return {HTMLElement}
*/
getOrCreateProgressSlot()
{
// Prefer the node currently mounted in the dock — slide-out remounts can leave
// `_progressElement` pointing at a detached panel while the rail shows a stale copy.
let live = document.querySelector('.bv-dock .bv-dock-progress-panel')
if (live) {
this._progressElement = live
return this._progressElement
}
if (this._progressElement != null && this._isProgressSlotConnected(this._progressElement)) {
return this._progressElement
}
this._progressElement = BrazenViewLayer.createDownloadManagerProgressSlot()
return this._progressElement
}
/**
* @param {HTMLElement|null|undefined} slot
* @return {boolean}
* @private
*/
_isProgressSlotConnected(slot)
{
return !!(slot && slot.isConnected)
}
/**
* Rebuild Start/Pause slide-out so a detached progress panel can remount.
* @private
*/
_remountDownloadStartPauseSlot()
{
let field = this._cm.getField(DOCK_DOWNLOAD_START_PAUSE)
if (!field?.dockElement) {
return
}
this._cm._refreshDockRootSlot(field)
}
/**
* @return {Promise<void>}
*/
async toggleTagDiscoveryMode()
{
if (!this._config.tagDiscovery) {
return
}
await this._withState((state) => {
state.tagDiscoveryEnabled = !state.tagDiscoveryEnabled
})
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.registerSelectionDragGuard()
if (!this._selectionModeActive) {
this._clearAllItemProgress()
return
}
this._paintSelectionMarksFromMirror()
this._refreshSelectionMarks()
}
/**
* @return {Promise<void>}
*/
async toggleCurrentMediaQueued()
{
if (!this.isDownloadPageRole('enqueueMedia')) {
return
}
let itemId = this._config.getQueueItemId({sourceUrl: location.href})
if (!itemId) {
return
}
let wasQueued = await this.isQueued(itemId)
// Optimistic chrome so the click always flips icon/active before IDB settles.
this._currentMediaQueued = !wasQueued
this._paintCurrentMediaQueueDockButton()
if (wasQueued) {
// Stop an in-flight claim from rewriting the row after remove.
if (String(this._activeResolutionItemId) === String(itemId)
|| String(this._activeDownloadItemId) === String(itemId)) {
this._processorEpoch++
this._activeResolutionItemId = null
this._activeDownloadItemId = null
}
await this.dequeueDownload(itemId)
} else {
await this.enqueueDownload({
itemId,
sourceUrl: location.href,
downloadType: this._getActivePageConfig()?.defaultDownloadType ?? 'post',
resolveContext: {fromMediaPage: true},
})
}
// Authoritative re-read — dequeue/enqueue sync must not leave a stale active icon.
this._currentMediaQueued = await this.isQueued(itemId)
this._paintCurrentMediaQueueDockButton()
}
/**
* Paint Add/Remove queue dock chrome from {@link _currentMediaQueued}.
* @private
*/
_paintCurrentMediaQueueDockButton()
{
let field = this._cm.getField(DOCK_ADD_TO_QUEUE)
if (!field?.dockElement) {
this._framework.refreshDockInterface(undefined, {layout: false})
return
}
this._cm._updateDockFieldButton(field)
}
/**
* Refresh sync cache for the media-page Add/Remove queue dock button.
* @param {{refreshDock?: boolean}} [options]
* @return {Promise<void>}
* @private
*/
async _refreshCurrentMediaQueueState(options = {})
{
if (!this.isDownloadPageRole('enqueueMedia')) {
this._currentMediaQueued = false
if (options.refreshDock !== false) {
this._paintCurrentMediaQueueDockButton()
}
return
}
let itemId = this._config.getQueueItemId?.({sourceUrl: location.href})
if (!itemId) {
this._currentMediaQueued = false
} else {
this._currentMediaQueued = await this.isQueued(itemId)
}
if (options.refreshDock !== false) {
this._paintCurrentMediaQueueDockButton()
}
}
/**
* Whether the active DM page config lists `role` in `roles`.
* Known roles: `selection`, `enqueueMedia`, `tagDiscoveryToggle`,
* `dashboard` (dedicated control / future-analytics host — does not grant page-oriented
* queue actions and does not auto-claim processor leadership).
* @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 || this._resolutionProcessing || this._downloadProcessing) {
return true
}
if (this._anyHumanInteraction(state) || state.resolutionBlocked) {
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
}
/**
* Make this tab the processor leader when the download manager is idle.
* No-op when this tab already leads. Blocks with an alert while active.
* @return {Promise<boolean>} True when this tab owns leadership after the call.
*/
async requestDownloadManagerLeadership()
{
if (this._documentSuspended || !this._cm.canPersist() || !this.isDownloadManagerEnabled()) {
return false
}
if (this.isDownloadManagerLeaderTab()) {
return true
}
if (this._isQueueVerificationTab()) {
return false
}
this._cachedDownloadCount = await this._getPendingDownloadCount()
this._cachedResolutionCount = await this._getPendingResolutionCount()
this._cachedState = await this._getState()
if (this.isDownloadManagerRunning()) {
alert("Can't switch leader while the download manager is active.")
return false
}
if (!await this._forceClaimProcessingLeadership()) {
return false
}
this._signalProcessorsWake()
this._cm.refreshDockButtonStates()
if (this._shouldRunProcessors()) {
void this._runProcessors()
}
return this.isDownloadManagerLeaderTab()
}
/**
* @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)
{
return Utilities.decodeHtmlEntities(text)
}
/**
* @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
}
/**
* Prefetch tag registry rows needed for filename ignore / substitutions.
* @param {{}} tagGroups
* @return {Promise<void>}
* @private
*/
async _ensureDownloadPathTagsLoaded(tagGroups)
{
let tagRuntime = this._cm.getTagRuntime()
if (!tagRuntime) {
return
}
let normalize = this._getDownloadTagNormalize()
let stripCharacterSeries = false
try {
stripCharacterSeries = !!this._config.downloadPaths?.getPatternResolver?.()?.stripCharacterSeries
} catch (error) {
stripCharacterSeries = false
}
let names = []
for (let group of Object.values(tagGroups ?? {})) {
if (Array.isArray(group)) {
for (let name of group) {
if (!name) {
continue
}
names.push(name)
let normalized = normalize(name)
if (normalized && normalized !== name) {
names.push(normalized)
}
if (stripCharacterSeries && normalized) {
let stripped = normalized.replace(/_\([^)]*\)$/, '')
if (stripped && stripped !== normalized) {
names.push(stripped)
}
}
}
}
}
if (names.length) {
await tagRuntime.ensureNames(names)
} else {
await tagRuntime.warmCache()
}
try {
await tagRuntime.refreshDownloadRulesetMaps?.()
} catch (error) {
// Join / ignore review fall back to ruleset reads when maps are unavailable.
}
}
/**
* Consumer tag normalizer for filename ignore / substitution lookups (same as attribute actions).
* @return {function(string): string}
* @private
*/
_getDownloadTagNormalize()
{
let fromResolver = null
try {
fromResolver = this._config.downloadPaths?.getPatternResolver?.()?.normalizeTag
} catch (error) {
fromResolver = null
}
if (typeof fromResolver === 'function') {
return fromResolver
}
let discovery = this._config.tagDiscovery ?? {}
let fromActions = discovery.actions?.normalize ?? discovery.normalizeTag
if (typeof fromActions === 'function') {
return fromActions
}
return (value) => String(value ?? '').trim()
}
/**
* @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 && this.patternIncludesChip(pattern, chip)) {
return true
}
}
return false
}
/**
* True when {@link pattern} references a chip via label, {label}, or {token}.
* @param {string} pattern
* @param {{token?: string, label?: string}} chip
* @return {boolean}
*/
patternIncludesChip(pattern, chip)
{
if (!pattern || !chip) {
return false
}
// Label / {label} / {token} only — bare token substrings (e.g. meta) over-match.
let needles = []
if (chip.label) {
needles.push(chip.label, `{${chip.label}}`)
}
if (chip.token) {
needles.push(`{${chip.token}}`)
}
for (let needle of needles) {
if (needle && pattern.includes(needle)) {
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)
{
let normalize = typeof options.normalizeTag === 'function' ?
options.normalizeTag :
this._getDownloadTagNormalize()
// Registry ignore / substitution keys are normalized (same as attribute toggles).
tags = (Array.isArray(tags) ? tags : []).
map((tag) => normalize(tag)).
filter(Boolean)
let applyFilenameIgnore = options.applyFilenameIgnore !== false
if (options.tagRuntime?._warmed) {
tags = options.tagRuntime.applyDownloadAttributesSync(
tags, options.stripCharacterSeries, applyFilenameIgnore)
} else {
tags = this._applyDownloadTagSubstitutions(tags, type, options.substitutions, options.stripCharacterSeries)
if (applyFilenameIgnore && ignore?.size) {
tags = tags.filter((tag) => {
if (ignore.has(tag) || ignore.has(normalize(tag))) {
return false
}
if (options.stripCharacterSeries && type === 'character') {
let stripped = tag.replace(/_\([^)]*\)$/, '')
if (stripped !== tag && (ignore.has(stripped) || ignore.has(normalize(stripped)))) {
return false
}
}
return true
})
}
}
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 {{name: string|null, element: HTMLElement|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()
}
return new Promise((resolve) => {
download.element?.remove()
GM_download({
url: download.url,
name: path,
// Filenames come from user patterns — never uniquify (no file(1).ext).
conflictAction: 'overwrite',
onload: () => {
this._framework._handleDownloadSucceeded(download)
resolve()
},
onerror: (error) => {
this._framework._handleDownloadFailed(download, error)
resolve()
},
})
})
}
/**
* @param {object} item
* @return {Promise<void>}
* @private
*/
async _processDownloadItem(item)
{
try {
// Status is already `downloading` from `_claimDownloadQueueItem`.
let payload = item.resolvedPayload ?? {}
await this._ensureDownloadPathTagsLoaded(payload.tagGroups ?? {})
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) {
// IndexedDB membership (bounded positive cache + postId index) — never getAll the ledger.
if (await this._framework._isDownloadDuplicateAsync(downloadId)) {
item.status = 'duplicate'
if (await this._commitDownloadRow(item)) {
await this._incrementDownloadProgress()
}
return
}
if (downloadId && this._framework._shouldClaimDownloadDuplicateLedger() &&
!(await this._framework._claimDownloadDuplicateLedgerSlot(downloadId))) {
item.status = 'duplicate'
if (await this._commitDownloadRow(item)) {
await this._incrementDownloadProgress()
}
return
}
if (this._framework._shouldClaimDownloadDuplicateLedger() && downloadId) {
item.ledgerClaimed = true
if (!(await this._commitDownloadRow(item))) {
return
}
}
}
if (!downloadUrl) {
item.status = 'failed'
item.error = 'Missing media URL'
if (await this._commitDownloadRow(item)) {
await this._incrementDownloadProgress()
}
return
}
try {
await this._paceDownloadInitiation()
if (!(await this._isActiveDownloadClaim(item.itemId))) {
return
}
// Persist before GM_download so a mid-transfer navigation can detect an orphan.
item.inProgress = true
item.startedAt = Date.now()
if (!(await this._commitDownloadRow(item))) {
return
}
await this._wrapDownloadTask({name: path, element: null, url: downloadUrl, downloadId})
item.status = 'done'
item.inProgress = false
} catch (error) {
item.inProgress = false
let rateLimit = await this._handleRateLimit('download', error, {sourceUrl: downloadUrl, resolvingUrl: downloadUrl}, item)
if (rateLimit === 'blocked') {
item.status = 'queued'
item.error = null
await this._commitDownloadRow(item)
return
}
item.status = 'failed'
item.error = String(error)
}
if (await this._commitDownloadRow(item)) {
await this._incrementDownloadProgress()
}
} finally {
await this._onDownloadTaskComplete(item.itemId)
}
}
// -------------------------------------------------------------------------
// Resolution pipeline
// -------------------------------------------------------------------------
/**
* @param {string} url
* @return {Promise<Document>}
* @private
*/
async _resolvePage(url)
{
let epoch = this._processorEpoch
let controller = new AbortController()
let timeoutId = setTimeout(() => controller.abort(), 30000)
let onPageHide = () => controller.abort()
window.addEventListener('pagehide', onPageHide, {once: true})
try {
if (this._documentSuspended || this._processorEpoch !== epoch) {
throw Object.assign(new Error('Page resolution aborted'), {status: 0, doc: null, url})
}
let response = await fetch(url, {
credentials: 'include',
signal: controller.signal,
headers: {Accept: 'text/html'},
})
if (this._documentSuspended || this._processorEpoch !== epoch) {
throw Object.assign(new Error('Page resolution aborted'), {status: 0, doc: null, url})
}
let text = await response.text()
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 (response.status === 429) {
throw Object.assign(new Error('HTTP 429'), {status: 429, doc, url})
}
if (!response.ok) {
throw Object.assign(new Error('HTTP ' + response.status), {status: response.status, doc, url})
}
return doc
} catch (error) {
if (error?.doc !== undefined || error?.status !== undefined) {
throw error
}
let message = error?.name === 'AbortError'
? 'Page resolution aborted'
: ('Page resolution failed: ' + url + ' — ' + (error?.message || error))
throw Object.assign(new Error(message), {status: 0, doc: null, url})
} finally {
clearTimeout(timeoutId)
window.removeEventListener('pagehide', onPageHide)
}
}
/**
* @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
if (await this._commitResolutionRow(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()
if (!(await this._isActiveResolutionClaim(item.itemId))) {
return
}
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._commitResolutionRow(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._commitResolutionRow(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'
if (await this._removeResolutionRowIfActive(item.itemId)) {
await this._incrementResolutionProgress()
}
return
}
await this._handleResolvedPayload(item, resolved)
return
}
item.status = 'failed'
item.error = 'No resolveFromMedia handler'
if (await this._commitResolutionRow(item)) {
await this._incrementResolutionProgress()
}
} catch (error) {
item.status = 'failed'
item.error = String(error?.message ?? error)
if (await this._commitResolutionRow(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'
if (await this._commitResolutionRow(item)) {
await this._incrementResolutionProgress()
}
return
}
let tagGroups = this._tagGroupsFromResolvedPayload(resolved)
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) {
if (this._shouldDeferTagDiscoveryReview()) {
item.status = 'discoveryQueued'
item.pendingTagGroups = resolved
if (!(await this._commitResolutionRow(item))) {
return
}
return
}
item.status = 'tagReview'
item.pendingTagGroups = resolved
if (!(await this._commitResolutionRow(item))) {
return
}
await this._withState((state) => {
state.resolutionBlocked = true
state.resolutionBlockedItemId = item.itemId
state.discoveryReviewMode = 'unknown'
// Fresh list for this item — do not merge leftover tags from a prior review.
state.discoveryPanelTags = lists.unknown
state.discoveryPanelKnownTags = lists.known
})
let state = this._getStateSync()
await this._presentTagDiscoveryReview(state.discoveryPanelTags, state.discoveryPanelKnownTags)
return
}
}
if (await this._tryBeginIgnoredPinReview(item, resolved, tagGroups, tagIncidences)) {
return
}
if (this._shouldSkipEmptyFilenamePins(tagGroups)) {
await this._dropResolvedWithoutDownload(item)
return
}
if (!(await this._promoteToDownloadQueue(item, resolved))) {
return
}
if (!(await this._removeResolutionRowIfActive(item.itemId))) {
return
}
await this._incrementResolutionProgress()
}
/**
* @param {object} item
* @param {object} resolved
* @return {Promise<boolean>}
* @private
*/
async _promoteToDownloadQueue(item, resolved)
{
let row = {
itemId: item.itemId,
downloadType: item.downloadType,
resolvedPayload: resolved,
status: 'queued',
downloadId: resolved.downloadId ?? null,
ledgerClaimed: false,
inProgress: false,
startedAt: null,
addedAt: Date.now(),
error: null,
}
await this._repos().downloadQueue.put(row)
this._kickDownloadProcessor()
return true
}
/**
* Wake the download pipeline after new Ready rows appear (promote / confirm).
* @private
*/
_kickDownloadProcessor()
{
// Promote may run on a follower after Confirm — wake the leader if we cannot run here.
this._signalProcessorsWake()
if (!this._shouldRunProcessors()) {
return
}
void this._runProcessors()
}
/**
* @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 {BrazenDownloadManagerLaneId} 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, context) : null
// Sync mirror before the user opens the challenge so Phase-1 mediaCloudflare can detect HI.
this._writeHumanInteractionBlockMirror(true)
// Only a visible tab may own the Open/Done chrome. Background processor leaders leave
// `promptTabId` null so a focused follower can steal immediately on wake.
let canOwnPrompt = document.visibilityState === 'visible' && !this._documentSuspended
// Serialize through `_withState` so a concurrent heartbeat/progress put cannot wipe
// initiation clocks (or clear this block) via a stale raw `_putState` snapshot.
// Per-lane: writing one lane never clobbers the other.
let began = false
await this._withState((state) => {
this._ensureHumanInteractionMap(state)
if (state.humanInteraction[context]) {
// Refresh URL / item; do not re-pin ownership from a hidden initiator.
let existing = state.humanInteraction[context]
let promptTabId = canOwnPrompt
? this._tabId
: (existing.promptTabId === this._tabId ? null : (existing.promptTabId ?? null))
state.humanInteraction[context] = {
itemId: item?.itemId ?? existing.itemId ?? null,
promptTabId,
openUrl: openUrlMarked ?? existing.openUrl ?? null,
at: existing.at ?? Date.now(),
}
began = true
return
}
state.humanInteraction[context] = {
itemId: item?.itemId ?? null,
promptTabId: canOwnPrompt ? this._tabId : null,
openUrl: openUrlMarked,
at: Date.now(),
}
began = true
})
if (!began) {
return
}
this._refreshAllItemProgress()
// Show the dock panel only when this tab is focused — do not auto-open the challenge.
if (canOwnPrompt) {
await this._promptHumanInteractionResume(context)
}
// Wake other visible tabs so a focused follower can steal the panel if this tab
// stays backgrounded (same pattern as tag discovery).
this._signalProcessorsWake()
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` (+ `brazen_hi_ctx`) plus hash — CF often replaceState-strips
* search params but leaves the hash.
* @param {string} url
* @param {BrazenDownloadManagerLaneId} [context]
* @return {string}
* @private
*/
_markHumanInteractionOpenUrl(url, context = null)
{
try {
let parsed = new URL(url, location.href)
parsed.searchParams.set('brazen_hi', '1')
if (context === 'resolution' || context === 'download') {
parsed.searchParams.set('brazen_hi_ctx', context)
}
let hashParts = ['brazen_hi=1']
if (context === 'resolution' || context === 'download') {
hashParts.push('brazen_hi_ctx=' + context)
}
if (!/(^|[&#])brazen_hi=1\b/.test(parsed.hash)) {
parsed.hash = hashParts.join('&')
} else if (context && !/(^|[&#])brazen_hi_ctx=/.test(parsed.hash)) {
parsed.hash = parsed.hash.replace(/^#/, '') + '&brazen_hi_ctx=' + context
}
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 BrazenDownloadManager.storageKey(this._cm._scriptPrefix, '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._anyHumanInteraction(this._getStateSync())
}
/**
* Phase-1 mediaCloudflare (or equivalent) page operation.
* Queue verification / HI-blocked tabs show **Done — resume** / **Done — resume — close tab**
* on this media page (where the CAPTCHA is). Standalone browsing shows **Done — reload**
* (never a grey modal).
* @param {{
* title?: string,
* message?: string,
* confirmLabel?: string,
* confirmAndCloseLabel?: string,
* }} [options]
* @return {Promise<void>}
*/
async handleMediaCloudflarePage(options = {})
{
let scriptPrefix = BrazenDownloadManager.storageKey(this._cm?._scriptPrefix, '')
if (await BrazenDownloadManager.shouldSilenceMediaCloudflarePrompt(scriptPrefix, this)) {
// Silence formerly meant "no UI" — now the challenge tab owns Done — resume.
this._showQueueChallengeResumePanel(options)
return
}
this._showStandaloneCloudflareReloadPanel(options)
}
/**
* @return {Promise<boolean>}
*/
async shouldSilenceMediaCloudflarePrompt()
{
let scriptPrefix = BrazenDownloadManager.storageKey(this._cm?._scriptPrefix, '')
return BrazenDownloadManager.shouldSilenceMediaCloudflarePrompt(scriptPrefix, this)
}
/**
* Lane entry helper (instance).
* @param {object|null|undefined} state
* @param {BrazenDownloadManagerLaneId} ctx
* @return {BrazenHumanInteractionLane|null}
* @private
*/
_hiLane(state, ctx)
{
return BrazenDownloadManager._hiLaneState(state, ctx)
}
/**
* @param {object|null|undefined} state
* @return {boolean}
* @private
*/
_anyHumanInteraction(state)
{
return BrazenDownloadManager._anyHumanInteractionState(state)
}
/**
* @param {object|null|undefined} state
* @return {BrazenDownloadManagerLaneId[]}
* @private
*/
_hiContexts(state)
{
return BrazenDownloadManager._hiContextsState(state)
}
/**
* Ensure `state.humanInteraction` is a per-lane map (mutates in place).
* @param {object} state
* @private
*/
_ensureHumanInteractionMap(state)
{
if (!state.humanInteraction || typeof state.humanInteraction !== 'object') {
state.humanInteraction = {resolution: null, download: null}
}
if (!('resolution' in state.humanInteraction)) {
state.humanInteraction.resolution = null
}
if (!('download' in state.humanInteraction)) {
state.humanInteraction.download = null
}
}
/**
* Show the human-interaction dock panel for one lane on the focused tab (steals ownership).
* Includes the queue verification / media challenge tab so Done — resume appears
* where the CAPTCHA was completed.
* @param {BrazenDownloadManagerLaneId} context
* @param {{force?: boolean}} [options] `force` skips the visibility gate (rare; preferred
* path only shows on visible tabs so background leaders do not pin the chrome).
* @return {Promise<void>}
* @private
*/
async _promptHumanInteractionResume(context, options = {})
{
if (context !== 'resolution' && context !== 'download') {
return
}
let state = await this._getState()
if (!this._hiLane(state, context)) {
// Remote clear — drop a stale local panel instead of leaving a dead zombie.
this._hideHumanInteractionPanel(context)
return
}
if (!options.force && document.visibilityState !== 'visible') {
return
}
if (!this._canOwnHumanInteractionPrompt()) {
return
}
// Focused tab always claims / steals — same model as tag discovery (not processor-leader-only).
await this._withState((next) => {
this._ensureHumanInteractionMap(next)
let lane = next.humanInteraction[context]
if (!lane) {
return
}
lane.promptTabId = this._tabId
})
state = this._getStateSync()
let lane = this._hiLane(state, context)
if (!lane || lane.promptTabId !== this._tabId) {
return
}
let handlers = this._config.rateLimitHandlers?.[context]?.humanInteraction ?? {}
let onChallengeTab = this._isQueueVerificationTab()
let openLabel = this._resolveHumanInteractionOpenLabel(handlers)
let defaultTitle = this._humanInteractionDefaultTitle(context)
// Challenge media tab: always use the Phase-1 challenge pane (Done — resume +
// Done — resume — close tab). Do not reuse the leader Open/Done chrome.
if (onChallengeTab) {
this._showQueueChallengeResumePanel({
title: handlers.confirmTitle ?? defaultTitle,
message: handlers.challengeConfirmMessage
?? 'Complete the verification on this page, then resume the download queue.',
confirmLabel: handlers.confirmLabel,
confirmAndCloseLabel: handlers.confirmAndCloseLabel,
})
return
}
let panel = this._ensureHumanInteractionPanel(context, {reopenLabel: openLabel})
BrazenViewLayer.updateHumanInteractionPanelContent(panel, {
title: handlers.confirmTitle ?? defaultTitle,
message: handlers.confirmMessage,
confirmLabel: handlers.confirmLabel,
reopenLabel: openLabel,
})
let reopenButton = panel.querySelector('.bv-human-interaction-reopen')
if (reopenButton) {
reopenButton.hidden = false
}
this._showHumanInteractionPanelInteractive(context, panel)
}
/**
* Label for the Open-challenge control. Ignores legacy consumer copy
* ("Reopen verification tab") so GreasyFork apps without a local override still rename.
* @param {object} [handlers]
* @return {string}
* @private
*/
_resolveHumanInteractionOpenLabel(handlers = {})
{
let label = handlers.openChallengeLabel ?? handlers.reopenLabel
if (!label || label === 'Reopen verification tab') {
return 'Open Cloudflare Challenge'
}
return label
}
/**
* @param {BrazenDownloadManagerLaneId} context
* @return {string}
* @private
*/
_humanInteractionDefaultTitle(context)
{
return context === 'download' ? 'Download verification required' : 'Resolution verification required'
}
/**
* @param {BrazenDownloadManagerLaneId} context
* @param {{reopenLabel?: string}} [options]
* @return {HTMLElement}
* @private
*/
_ensureHumanInteractionPanel(context, options = {})
{
let existing = this._humanInteractionPanels[context]
if (existing) {
// Recreate when left in an inconsistent offscreen/visible state (dead clicks).
let stuckOffscreen = existing.classList.contains('bv-dock-panel-offscreen')
&& !existing.classList.contains('bv-dock-panel-hidden')
&& existing.style.display === 'flex'
if (stuckOffscreen) {
this._framework._hideDockSlidePanel(existing, false)
existing.remove()
this._humanInteractionPanels[context] = null
} else {
if (options.reopenLabel) {
let reopenButton = existing.querySelector('.bv-human-interaction-reopen')
if (reopenButton) {
reopenButton.textContent = options.reopenLabel
}
}
return existing
}
}
let panelId = context === 'download'
? 'bv-human-interaction-panel-download'
: 'bv-human-interaction-panel-resolution'
let handlers = this._config.rateLimitHandlers?.[context]?.humanInteraction ?? {}
let panel = BrazenViewLayer.createHumanInteractionPanel({
id: panelId,
onConfirm: () => { void this._confirmHumanInteractionResume(context) },
onReopen: () => { void this._reopenHumanInteractionTab(context) },
reopenLabel: options.reopenLabel,
title: handlers.confirmTitle ?? this._humanInteractionDefaultTitle(context),
})
this._humanInteractionPanels[context] = panel
BrazenViewLayer.appendToDockPanelStack(panel)
return panel
}
/**
* Show an HI panel and clear a stuck `bv-dock-panel-offscreen` (pointer-events: none).
* @param {BrazenDownloadManagerLaneId} context
* @param {HTMLElement} panel
* @private
*/
_showHumanInteractionPanelInteractive(context, panel)
{
if (!panel) {
return
}
BrazenViewLayer._clearDockSlidePanelHideTimer(panel)
if (BrazenViewLayer.isDockSlidePanelVisible(panel) &&
!panel.classList.contains('bv-dock-panel-offscreen')) {
return
}
this._framework._showDockSlidePanel(panel)
panel.classList.remove('bv-dock-panel-offscreen')
}
/**
* 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({
id: 'bv-human-interaction-panel',
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.appendToDockPanelStack(panel)
this._framework._showDockSlidePanel(panel)
}
/**
* Phase-1 queue Cloudflare tab: show Done — resume on the challenge media page itself
* (full DM init is halted on mediaCloudflare).
* @param {{
* title?: string,
* message?: string,
* confirmLabel?: string,
* confirmAndCloseLabel?: string,
* }} [options]
* @private
*/
_showQueueChallengeResumePanel(options = {})
{
let context = BrazenDownloadManager._peekHumanInteractionContextFromLocation() ?? 'download'
let panelId = context === 'resolution'
? 'bv-human-interaction-panel-resolution'
: 'bv-human-interaction-panel-download'
let handlers = this._config?.rateLimitHandlers?.[context]?.humanInteraction ?? {}
let title = options.title ?? handlers.confirmTitle ??
(context === 'resolution' ? 'Resolution verification required' : 'Cloudflare challenge')
let message = options.message ?? handlers.challengeConfirmMessage ??
'Complete the Cloudflare check on this page, then resume the download queue.'
// Label defaults live on View Layer createHumanInteractionPanel — omit when unset.
let confirmLabel = options.confirmLabel ?? handlers.confirmLabel
let confirmAndCloseLabel = options.confirmAndCloseLabel ?? handlers.confirmAndCloseLabel
let existing = this._humanInteractionPanels[context]
?? document.getElementById(panelId)
?? document.getElementById('bv-human-interaction-panel')
if (existing?.querySelector?.('.bv-human-interaction-confirm-close')) {
BrazenViewLayer.updateHumanInteractionPanelContent(existing, {
title,
message,
confirmLabel,
confirmAndCloseLabel,
})
existing.querySelector('.bv-human-interaction-reopen')?.remove()
this._humanInteractionPanels[context] = existing
this._showHumanInteractionPanelInteractive(context, existing)
return
}
// Leader Open/Done chrome (or a stale pane) — replace with challenge controls.
if (existing) {
this._framework._hideDockSlidePanel(existing)
existing.remove()
if (this._humanInteractionPanels[context] === existing) {
this._humanInteractionPanels[context] = null
}
}
let panel = BrazenViewLayer.createHumanInteractionPanel({
id: panelId,
showReopen: false,
title,
message,
confirmLabel,
confirmAndCloseLabel,
onConfirm: () => { void this._confirmHumanInteractionFromChallengePage(context, panel) },
onConfirmAndClose: () => {
void this._confirmHumanInteractionFromChallengePage(context, panel, {closeTab: true})
},
})
this._humanInteractionPanels[context] = panel
BrazenViewLayer.appendToDockPanelStack(panel)
this._showHumanInteractionPanelInteractive(context, panel)
}
/**
* Show the navigation-interrupted download decision panel (visible leader only).
* Click marks pending rows done and pauses the queue; timeout requeues and relaunches.
* @param {object[]|null|undefined} rows
* @private
*/
_showDownloadInterruptionPanel(rows)
{
if (!rows?.length || document.visibilityState !== 'visible' || this._documentSuspended) {
return
}
this._downloadInterruptionPending = true
this._downloadInterruptionRows = rows
this._hideDownloadInterruptionPanel({keepPending: true})
let count = rows.length
let panel = BrazenViewLayer.createDownloadInterruptionPanel({
message: count === 1
? 'A download was still in progress when this page loaded. Click below to mark it complete and pause the queue, or wait for the timer to relaunch it.'
: `${count} downloads were still in progress when this page loaded. Click below to mark them complete and pause the queue, or wait for the timer to relaunch them.`,
onComplete: () => { void this._completeDownloadInterruption() },
onTimeout: () => { void this._timeoutDownloadInterruption() },
})
this._downloadInterruptionPanel = panel
BrazenViewLayer.appendToDockPanelStack(panel)
this._framework._showDockSlidePanel(panel)
}
/**
* @param {{keepPending?: boolean}} [options]
* @private
*/
_hideDownloadInterruptionPanel(options = {})
{
let panel = this._downloadInterruptionPanel
?? document.getElementById('bv-download-interruption-panel')
if (panel) {
panel._bvTimedButtonApi?.cancel?.()
this._framework._hideDockSlidePanel(panel)
panel.remove()
}
this._downloadInterruptionPanel = null
if (!options.keepPending) {
this._downloadInterruptionPending = false
this._downloadInterruptionRows = null
}
}
/**
* User clicked the timed button: treat interrupted downloads as complete and pause.
* @return {Promise<void>}
* @private
*/
async _completeDownloadInterruption()
{
let rows = this._downloadInterruptionRows ?? []
this._hideDownloadInterruptionPanel()
if (!this._cm.canPersist()) {
return
}
for (let row of rows) {
let current = await this._repos().downloadQueue.get(row.itemId)
if (!current || current.status !== 'downloading') {
continue
}
current.status = 'done'
current.inProgress = false
current.error = null
await this._repos().downloadQueue.put(current)
await this._incrementDownloadProgress()
await this._clearTerminalQueueRows(current.itemId)
}
await this._withState((state) => {
state.paused = true
})
this._refreshDockProgress({refreshAllItems: true})
await this._syncFromStorage()
}
/**
* Timed button expired: requeue interrupted downloads and relaunch (today's default).
* @return {Promise<void>}
* @private
*/
async _timeoutDownloadInterruption()
{
let rows = this._downloadInterruptionRows ?? []
this._hideDownloadInterruptionPanel()
if (!this._cm.canPersist()) {
return
}
for (let row of rows) {
let current = await this._repos().downloadQueue.get(row.itemId)
if (!current || current.status !== 'downloading') {
continue
}
current.status = 'queued'
current.inProgress = false
current.error = null
await this._repos().downloadQueue.put(current)
}
this._signalProcessorsWake()
if (this._shouldRunProcessors()) {
void this._runProcessors()
}
}
/**
* Clear one HI lane from a Phase-1 challenge tab (CM/DM may not be fully initialized).
* Optional `closeTab` also calls `window.close()` after resume (may be blocked by the browser).
* @param {BrazenDownloadManagerLaneId} [context]
* @param {HTMLElement} [panel]
* @param {{closeTab?: boolean}} [options]
* @return {Promise<void>}
* @private
*/
async _confirmHumanInteractionFromChallengePage(context = null, panel = null, options = {})
{
let prefix = BrazenDownloadManager.storageKey(this._cm?._scriptPrefix, '')
let state = null
let repos = null
try {
if (typeof BrazenStorageRepositories === 'function') {
repos = new BrazenStorageRepositories(prefix)
if (repos.storage.available) {
await repos.storage.open()
state = await repos.downloadManagerState.get()
}
}
} catch (e) {
// ignore — fall through to URL / default context
}
let ctx = context
?? BrazenDownloadManager._peekHumanInteractionContextFromLocation()
?? BrazenDownloadManager._resolveChallengeHumanInteractionContext(state)
?? 'download'
let el = panel
?? document.getElementById(
ctx === 'resolution'
? 'bv-human-interaction-panel-resolution'
: 'bv-human-interaction-panel-download',
)
?? document.getElementById('bv-human-interaction-panel')
if (el) {
this._framework._hideDockSlidePanel(el)
}
try {
if (repos?.storage?.available && state) {
// Migrate + clear the specified lane only.
if (!state.humanInteraction || typeof state.humanInteraction !== 'object') {
state.humanInteraction = {resolution: null, download: null}
}
if (state.humanInteractionBlocked) {
let legacyCtx = state.humanInteractionContext === 'download' ? 'download' : 'resolution'
if (!state.humanInteraction[legacyCtx]) {
state.humanInteraction[legacyCtx] = {
itemId: state.humanInteractionItemId ?? null,
promptTabId: state.humanInteractionPromptTabId ?? null,
openUrl: state.humanInteractionOpenUrl ?? null,
at: Date.now(),
}
}
}
state.humanInteraction[ctx] = null
delete state.humanInteractionBlocked
delete state.humanInteractionContext
delete state.humanInteractionItemId
delete state.humanInteractionPromptTabId
delete state.humanInteractionOpenUrl
state.id = 'state'
await repos.downloadManagerState.put(state)
this._cachedState = state
this._writeHumanInteractionBlockMirror(
BrazenDownloadManager._anyHumanInteractionState(state),
)
}
} catch (e) {
// ignore IDB failures on challenge tabs
this._writeHumanInteractionBlockMirror(false)
}
this._signalProcessorsWake()
if (typeof this._shouldRunProcessors === 'function' && this._shouldRunProcessors()) {
void this._runProcessors()
}
if (!options.closeTab) {
return
}
try {
window.close()
} catch (e) {
// ignore — browsers often block close for non-script-opened tabs
}
// Resume already succeeded; if close was ignored, surface a short note.
await new Promise((resolve) => setTimeout(resolve, 50))
if (window.closed || !el) {
return
}
BrazenViewLayer.updateHumanInteractionPanelContent(el, {
message: 'Queue resumed. Close this tab manually if it stays open.',
})
el.querySelector('.bv-dock-list-footer')?.replaceChildren()
this._framework._showDockSlidePanel(el)
}
/**
* @param {BrazenDownloadManagerLaneId} context
* @return {Promise<void>}
* @private
*/
async _confirmHumanInteractionResume(context)
{
this._hideHumanInteractionPanel(context)
await this._clearHumanInteractionBlock(context)
// Confirm may run on a follower — wake an idle leader elsewhere.
this._signalProcessorsWake()
if (this._shouldRunProcessors()) {
void this._runProcessors()
}
}
/**
* Re-open the verification URL if the user closed that tab by mistake.
* Opens synchronously from the sync cache so the click keeps user activation.
* @param {BrazenDownloadManagerLaneId} context
* @return {Promise<void>}
* @private
*/
async _reopenHumanInteractionTab(context)
{
let openUrl = this._hiLane(this._getStateSync(), context)?.openUrl
if (openUrl) {
this._openHumanInteractionTab(this._markHumanInteractionOpenUrl(openUrl, context))
return
}
// Cache miss — await may lose transient user activation; best-effort only.
let state = await this._getState()
openUrl = this._hiLane(state, context)?.openUrl
if (openUrl) {
this._openHumanInteractionTab(this._markHumanInteractionOpenUrl(openUrl, context))
return
}
let panel = this._humanInteractionPanels[context]
if (panel) {
BrazenViewLayer.updateHumanInteractionPanelContent(panel, {
message: 'Could not open the challenge tab (missing URL). Try Done — resume, or reload this page.',
})
}
}
/**
* @param {BrazenDownloadManagerLaneId} [context] omit to hide all lane panels
* @private
*/
_hideHumanInteractionPanel(context = null)
{
if (context === 'resolution' || context === 'download') {
let panel = this._humanInteractionPanels[context]
if (panel) {
this._framework._hideDockSlidePanel(panel)
}
return
}
for (let ctx of /** @type {BrazenDownloadManagerLaneId[]} */ (['resolution', 'download'])) {
let panel = this._humanInteractionPanels[ctx]
if (panel) {
this._framework._hideDockSlidePanel(panel)
}
}
}
/**
* True when this tab may show the human-interaction resume panel.
* Any focused tab may claim it — including the queue verification media page
* where the CAPTCHA was cleared (processors still refuse leadership there).
* @return {boolean}
* @private
*/
_canOwnHumanInteractionPrompt()
{
return true
}
/**
* True when this document is a queue-opened Cloudflare / verification tab
* (`brazen_hi` marker). Same-origin opener alone is not enough — normal search/media
* tabs opened from another tab must still show Open Cloudflare Challenge.
* @return {boolean}
* @private
*/
_isQueueVerificationTab()
{
return BrazenDownloadManager.hasBrazenHumanInteractionMarker()
}
/**
* Hide local HI panels and drop ownership so another focused tab can claim them.
* Does not clear lane blocks — only Done — resume unlocks the queue.
* @param {{hideLocal?: boolean}} [options] When false, only clears IDB ownership (minimize / tab blur).
* @return {Promise<void>}
* @private
*/
async _releaseHumanInteractionPanelOwnership(options = {})
{
let hideLocal = options.hideLocal !== false
if (hideLocal) {
this._hideHumanInteractionPanel()
}
if (this._documentSuspended) {
return
}
let stillBlocked = false
await this._withState((state) => {
this._ensureHumanInteractionMap(state)
for (let ctx of /** @type {BrazenDownloadManagerLaneId[]} */ (['resolution', 'download'])) {
let lane = state.humanInteraction[ctx]
if (lane?.promptTabId === this._tabId) {
lane.promptTabId = null
}
}
stillBlocked = this._anyHumanInteraction(state)
})
// Ownership drop alone does not notify peers — wake so a focused follower can steal now
// instead of waiting for the HI watchdog.
if (stillBlocked) {
this._signalProcessorsWake()
}
}
/**
* @param {BrazenDownloadManagerLaneId} context
* @return {Promise<void>}
* @private
*/
async _clearHumanInteractionBlock(context)
{
if (context !== 'resolution' && context !== 'download') {
return
}
this._hideHumanInteractionPanel(context)
// Stamp only the cleared pipeline's clock so resolution/download gaps stay independent.
this._stampInitiationClock(context, Date.now())
await this._withState((next) => {
this._ensureHumanInteractionMap(next)
next.humanInteraction[context] = null
delete next.humanInteractionBlocked
delete next.humanInteractionContext
delete next.humanInteractionItemId
delete next.humanInteractionPromptTabId
delete next.humanInteractionOpenUrl
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._writeHumanInteractionBlockMirror(this._anyHumanInteraction(next))
})
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; isDiscovered stays null).
* Discovery off → confirm as-is (`resolution`, sets typeEntryId and isDiscovered).
*
* @param {Record<string, string[]>|null|undefined} tagGroups
* @return {Promise<void>}
* @private
*/
async _registerResolvedTagGroups(tagGroups)
{
if (!this._cm.canPersist() || !tagGroups || typeof tagGroups !== 'object') {
return
}
let normalize = this._getDownloadTagNormalize()
let normalizedGroups = {}
for (let [typeName, names] of Object.entries(tagGroups)) {
if (!Array.isArray(names)) {
continue
}
let list = []
let seen = new Set()
for (let name of names) {
if (!name) {
continue
}
let normalized = normalize(name) || name
if (seen.has(normalized)) {
continue
}
seen.add(normalized)
list.push(normalized)
}
if (list.length) {
normalizedGroups[typeName] = list
}
}
let hasNames = Object.values(normalizedGroups).some((names) => names.some(Boolean))
if (!hasNames) {
return
}
let source = this._isTagDiscoveryActive() ? 'media' : 'resolution'
await this._cm.registerTypedTagGroups(normalizedGroups, 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
}
/**
* After discovery review: mark tags {@link TagEntry.isDiscovered} and promote seen types
* to {@link TagEntry.typeEntryId} when still unset (attributes may have typed earlier).
* @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) {
if (!tag?.name) {
continue
}
let entry = await tagRuntime.getTag(tag.name)
// Already discovered with a type — nothing left for Confirm.
if (entry?.isDiscovered === true && entry?.typeEntryId != null) {
continue
}
// Prefer existing type / lastSeen from resolution; fall back to the panel row's type.
let typeEntryId = null
let typeName = null
if (entry?.typeEntryId == null) {
typeEntryId = entry?.meta?.lastSeenTypeEntryId ?? null
if (typeEntryId == null && tag.type) {
typeName = tag.type
typeEntryId = await this._repos().tags.resolveCanonicalTypeEntryId(tag.type)
}
// Nothing to promote and nothing to discover-mark usefully — skip.
if (typeEntryId == null && !typeName) {
continue
}
}
// discovery-confirm sets isDiscovered even when typeEntryId was set via attributes.
await tagRuntime.ensureTag(tag.name, {
typeEntryId,
typeName,
source: 'discovery-confirm',
})
}
}
/**
* @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 (this.patternIncludesChip(pattern, chip)) {
relevantTypes.add(chip.token)
}
}
}
// Pattern pins always own scan scope. Optional tagDiscovery.tagTypes array is an
// allowlist intersect only — never a replacement that expands beyond pins.
let tagTypes = [...relevantTypes]
let configured = this._config.tagDiscovery.tagTypes
if (Array.isArray(configured) && configured.length) {
let allow = new Set(configured)
tagTypes = tagTypes.filter((type) => allow.has(type))
}
if (!tagTypes.length) {
return empty
}
let candidates = []
let incidences = tagIncidences && typeof tagIncidences === 'object' ? tagIncidences : {}
for (let type of tagTypes) {
for (let tag of tagGroups[type] ?? []) {
if (!tag) {
continue
}
candidates.push({
name: tag,
type,
count: this._resolveTagIncidence(tag, incidences),
})
}
}
// One TagRuntime ensure for the whole set — avoid per-tag IDB round-trips.
let tagRuntime = this._cm.getTagRuntime?.()
if (tagRuntime && candidates.length) {
try {
await tagRuntime.ensureNames(candidates.map((entry) => entry.name))
} catch (error) {
// Fall through to per-tag lookups.
}
}
let unknown = []
let known = []
for (let entry of candidates) {
if (await this._isTagKnownForDiscovery(entry.name)) {
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 has completed discovery review ({@link TagEntry.isDiscovered}).
* Type may be set earlier via attribute actions; discovery is gated separately.
* 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
}
let tagRuntime = this._cm.getTagRuntime?.()
let cached = tagRuntime?.resolveCachedByName?.(tagName)
if (cached) {
return cached.isDiscovered === true
}
if (!this._cm.canPersist()) {
return false
}
let tag = await this._repos().tags.getByName(tagName)
return !!(tag && tag.isDiscovered === true)
}
/**
* @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
}
/**
* @return {boolean}
* @private
*/
_isIgnoredPinReviewEnabled()
{
let review = this._config.tagDiscovery?.ignoredPinReview
if (review?.isEnabled) {
return !!Utilities.callEventHandler(review.isEnabled, [], false)
}
if (!this._cm.getField(OPTION_REVIEW_IGNORED_FILENAME_PINS)) {
return false
}
return !!this._framework._getConfig(OPTION_REVIEW_IGNORED_FILENAME_PINS)
}
/**
* @return {boolean}
* @private
*/
_isSkipEmptyFilenamePinsEnabled()
{
let skip = this._config.tagDiscovery?.skipEmptyFilenamePins
if (skip?.isEnabled) {
return !!Utilities.callEventHandler(skip.isEnabled, [], false)
}
if (!this._cm.getField(OPTION_SKIP_EMPTY_FILENAME_PINS)) {
return false
}
return !!this._framework._getConfig(OPTION_SKIP_EMPTY_FILENAME_PINS)
}
/**
* @return {boolean}
* @private
*/
_isDeferredTagDiscoveryEnabled()
{
if (!this._cm.getField(OPTION_DEFER_TAG_DISCOVERY)) {
return false
}
return !!this._framework._getConfig(OPTION_DEFER_TAG_DISCOVERY)
}
/**
* Defer unknown-tag and ignored-pin review to the discovery lane (batch only).
* @return {boolean}
* @private
*/
_shouldDeferTagDiscoveryReview()
{
return this._isDeferredTagDiscoveryEnabled() && this._isTagDiscoveryActive()
}
/**
* @return {Promise<number>}
* @private
*/
async _countDiscoveryQueuedItems()
{
return this._repos().downloadResolutionQueue.listByStatus('discoveryQueued').
then((rows) => rows.length)
}
/**
* Clear discovery-lane phase when no deferred rows remain.
* @return {Promise<void>}
* @private
*/
async _maybeEndDiscoveryLanePhase()
{
if (!this._isDeferredTagDiscoveryEnabled()) {
return
}
let pending = await this._countDiscoveryQueuedItems()
if (pending > 0) {
return
}
await this._withState((state) => {
state.discoveryLanePhaseActive = false
})
}
/**
* @param {object|null|undefined} state
* @return {Promise<object|null>}
* @private
*/
async _peekResolutionWork(state)
{
if (state?.resolutionBlocked) {
return null
}
let repo = this._repos().downloadResolutionQueue
if (!this._isDeferredTagDiscoveryEnabled()) {
return repo.peekNextQueued()
}
if (state?.discoveryLanePhaseActive) {
return repo.peekNextDiscoveryQueued()
}
let next = await repo.peekNextQueued()
if (next) {
return next
}
let deferred = await repo.peekNextDiscoveryQueued()
if (!deferred) {
return null
}
await this._withState((nextState) => {
nextState.discoveryLanePhaseActive = true
})
return deferred
}
/**
* @param {string} itemId
* @return {Promise<object|null>}
* @private
*/
async _claimResolutionWorkItem(itemId)
{
let row = await this._repos().downloadResolutionQueue.get(itemId)
if (!row) {
return null
}
if (row.status === 'queued' || row.status === 'discoveryQueued') {
row.status = 'resolving'
row.error = null
await this._repos().downloadResolutionQueue.put(row)
return row
}
return null
}
/**
* @param {object} item
* @return {Promise<void>}
* @private
*/
async _processResolutionWorkItem(item)
{
if (item.pendingTagGroups && this._isDeferredTagDiscoveryEnabled()) {
await this._activateDeferredDiscoveryItem(item)
return
}
await this._processResolutionItem(item)
}
/**
* Open tag review for a deferred discovery-lane item (no re-fetch).
* @param {object} item
* @return {Promise<void>}
* @private
*/
async _activateDeferredDiscoveryItem(item)
{
try {
let pending = item.pendingTagGroups
if (!pending?.mediaUrl) {
item.status = 'failed'
item.error = 'Deferred discovery item has no resolved payload'
if (await this._commitResolutionRow(item)) {
await this._incrementResolutionProgress()
}
return
}
let tagGroups = this._tagGroupsFromResolvedPayload(pending)
let tagIncidences = pending.tagIncidences ?? {}
if (this._isTagDiscoveryActive() && this._shouldRunTagDiscovery('mediaPost')) {
let lists = await this._partitionDiscoveryTags(tagGroups, tagIncidences)
if (lists.unknown.length) {
item.status = 'tagReview'
item.pendingTagGroups = pending
if (!(await this._commitResolutionRow(item))) {
return
}
await this._withState((state) => {
state.resolutionBlocked = true
state.resolutionBlockedItemId = item.itemId
state.discoveryReviewMode = 'unknown'
state.discoveryPanelTags = lists.unknown
state.discoveryPanelKnownTags = lists.known
})
let state = this._getStateSync()
await this._presentTagDiscoveryReview(state.discoveryPanelTags, state.discoveryPanelKnownTags)
return
}
}
if (await this._tryBeginIgnoredPinReview(item, pending, tagGroups, tagIncidences, {forceImmediate: true})) {
return
}
if (this._shouldSkipEmptyFilenamePins(tagGroups)) {
await this._dropResolvedWithoutDownload(item)
await this._maybeEndDiscoveryLanePhase()
return
}
if (!(await this._promoteToDownloadQueue(item, pending))) {
return
}
if (!(await this._removeResolutionRowIfActive(item.itemId))) {
return
}
await this._incrementResolutionProgress()
await this._maybeEndDiscoveryLanePhase()
} finally {
await this._onResolutionTaskComplete(item.itemId)
}
}
/**
* Tag-type tokens present as chips in the active filename/subfolder patterns.
* @return {string[]}
* @private
*/
_getPinnedFilenameTagTypes()
{
if (!this._config.downloadPaths?.getPatternResolver) {
return []
}
let resolver = this._config.downloadPaths.getPatternResolver()
let patterns = this._getActiveDownloadPatterns()
let pinned = []
let seen = new Set()
for (let chip of resolver.chips ?? []) {
if (!resolver.tagTypes.includes(chip.token) || seen.has(chip.token)) {
continue
}
if (!this._activePatternsIncludeTagType(patterns, chip.token, resolver.chips)) {
continue
}
seen.add(chip.token)
pinned.push(chip.token)
}
return pinned
}
/**
* True when any active filename/subfolder tag-type pin has at least one raw tag on the post.
* Filename Tag Ignore List is not applied.
* @param {{}} tagGroups
* @return {boolean}
* @private
*/
_hasRawTagsForAnyFilenamePin(tagGroups)
{
let groups = tagGroups && typeof tagGroups === 'object' ? tagGroups : {}
for (let type of this._getPinnedFilenameTagTypes()) {
if ((groups[type] ?? []).some(Boolean)) {
return true
}
}
return false
}
/**
* Queue-only: drop before promote when enabled and no pinned type has raw tags.
* No-op when patterns contain no tag-type pins.
* @param {{}} tagGroups
* @return {boolean}
* @private
*/
_shouldSkipEmptyFilenamePins(tagGroups)
{
if (!this._isSkipEmptyFilenamePinsEnabled()) {
return false
}
let pinned = this._getPinnedFilenameTagTypes()
if (!pinned.length) {
return false
}
return !this._hasRawTagsForAnyFilenamePin(tagGroups)
}
/**
* Drop a resolved resolution item without promoting to the download queue.
* @param {object} item
* @param {{clearDiscoveryGate?: boolean}} [options]
* @return {Promise<void>}
* @private
*/
async _dropResolvedWithoutDownload(item, options = {})
{
let itemId = item?.itemId
if (!itemId) {
return
}
let removed = await this._removeResolutionRowIfActive(itemId)
if (!removed) {
let existing = await this._repos().downloadResolutionQueue.get(itemId)
if (existing) {
await this._repos().downloadResolutionQueue.remove(itemId)
removed = true
}
}
let element = this._trackedItemElements.get(String(itemId))
this._untrackItemElement(itemId)
if (element) {
this._setItemProgress(element, null)
}
if (options.clearDiscoveryGate) {
await this._withState((next) => {
this._clearDiscoveryReviewState(next)
})
await this._syncFromStorage()
}
if (removed) {
await this._incrementResolutionProgress()
}
}
/**
* True when any active filename/subfolder pattern includes a chip for {@link type}.
* @param {string[]} patterns
* @param {string} type
* @param {{token: string, label: string}[]} chips
* @return {boolean}
* @private
*/
_activePatternsIncludeTagType(patterns, type, chips)
{
for (let chip of chips ?? []) {
if (chip.token !== type) {
continue
}
for (let pattern of patterns) {
if (this.patternIncludesChip(pattern, chip)) {
return true
}
}
}
return false
}
/**
* Resolve `tagGroups` from a resolution payload (nested or flat).
* @param {object|null|undefined} resolved
* @return {{}}
* @private
*/
_tagGroupsFromResolvedPayload(resolved)
{
if (!resolved || typeof resolved !== 'object') {
return {}
}
if (resolved.tagGroups && typeof resolved.tagGroups === 'object' && !Array.isArray(resolved.tagGroups)) {
return resolved.tagGroups
}
// Defensive: older/flat payloads stored groups at the top level.
if (Array.isArray(resolved.author) || Array.isArray(resolved.character) ||
Array.isArray(resolved.copyright) || Array.isArray(resolved.general)) {
return resolved
}
return {}
}
/**
* Whether a post tag is filename-ignored — same source as the Aa ignore button
* ({@link BrazenConfigurationManager.hasTagSoleAttribute} / shared cache row).
* When strip-character-series is on, an ignore on the series-stripped base also counts.
* @param {string} name
* @param {object} resolver
* @param {string|null} [type]
* @return {boolean}
* @private
*/
_isFilenameIgnoredTag(name, resolver, type = null)
{
if (!name) {
return false
}
let normalize = typeof resolver?.normalizeTag === 'function' ?
resolver.normalizeTag :
this._getDownloadTagNormalize()
let normalized = normalize(name)
if (!normalized) {
return false
}
let isIgnoredName = (candidate) => {
if (!candidate) {
return false
}
if (this._cm?.canPersist?.()) {
return !!this._cm.hasTagSoleAttribute('filename-tag-ignore-list', candidate)
}
let ignore = resolver?.ignore
return !!(ignore?.size && (ignore.has(candidate) || ignore.has(normalize(candidate))))
}
if (isIgnoredName(normalized) || (normalized !== name && isIgnoredName(name))) {
return true
}
if (resolver?.stripCharacterSeries && (type === 'character' || type == null)) {
let stripped = normalized.replace(/_\([^)]*\)$/, '')
if (stripped && stripped !== normalized && isIgnoredName(stripped)) {
return true
}
}
return false
}
/**
* Filename-ignored tags for active filename/subfolder tag-type pins that are blocking.
* A pin type is blocking when it has raw tags, the download-path join is empty with ignore
* applied, and a no-ignore join is non-empty (ignore emptied the slot; substitution-only
* empties do not block). Lists filename-ignored tags only for those blocking types.
* `pinJoinEmpty` is true only when `tags.length > 0`.
*
* @param {{}} tagGroups
* @param {Record<string, number|string>|null|undefined} [tagIncidences]
* @return {Promise<{tags: Array<{name: string, type: string|null, count: number|null}>, pinJoinEmpty: boolean}>}
* @private
*/
async _collectIgnoredFilenamePinTags(tagGroups, tagIncidences = {})
{
let empty = {tags: [], pinJoinEmpty: false}
if (!this._isIgnoredPinReviewEnabled() || !this._config.downloadPaths?.getPatternResolver) {
return empty
}
let types = this._getPinnedFilenameTagTypes()
if (!types.length) {
return empty
}
await this._ensureDownloadPathTagsLoaded(tagGroups)
let resolver = this._config.downloadPaths.getPatternResolver()
let normalize = typeof resolver?.normalizeTag === 'function' ?
resolver.normalizeTag :
this._getDownloadTagNormalize()
let incidences = tagIncidences && typeof tagIncidences === 'object' ? tagIncidences : {}
/** @type {string[]} */
let blockingTypes = []
for (let type of types) {
let raw = (tagGroups[type] ?? []).filter(Boolean)
if (!raw.length) {
continue
}
// Product truth: slot empty after ignore/substitution — same join as the download path.
let joined = this._joinTagsForDownloadPath(raw, type, resolver.ignore, resolver)
if (joined) {
continue
}
// Substitution-only empties do not block; ignore-caused empties do.
let joinedNoIgnore = this._joinTagsForDownloadPath(raw, type, resolver.ignore, {
...resolver,
applyFilenameIgnore: false,
})
if (!joinedNoIgnore) {
continue
}
blockingTypes.push(type)
}
if (!blockingTypes.length) {
return empty
}
let collected = []
let seen = new Set()
for (let type of blockingTypes) {
let raw = (tagGroups[type] ?? []).filter(Boolean)
for (let name of raw) {
if (!this._isFilenameIgnoredTag(name, resolver, type)) {
continue
}
let normalized = normalize(name) || name
if (seen.has(normalized)) {
continue
}
seen.add(normalized)
collected.push({
name: normalized,
type,
count: this._resolveTagIncidence(normalized, incidences) ??
this._resolveTagIncidence(name, incidences),
})
}
}
if (!collected.length) {
return empty
}
return {tags: collected, pinJoinEmpty: true}
}
/**
* Block on ignored filename pins when configured.
*
* @param {object} item
* @param {object} resolved
* @param {{}} tagGroups
* @param {Record<string, number|string>} tagIncidences
* @param {{forceImmediate?: boolean}} [options]
* @return {Promise<boolean>} true if review panel was opened or item was deferred
* @private
*/
async _tryBeginIgnoredPinReview(item, resolved, tagGroups, tagIncidences, options = {})
{
let ignoredPin = await this._collectIgnoredFilenamePinTags(tagGroups, tagIncidences)
let ignoredTags = ignoredPin.tags
// Gate only when ignore-caused empty join with listable ignored tags.
if (!ignoredPin.pinJoinEmpty || !ignoredTags.length) {
return false
}
if (this._shouldDeferTagDiscoveryReview() && !options.forceImmediate) {
item.status = 'discoveryQueued'
item.pendingTagGroups = resolved
if (!(await this._commitResolutionRow(item))) {
return false
}
return true
}
item.status = 'tagReview'
item.pendingTagGroups = resolved
if (!(await this._commitResolutionRow(item))) {
// Claim lost — do not report handled (caller may still promote / retry).
return false
}
await this._withState((state) => {
state.resolutionBlocked = true
state.resolutionBlockedItemId = item.itemId
state.discoveryReviewMode = 'ignoredPins'
state.discoveryPanelTags = ignoredTags
state.discoveryPanelKnownTags = []
})
await this._presentTagDiscoveryReview(ignoredTags, [])
return true
}
/**
* Open tag discovery on this tab if visible, and wake other tabs so a focused
* non-leader can claim the panel when the processor leader is in the background.
* @param {Array} tags
* @param {Array|null|undefined} [knownTags]
* @return {Promise<void>}
* @private
*/
async _presentTagDiscoveryReview(tags, knownTags = null)
{
await this._showTagDiscoveryPanel(tags, knownTags)
this._signalProcessorsWake()
}
/**
* Show the tag-discovery panel on the focused tab. Visible tabs may steal
* `tagDiscoveryPanelTabId` so opening another search tab cannot leave review
* locked on a background owner with no UI.
* @param {Array} tags
* @param {Array|null|undefined} [knownTags]
* @return {Promise<void>}
* @private
*/
async _showTagDiscoveryPanel(tags, knownTags = null)
{
if (document.visibilityState !== 'visible') {
return
}
await this._withState((state) => {
// Caller already gated on visibility — focused tab always claims / steals.
state.tagDiscoveryPanelTabId = this._tabId
if (knownTags != null) {
state.discoveryPanelKnownTags = knownTags
}
})
let state = this._getStateSync()
let known = state?.discoveryPanelKnownTags ?? knownTags ?? []
let tagRuntime = this._cm.getTagRuntime()
if (tagRuntime) {
let names = []
for (let tag of [...(tags ?? []), ...known]) {
if (tag?.name) {
names.push(tag.name)
}
}
await tagRuntime.ensureNames(names)
}
this._renderTagDiscoveryPanel(tags, known)
}
/**
* Hide the local panel and drop ownership so another focused tab can claim it.
* Does not clear `resolutionBlocked` — only Confirm / Skip unlock the queue.
* @param {{hideLocal?: boolean}} [options] When false, only clears IDB ownership (minimize / tab blur).
* @return {Promise<void>}
* @private
*/
async _releaseTagDiscoveryPanelOwnership(options = {})
{
let hideLocal = options.hideLocal !== false
if (hideLocal) {
this._hideTagDiscoveryPanel()
}
if (this._documentSuspended) {
return
}
await this._withState((state) => {
if (state.tagDiscoveryPanelTabId === this._tabId) {
state.tagDiscoveryPanelTabId = null
}
})
}
/**
* @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.appendToDockPanelStack(this._tagDiscoveryPanel)
}
let known = knownTags ?? this._getStateSync()?.discoveryPanelKnownTags ?? []
let reviewMode = this._getStateSync()?.discoveryReviewMode ?? 'unknown'
let panelOptions = {
...(this._config.tagDiscovery?.panel ?? {}),
knownTags: known,
onTagNameClick: (tag) => this._openTagDiscoverySearch(tag),
resetSimilar: true,
intro: reviewMode === 'ignoredPins'
? 'Filename tag slots are empty after ignore. Review ignored tags or similar matches, then confirm to continue.'
: 'New tags were found. Review actions, then confirm to continue.',
unknownSectionLabel: reviewMode === 'ignoredPins' ? 'Ignored tags' : undefined,
}
if (reviewMode === 'ignoredPins') {
panelOptions.similarOpen = true
panelOptions.similarShowCount = false
}
let fingerprint = this._discoverySimilarFingerprint(tags, known)
let sameFingerprint = fingerprint === this._similarDiscoveryFingerprint
let reuseSimilar = sameFingerprint && Array.isArray(this._similarDiscoveryCachedTags)
// New tag set → soft-reset Similar (keep open + count) instead of collapsing/flashing.
panelOptions.resetSimilar = !sameFingerprint
panelOptions.preserveSimilarOpen = true
panelOptions.clearSimilarCount = false
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()
} else {
this._framework._showDockSlidePanel(this._tagDiscoveryPanel)
}
if (reuseSimilar) {
// Attribute-only refresh: repaint cached similar rows (action chrome) without IDB scan.
let excluded = this._buildSimilarDiscoveryExcludedNames(tags, known)
let cached = (this._similarDiscoveryCachedTags ?? []).filter((tag) => !excluded.has(tag.name))
this._similarDiscoveryCachedTags = cached
void this._paintSimilarDiscoveryTags(cached, panelOptions)
return
}
if (sameFingerprint && this._similarDiscoveryRaf != null) {
// Prefetch already scheduled for this tag set — do not restart.
return
}
// Same fingerprint with no cache (cancelled mid-load) or a new fingerprint → (re)load.
this._scheduleSimilarDiscoveryLoad(tags, known, fingerprint)
}
/**
* Open the consumer search-list URL for a discovery-panel tag name.
* @param {{name: string, type?: string|null}} tag
* @private
*/
_openTagDiscoverySearch(tag)
{
let discovery = this._config.tagDiscovery ?? {}
let actions = discovery.actions ?? {}
let buildUrl = actions.bookmark?.buildUrl
if (!buildUrl || !tag?.name) {
return
}
let normalize = actions.normalize ?? discovery.normalizeTag ?? ((name) => name)
let url = buildUrl(normalize(tag.name))
if (url) {
window.open(url, '_blank')
}
}
/**
* @param {Array} tags
* @param {Array} knownTags
* @return {string}
* @private
*/
_discoverySimilarFingerprint(tags, knownTags)
{
let newKey = (Array.isArray(tags) ? tags : [])
.map((tag) => `${tag?.type ?? ''}\0${tag?.name ?? ''}`)
.sort()
.join('\n')
let knownKey = (Array.isArray(knownTags) ? knownTags : [])
.map((tag) => tag?.name ?? '')
.sort()
.join('\n')
return newKey + '\n---\n' + knownKey
}
/**
* Cancel in-flight similar prefetch. Keeps a completed cache so hide/restore
* (e.g. brief visibility loss) can reuse rows without a Loading flash.
* @private
*/
_cancelSimilarDiscoveryLoad()
{
this._similarDiscoveryLoadId++
if (this._similarDiscoveryRaf != null) {
cancelAnimationFrame(this._similarDiscoveryRaf)
this._similarDiscoveryRaf = null
}
if (!Array.isArray(this._similarDiscoveryCachedTags)) {
// Incomplete load — allow the next render to schedule a fresh prefetch.
this._similarDiscoveryFingerprint = null
}
}
/**
* Names already on the discovery panel (New + Known) — never suggest these as Similar.
* Includes raw and consumer-normalized forms when a normalize hook exists.
*
* @param {Array} tags
* @param {Array} knownTags
* @return {Set<string>}
* @private
*/
_buildSimilarDiscoveryExcludedNames(tags, knownTags)
{
let discovery = this._config.tagDiscovery ?? {}
let normalize = discovery.actions?.normalize ?? discovery.normalizeTag ?? null
let excluded = new Set()
let add = (name) => {
if (!name) {
return
}
excluded.add(name)
if (typeof normalize === 'function') {
let normalized = normalize(name)
if (normalized) {
excluded.add(normalized)
}
}
}
for (let tag of Array.isArray(tags) ? tags : []) {
add(tag?.name)
}
for (let tag of Array.isArray(knownTags) ? knownTags : []) {
add(tag?.name)
}
return excluded
}
/**
* Ensure TagRuntime rows for similar names, then paint Similar section actions.
* @param {Array<{name: string, type: string|null, count?: number|null}>} similarTags
* @param {object} panelOptions
* @return {Promise<void>}
* @private
*/
async _paintSimilarDiscoveryTags(similarTags, panelOptions)
{
if (!this._tagDiscoveryPanel) {
return
}
let tags = Array.isArray(similarTags) ? similarTags.filter((tag) => tag?.name) : []
let tagRuntime = this._cm.getTagRuntime()
if (tagRuntime && tags.length) {
await tagRuntime.ensureNames(tags.map((tag) => tag.name))
}
if (!this._tagDiscoveryPanel) {
return
}
BrazenViewLayer.renderTagDiscoverySimilarContent(
this._tagDiscoveryPanel,
tags,
(row, tag, actionsElement) => {
this._appendTagDiscoveryFrameworkActions(row, tag, actionsElement)
},
panelOptions,
)
}
/**
* After paint: prefetch similar registry tags for the current new-tag set.
* @param {Array} tags
* @param {Array} knownTags
* @param {string} fingerprint
* @private
*/
_scheduleSimilarDiscoveryLoad(tags, knownTags, fingerprint)
{
if (this._similarDiscoveryRaf != null) {
cancelAnimationFrame(this._similarDiscoveryRaf)
this._similarDiscoveryRaf = null
}
let loadId = ++this._similarDiscoveryLoadId
this._similarDiscoveryFingerprint = fingerprint
this._similarDiscoveryCachedTags = null
this._similarDiscoveryRaf = requestAnimationFrame(() => {
this._similarDiscoveryRaf = null
void this._loadSimilarDiscoveryTags(loadId, tags, knownTags, fingerprint)
})
}
/**
* Strip bracket segments and split snake_case tokens for similarity scoring.
* @param {string} name
* @return {string[]}
* @private
*/
_normalizeTagTokensForSimilarity(name)
{
let cleaned = String(name ?? '').replace(/\([^)]*\)|\[[^\]]*\]/g, '')
cleaned = cleaned.replace(/_+/g, '_').replace(/^_|_$/g, '')
if (!cleaned) {
return []
}
return cleaned.split('_').filter(Boolean)
}
/**
* Coverage of the longest contiguous shared token run over the shorter joined length.
* @param {string[]} tokensA
* @param {string[]} tokensB
* @return {number}
* @private
*/
_scoreTagTokensSimilarity(tokensA, tokensB)
{
if (!tokensA?.length || !tokensB?.length) {
return 0
}
let bestLen = 0
let bestJoined = 0
for (let i = 0; i < tokensA.length; i++) {
for (let j = 0; j < tokensB.length; j++) {
let k = 0
while (i + k < tokensA.length && j + k < tokensB.length && tokensA[i + k] === tokensB[j + k]) {
k++
}
if (k > bestLen) {
bestLen = k
bestJoined = tokensA.slice(i, i + k).join('_').length
}
}
}
if (!bestLen) {
return 0
}
let shorter = Math.min(tokensA.join('_').length, tokensB.join('_').length)
return shorter > 0 ? bestJoined / shorter : 0
}
/**
* Keep at most {@link SIMILAR_DISCOVERY_LIMIT} unique candidates (by name), ranked by score.
* @param {Array<{name: string, type: string|null, count: null, score: number}>} top
* @param {{name: string, type: string|null, count: null, score: number}} candidate
* @private
*/
_considerSimilarDiscoveryCandidate(top, candidate)
{
let existingIdx = top.findIndex((row) => row.name === candidate.name)
if (existingIdx >= 0) {
if (candidate.score > top[existingIdx].score) {
top[existingIdx] = candidate
top.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name))
}
return
}
if (top.length < SIMILAR_DISCOVERY_LIMIT) {
top.push(candidate)
top.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name))
return
}
let worst = top[top.length - 1]
if (candidate.score < worst.score ||
(candidate.score === worst.score && candidate.name >= worst.name)) {
return
}
top[top.length - 1] = candidate
top.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name))
}
/**
* @param {number} loadId
* @param {Array} tags
* @param {Array} knownTags
* @param {string} fingerprint
* @return {Promise<void>}
* @private
*/
async _loadSimilarDiscoveryTags(loadId, tags, knownTags, fingerprint)
{
if (!this._tagDiscoveryPanel || loadId !== this._similarDiscoveryLoadId) {
return
}
let newTags = Array.isArray(tags) ? tags.filter((tag) => tag?.name) : []
let reviewMode = this._getStateSync()?.discoveryReviewMode ?? 'unknown'
let panelOptions = {
...(this._config.tagDiscovery?.panel ?? {}),
onTagNameClick: (tag) => this._openTagDiscoverySearch(tag),
}
if (reviewMode === 'ignoredPins') {
panelOptions.similarOpen = true
panelOptions.similarShowCount = false
}
let finish = async (similar) => {
if (loadId !== this._similarDiscoveryLoadId) {
return
}
let excluded = this._buildSimilarDiscoveryExcludedNames(tags, knownTags)
let filtered = (Array.isArray(similar) ? similar : []).filter((tag) => tag?.name && !excluded.has(tag.name))
this._similarDiscoveryCachedTags = filtered
this._similarDiscoveryFingerprint = fingerprint
await this._paintSimilarDiscoveryTags(filtered, panelOptions)
if (loadId !== this._similarDiscoveryLoadId) {
return
}
}
if (!newTags.length || !this._cm.canPersist()) {
await finish([])
return
}
// Exclude every name already listed under New tags or Known tags.
let excluded = this._buildSimilarDiscoveryExcludedNames(newTags, knownTags)
/** @type {Array<{name: string, type: string|null, count: null, score: number}>} */
let top = []
/** @type {Map<string, Array<{name: string, tokens: string[]}>>} */
let tagsByType = new Map()
for (let tag of newTags) {
let type = tag.type ?? 'unknown'
if (!tagsByType.has(type)) {
tagsByType.set(type, [])
}
tagsByType.get(type).push({
name: tag.name,
tokens: this._normalizeTagTokensForSimilarity(tag.name),
})
}
let tagRepo = this._repos()?.tags
if (!tagRepo) {
await finish([])
return
}
for (let [type, typeNewTags] of tagsByType) {
if (loadId !== this._similarDiscoveryLoadId) {
return
}
let typeEntryId = type && type !== 'unknown'
? await tagRepo.resolveCanonicalTypeEntryId(type)
: null
if (typeEntryId == null) {
continue
}
let discovery = this._config.tagDiscovery ?? {}
let normalize = discovery.actions?.normalize ?? discovery.normalizeTag
let cursor = null
while (true) {
if (loadId !== this._similarDiscoveryLoadId) {
return
}
let page = await tagRepo.listTags({
typeEntryId,
limit: SIMILAR_DISCOVERY_PAGE_SIZE,
cursor,
})
let entries = page.entries ?? []
let nextCursor = page.nextCursor
for (let entry of entries) {
let candidateName = entry?.name
if (!candidateName || excluded.has(candidateName)) {
continue
}
if (typeof normalize === 'function') {
let normalizedCandidate = normalize(candidateName)
if (normalizedCandidate && excluded.has(normalizedCandidate)) {
continue
}
}
let candidateTokens = this._normalizeTagTokensForSimilarity(candidateName)
let bestScore = 0
for (let newTag of typeNewTags) {
let score = this._scoreTagTokensSimilarity(newTag.tokens, candidateTokens)
if (score > bestScore) {
bestScore = score
}
}
if (bestScore <= 0.5) {
continue
}
this._considerSimilarDiscoveryCandidate(top, {
name: candidateName,
type,
count: null,
score: bestScore,
})
}
if (!nextCursor) {
break
}
cursor = nextCursor
await Utilities.sleep(0)
}
}
await finish(top.map(({name, type, count}) => ({name, type, count})))
}
/**
* 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)
{
actionsElement.replaceChildren()
let discovery = this._config.tagDiscovery ?? {}
let actions = discovery.actions ?? {}
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.classList.toggle('bv-tag-muted', !!state.muted)
row.classList.toggle('bv-tag-hidden', !!state.hidden)
for (let className of actions.extraMutedClasses ?? discovery.extraMutedClasses ?? []) {
row.classList.toggle(className, !!state.muted)
}
for (let className of actions.extraHiddenClasses ?? discovery.extraHiddenClasses ?? []) {
row.classList.toggle(className, !!state.hidden)
}
}
/**
* Debounce discovery panel re-renders so attribute clicks finish before DOM swap
* (synchronous replace mid-click toggles the Similar <details>).
* @param {string[]|null} [onlyTagNames] When a non-empty array, refresh only if the open
* panel lists one of those names. `null`/omitted = unrestricted.
* @private
*/
_scheduleTagDiscoveryPanelRefresh(onlyTagNames = null)
{
if (this._tagDiscoveryActionInFlight) {
return
}
if (onlyTagNames == null) {
this._tagDiscoveryRefreshOnlyTags = null
} else if (Array.isArray(onlyTagNames) && onlyTagNames.length) {
if (this._tagDiscoveryRefreshOnlyTags == null && this._tagDiscoveryRefreshTimer != null) {
// A prior unrestricted schedule already queued — keep unrestricted.
} else if (this._tagDiscoveryRefreshOnlyTags == null && this._tagDiscoveryRefreshTimer == null) {
this._tagDiscoveryRefreshOnlyTags = onlyTagNames.slice()
} else if (Array.isArray(this._tagDiscoveryRefreshOnlyTags)) {
let seen = new Set(this._tagDiscoveryRefreshOnlyTags)
for (let name of onlyTagNames) {
if (name && !seen.has(name)) {
seen.add(name)
this._tagDiscoveryRefreshOnlyTags.push(name)
}
}
}
}
if (this._tagDiscoveryRefreshTimer != null) {
clearTimeout(this._tagDiscoveryRefreshTimer)
}
this._tagDiscoveryRefreshTimer = setTimeout(() => {
this._tagDiscoveryRefreshTimer = null
this._refreshTagDiscoveryPanel()
}, 0)
}
/**
* @private
*/
_refreshTagDiscoveryPanel()
{
if (this._tagDiscoveryActionInFlight) {
return
}
let onlyTags = this._tagDiscoveryRefreshOnlyTags
this._tagDiscoveryRefreshOnlyTags = undefined
let state = this._getStateSync()
if (!state?.resolutionBlocked || state.tagDiscoveryPanelTabId !== this._tabId) {
return
}
if (!state.discoveryPanelTags?.length) {
return
}
if (Array.isArray(onlyTags) && onlyTags.length &&
!this._discoveryOpenTagNamesContainsAny(onlyTags, state)) {
return
}
// Keep the open review list on attribute toggles (including ignored-pin un-ignore).
// Re-collect only on Confirm / restore — do not drop rows the moment Aa is cleared.
this._renderTagDiscoveryPanel(state.discoveryPanelTags, state.discoveryPanelKnownTags ?? [])
}
/**
* Re-collect ignored-pin rows for restore / Confirm (not attribute toggles).
* Attribute toggles keep the open list and only refresh action chrome.
* @return {Promise<void>}
* @private
*/
async _refreshIgnoredPinDiscoveryPanel()
{
let state = this._getStateSync()
if (!state?.resolutionBlocked || state.discoveryReviewMode !== 'ignoredPins' ||
state.tagDiscoveryPanelTabId !== this._tabId) {
return
}
let refreshGen = (this._ignoredPinRefreshGen = (this._ignoredPinRefreshGen ?? 0) + 1)
let itemId = state.resolutionBlockedItemId
if (!itemId) {
// Gate with no subject — unlock so resolution cannot sit in Waiting forever.
this._hideTagDiscoveryPanel()
await this._withState((next) => {
this._clearDiscoveryReviewState(next)
})
this._resumeProcessorsAfterDiscoveryReview()
return
}
let queueItem = await this._repos().downloadResolutionQueue.get(itemId)
if (refreshGen !== this._ignoredPinRefreshGen) {
return
}
let resolved = queueItem?.pendingTagGroups
if (!resolved) {
// Blocked item missing or no longer in tagReview — clear the orphan gate.
this._hideTagDiscoveryPanel()
await this._withState((next) => {
this._clearDiscoveryReviewState(next)
})
this._resumeProcessorsAfterDiscoveryReview()
return
}
let tagGroups = this._tagGroupsFromResolvedPayload(resolved)
let tagIncidences = resolved.tagIncidences ?? {}
let ignoredPin = await this._collectIgnoredFilenamePinTags(tagGroups, tagIncidences)
if (refreshGen !== this._ignoredPinRefreshGen) {
return
}
let ignoredTags = ignoredPin.tags
await this._withState((next) => {
next.discoveryPanelTags = ignoredTags
next.discoveryPanelKnownTags = []
})
// No actionable ignored rows (or substitution-only empty) — continue.
if (!ignoredPin.pinJoinEmpty || !ignoredTags.length) {
if (queueItem) {
await this._finishResolutionAfterTagDiscovery(queueItem)
} else {
this._hideTagDiscoveryPanel()
await this._withState((next) => {
this._clearDiscoveryReviewState(next)
})
this._resumeProcessorsAfterDiscoveryReview()
}
return
}
this._renderTagDiscoveryPanel(ignoredTags, [])
}
/**
* Re-render tag discovery panel rows (e.g. after bookmark/rule toggles).
* @param {string[]|null} [onlyTagNames] When set, no-op unless the open panel lists one.
*/
refreshTagDiscoveryPanel(onlyTagNames = null)
{
this._scheduleTagDiscoveryPanelRefresh(onlyTagNames)
}
/**
* @param {string|string[]} tags
* @return {boolean}
*/
openTagDiscoveryContainsAnyTag(tags)
{
let names = Array.isArray(tags) ? tags : (tags != null && tags !== '' ? [tags] : [])
if (!names.length) {
return false
}
let state = this._getStateSync()
if (!state?.resolutionBlocked || state.tagDiscoveryPanelTabId !== this._tabId ||
!state.discoveryPanelTags?.length) {
return false
}
return this._discoveryOpenTagNamesContainsAny(names, state)
}
/**
* Raw + consumer-normalized tag names currently shown on the open discovery panel.
* @param {object|null|undefined} [state]
* @return {Set<string>}
* @private
*/
_buildDiscoveryOpenTagNames(state = null)
{
state = state ?? this._getStateSync()
let discovery = this._config.tagDiscovery ?? {}
let normalize = discovery.actions?.normalize ?? discovery.normalizeTag ?? null
let openNames = new Set()
let add = (name) => {
if (!name) {
return
}
openNames.add(name)
if (typeof normalize === 'function') {
let normalized = normalize(name)
if (normalized) {
openNames.add(normalized)
}
}
}
for (let tag of state?.discoveryPanelTags ?? []) {
add(tag?.name)
}
for (let tag of state?.discoveryPanelKnownTags ?? []) {
add(tag?.name)
}
for (let tag of this._similarDiscoveryCachedTags ?? []) {
add(tag?.name)
}
return openNames
}
/**
* @param {string|string[]} names Notify/detail tag names (often normalized).
* @param {object|null|undefined} [state]
* @return {boolean}
* @private
*/
_discoveryOpenTagNamesContainsAny(names, state = null)
{
let openNames = this._buildDiscoveryOpenTagNames(state)
let list = Array.isArray(names) ? names : (names != null && names !== '' ? [names] : [])
let discovery = this._config.tagDiscovery ?? {}
let normalize = discovery.actions?.normalize ?? discovery.normalizeTag ?? null
return list.some((name) => {
if (!name) {
return false
}
if (openNames.has(name)) {
return true
}
if (typeof normalize === 'function') {
let normalized = normalize(name)
if (normalized && openNames.has(normalized)) {
return true
}
}
return false
})
}
/**
* Re-open the tag discovery panel after reload / tab focus when review is still pending.
* Safe to call once the dock exists (or on later focus). Visible tabs steal ownership.
* @return {Promise<void>}
*/
async restoreTagDiscoveryPanelIfNeeded()
{
return this._restoreTagDiscoveryPanelIfNeeded()
}
/**
* Re-open the tag discovery panel after reload / tab focus when review is still pending.
* @return {Promise<void>}
* @private
*/
async _restoreTagDiscoveryPanelIfNeeded()
{
if (!this._config.tagDiscovery) {
return
}
if (this._tagDiscoveryActionInFlight) {
return
}
let state = await this._getState()
// Already gated — show even if discovery was toggled off so the user can Confirm/Skip.
// Re-collect ignored-pin rows so un-ignore / strip-base ignores match current registry.
if (state.resolutionBlocked && state.discoveryReviewMode === 'ignoredPins') {
// Steal ownership from a closed/stale tab so refresh/clear can run here.
await this._withState((next) => {
next.tagDiscoveryPanelTabId = this._tabId
})
await this._refreshIgnoredPinDiscoveryPanel()
let after = this._getStateSync()
if (!after?.resolutionBlocked) {
return
}
if (after.discoveryPanelTags?.length) {
await this._showTagDiscoveryPanel(after.discoveryPanelTags, [])
return
}
// Still gated with an empty list — unlock (refresh should have cleared; belt-and-suspenders).
this._hideTagDiscoveryPanel()
let stuckId = after.resolutionBlockedItemId
let stuckItem = stuckId ? await this._repos().downloadResolutionQueue.get(stuckId) : null
if (stuckItem?.pendingTagGroups) {
await this._finishResolutionAfterTagDiscovery(stuckItem)
} else {
await this._withState((next) => {
this._clearDiscoveryReviewState(next)
})
this._resumeProcessorsAfterDiscoveryReview()
}
return
}
// Unknown review: always re-partition from pending tag groups so pin scope
// applies after reload (do not reopen a stale all-types list from IDB).
if (state.resolutionBlocked) {
let tagGroups = null
let tagIncidences = {}
let queueItem = null
if (state.resolutionBlockedItemId) {
queueItem = await this._repos().downloadResolutionQueue.get(state.resolutionBlockedItemId)
let resolved = queueItem?.pendingTagGroups
if (resolved) {
tagGroups = this._tagGroupsFromResolvedPayload(resolved)
tagIncidences = resolved.tagIncidences ?? {}
}
}
if (tagGroups && this._isTagDiscoveryActive() && state.discoveryReviewMode !== 'ignoredPins') {
let lists = await this._partitionDiscoveryTags(tagGroups, tagIncidences)
if (lists.unknown.length) {
await this._withState((next) => {
next.discoveryReviewMode = 'unknown'
next.discoveryPanelTags = lists.unknown
next.discoveryPanelKnownTags = lists.known
next.tagDiscoveryPanelTabId = this._tabId
})
await this._showTagDiscoveryPanel(lists.unknown, lists.known)
return
}
let ignoredPin = await this._collectIgnoredFilenamePinTags(tagGroups, tagIncidences)
if (ignoredPin.pinJoinEmpty && ignoredPin.tags.length) {
await this._withState((next) => {
next.discoveryReviewMode = 'ignoredPins'
next.discoveryPanelTags = ignoredPin.tags
next.discoveryPanelKnownTags = []
next.tagDiscoveryPanelTabId = this._tabId
})
await this._showTagDiscoveryPanel(ignoredPin.tags, [])
return
}
// Pin scope left nothing to review — continue instead of reopening a stale all-types list.
if (queueItem?.pendingTagGroups) {
await this._finishResolutionAfterTagDiscovery(queueItem)
return
}
await this._withState((next) => {
this._clearDiscoveryReviewState(next)
})
return
}
// Gate set but subject missing / no pending groups — clear instead of zombie panel.
if (!(queueItem?.pendingTagGroups)) {
this._hideTagDiscoveryPanel()
await this._withState((next) => {
this._clearDiscoveryReviewState(next)
})
this._resumeProcessorsAfterDiscoveryReview()
return
}
// No tag groups available to re-scope — last resort show cached rows.
if (state.discoveryPanelTags?.length) {
await this._showTagDiscoveryPanel(state.discoveryPanelTags, state.discoveryPanelKnownTags ?? [])
}
return
}
// Recover orphan `tagReview` rows (including when discovery was toggled off mid-review).
let resolutionRows = await this._repos().downloadResolutionQueue.listByStatus('tagReview')
let stuck = resolutionRows.find((row) => row.pendingTagGroups)
if (!stuck) {
return
}
let pending = stuck.pendingTagGroups
let tagGroups = this._tagGroupsFromResolvedPayload(pending)
let tagIncidences = pending.tagIncidences ?? {}
if (this._isTagDiscoveryActive()) {
let lists = await this._partitionDiscoveryTags(tagGroups, tagIncidences)
if (lists.unknown.length) {
await this._withState((next) => {
next.resolutionBlocked = true
next.resolutionBlockedItemId = stuck.itemId
next.discoveryReviewMode = 'unknown'
next.discoveryPanelTags = lists.unknown
next.discoveryPanelKnownTags = lists.known
})
let stateAfter = this._getStateSync()
if (stateAfter?.discoveryPanelTags?.length) {
await this._showTagDiscoveryPanel(stateAfter.discoveryPanelTags, stateAfter.discoveryPanelKnownTags)
}
return
}
}
let ignoredPin = await this._collectIgnoredFilenamePinTags(tagGroups, tagIncidences)
let ignoredTags = ignoredPin.tags
if (!ignoredPin.pinJoinEmpty || !ignoredTags.length) {
// Nothing left to review — promote/drop the orphan tagReview row instead of
// leaving it stranded (peek only claims `queued`, so it would sit forever).
await this._finishResolutionAfterTagDiscovery(stuck)
return
}
await this._withState((next) => {
next.resolutionBlocked = true
next.resolutionBlockedItemId = stuck.itemId
next.discoveryReviewMode = 'ignoredPins'
next.discoveryPanelTags = ignoredTags
next.discoveryPanelKnownTags = []
})
await this._showTagDiscoveryPanel(ignoredTags, [])
// Resume deferred discovery lane after reload when resolution queue is empty.
let latest = this._getStateSync() ?? await this._getState()
if (this._isDeferredTagDiscoveryEnabled() && !latest?.resolutionBlocked) {
let [deferredRows, queuedRows] = await Promise.all([
this._repos().downloadResolutionQueue.listByStatus('discoveryQueued'),
this._repos().downloadResolutionQueue.listByStatus('queued'),
])
if (deferredRows.length && !queuedRows.length) {
await this._withState((next) => {
next.discoveryLanePhaseActive = true
})
this._signalProcessorsWake()
if (this._shouldRunProcessors()) {
void this._runProcessors()
}
}
}
}
/**
* @private
*/
_hideTagDiscoveryPanel()
{
if (this._tagDiscoveryRefreshTimer != null) {
clearTimeout(this._tagDiscoveryRefreshTimer)
this._tagDiscoveryRefreshTimer = null
}
this._cancelSimilarDiscoveryLoad()
this._similarDiscoveryCachedTags = null
this._similarDiscoveryFingerprint = null
if (!this._tagDiscoveryPanel) {
return
}
this._framework._hideDockSlidePanel(this._tagDiscoveryPanel)
}
// -------------------------------------------------------------------------
// Processors & sync
// -------------------------------------------------------------------------
/**
* True when the processor lock looks dead. Uses the fresher of IDB
* `processingHeartbeatAt` and the sync localStorage mirror `at` when the mirror
* still names the same owner — background timer throttling often stalls IDB
* heartbeats while the leader is still pacing / resolving.
* @param {object|null} state
* @return {boolean}
* @private
*/
_isProcessingLockStale(state)
{
if (!state?.processingTabId) {
return true
}
let lastBeat = state.processingHeartbeatAt ?? 0
let mirror = this._readProcessingLockMirror()
if (mirror?.tabId === state.processingTabId) {
let mirrorAt = Number(mirror.at) || 0
lastBeat = Math.max(lastBeat, mirrorAt)
}
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 BrazenDownloadManager.storageKey(this._cm._scriptPrefix, 'dm-processing-lock')
}
/**
* Cross-tab wake ping so an idle leader starts work when another tab enqueues.
* (`storage` events fire only in other documents — not the writer.)
* @return {string}
* @private
*/
_processorsWakeStorageKey()
{
return BrazenDownloadManager.storageKey(this._cm._scriptPrefix, 'dm-processors-wake')
}
/**
* Cross-tab ping so non-leader tabs can repaint dock progress counters.
* (`storage` events fire only in other documents — not the writer.)
* @return {string}
* @private
*/
_progressUiWakeStorageKey()
{
return BrazenDownloadManager.storageKey(this._cm._scriptPrefix, 'dm-progress-ui-wake')
}
/**
* Notify other tabs that queue work may be available.
* Debounced so bulk enqueue coalesces to one storage event.
* @private
*/
_signalProcessorsWake()
{
if (this._processorsWakeSignalTimer) {
clearTimeout(this._processorsWakeSignalTimer)
}
this._processorsWakeSignalTimer = setTimeout(() => {
this._processorsWakeSignalTimer = null
try {
localStorage.setItem(this._processorsWakeStorageKey(), String(Date.now()))
} catch (e) {
// ignore quota / private-mode localStorage failures
}
}, PROCESSORS_WAKE_SIGNAL_DEBOUNCE_MS)
}
/**
* Notify other tabs that dock progress counters changed.
* Debounced so rapid increments coalesce to one storage event.
* @private
*/
_signalProgressUiWake()
{
if (this._progressUiWakeSignalTimer) {
clearTimeout(this._progressUiWakeSignalTimer)
}
this._progressUiWakeSignalTimer = setTimeout(() => {
this._progressUiWakeSignalTimer = null
try {
localStorage.setItem(this._progressUiWakeStorageKey(), String(Date.now()))
} catch (e) {
// ignore quota / private-mode localStorage failures
}
}, PROGRESS_UI_WAKE_SIGNAL_DEBOUNCE_MS)
}
/**
* Debounced reload of cached DM state + dock progress paint (visible tabs only).
* @private
*/
_scheduleProgressUiWakeReceive()
{
if (this._progressUiWakeReceiveTimer) {
clearTimeout(this._progressUiWakeReceiveTimer)
}
this._progressUiWakeReceiveTimer = setTimeout(() => {
this._progressUiWakeReceiveTimer = null
void this._refreshProgressUiFromExternalWake()
}, PROGRESS_UI_WAKE_RECEIVE_DEBOUNCE_MS)
}
/**
* Visible non-leader tabs: hydrate completed counts, dock progress, and selection tiles.
* @return {Promise<void>}
* @private
*/
async _refreshProgressUiFromExternalWake()
{
if (this._documentSuspended || document.visibilityState !== 'visible') {
return
}
if (this._cm.canPersist()) {
this._cachedState = await this._repos().downloadManagerState.get()
}
this._refreshDockProgress({refreshAllItems: false})
this._refreshSelectionMarks()
this._framework._scheduleLedgerComplianceRefresh?.()
}
/**
* Schedule reclaim / resume after focus, bfcache restore, or cross-tab wake.
* Debounced so stacked wake events do not run concurrent full syncs.
* @param {{fullUi?: boolean, requeueInterrupted?: boolean}} [options]
* @private
*/
_scheduleResumeFromExternalWake(options = {})
{
let fullUi = options.fullUi !== false
let requeueInterrupted = options.requeueInterrupted === true
if (this._processorsWakeReceiveTimer) {
clearTimeout(this._processorsWakeReceiveTimer)
}
this._processorsWakeReceiveTimer = setTimeout(() => {
this._processorsWakeReceiveTimer = null
this._processorsWakeResumeTail = this._processorsWakeResumeTail
.catch(() => {})
.then(() => this._resumeProcessorsFromExternalWake({fullUi, requeueInterrupted}))
}, PROCESSORS_WAKE_RECEIVE_DEBOUNCE_MS)
}
/**
* Reclaim leadership and resume processors after focus / bfcache restore / cross-tab wake.
* Hidden tabs skip full UI hydrate — only cheap leadership / processor resume.
* @param {{fullUi?: boolean, requeueInterrupted?: boolean}} [options]
* @return {Promise<void>}
* @private
*/
async _resumeProcessorsFromExternalWake(options = {})
{
if (this._documentSuspended) {
return
}
let fullUi = options.fullUi !== false && document.visibilityState === 'visible'
let requeueInterrupted = options.requeueInterrupted === true
if (fullUi) {
await this._syncFromStorage()
} else {
// Background wake: refresh cached state only — no dock/selection listAll storm.
// Permanent hidden leaders rely on this path (storage wake), not visibilitychange.
if (this._cm.canPersist()) {
this._cachedState = await this._repos().downloadManagerState.get()
}
}
if (requeueInterrupted && this._cm.canPersist()) {
// After bfcache / unload interrupt, local active ids are cleared — reclaim orphan
// `resolving` / `downloading` rows before renewing an owned lock (renew skips requeue).
let state = this._getStateSync() ?? await this._getState()
if (state?.processingTabId === this._tabId || !this._isProcessingOwnerAlive(state)) {
await this._requeueInterruptedPipelineWork({deferInProgress: fullUi})
}
}
// Renew when we own; steal only when visible and the owner is truly dead.
// Browse maximize with a live hidden leader hits `_isProcessingOwnerAlive` and
// does not take the crown.
await this._tryClaimProcessingLeadership()
if (fullUi) {
// Tag discovery / HI UI belong on the visible browse tab; processor crown
// still reflects IDB `processingTabId` (leader) after dock chrome refresh.
await this._restoreTagDiscoveryPanelIfNeeded()
await this._restoreHumanInteractionPanelsIfNeeded()
if (this._downloadInterruptionPending) {
this._showDownloadInterruptionPanel(this._downloadInterruptionRows)
}
}
// Hidden leader renew: restart processors after a loop that exited on a failed
// claim (or idle settle) without needing a visibility flip on the leader tab.
if (this._shouldRunProcessors()) {
void this._runProcessors()
}
}
/**
* Stop local processor ownership so unload / bfcache cannot leave orphan `resolving`
* rows shielded by stale `_active*ItemId`, and so late awaits cannot recommit.
* @private
*/
_interruptDocumentProcessors()
{
this._processorEpoch++
this._stopProcessingHeartbeat()
this._activeResolutionItemId = null
this._activeDownloadItemId = null
this._processing = false
this._resolutionProcessing = false
this._downloadProcessing = false
this._dispatcherWakeRequested = false
this._resolutionWakeRequested = false
this._downloadWakeRequested = false
}
/**
* Sync unload path: block mirror rewrites, drop processor RAM ownership, clear the
* leadership mirror, close IDB so bfcache cannot pin the database, then best-effort
* async release of IDB leadership / panel ownership.
* @private
*/
_suspendDocumentForUnload()
{
if (this._documentSuspended) {
return
}
this._documentSuspended = true
this._interruptDocumentProcessors()
this._stopHumanInteractionWatchdog()
this._clearProcessingLockMirrorIfOwned()
try {
this._cm.repos?.storage?.close?.()
} catch (e) {
// ignore
}
void this._releaseProcessingLeadership().catch(() => {})
void this._releaseTagDiscoveryPanelOwnership().catch(() => {})
void this._releaseHumanInteractionPanelOwnership().catch(() => {})
}
/**
* bfcache restore: reopen storage, invalidate any thawed processor continuations, then
* resume with forced orphan requeue.
* @private
*/
_resumeDocumentAfterBfcache()
{
this._documentSuspended = false
this._interruptDocumentProcessors()
this._startHumanInteractionWatchdog()
void (async () => {
try {
await this._cm.repos?.storage?.open?.()
} catch (e) {
// ignore — first IDB helper will retry open()
}
this._scheduleResumeFromExternalWake({fullUi: true, requeueInterrupted: true})
})()
}
/**
* @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()
{
if (this._documentSuspended) {
return
}
try {
localStorage.setItem(this._processingLockStorageKey(), JSON.stringify({
tabId: this._tabId,
at: Date.now(),
}))
} catch (e) {
// ignore quota / private-mode localStorage failures
}
}
/**
* Sync-touch the lock mirror while this tab owns `processingTabId` (e.g. during
* initiation gaps) so background tabs do not treat a throttled IDB heartbeat as death.
* @private
*/
_touchProcessingLockMirrorIfOwned()
{
if (this._documentSuspended) {
return
}
let state = this._getStateSync()
if (state?.processingTabId !== this._tabId) {
return
}
this._writeProcessingLockMirror()
}
/**
* @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-or-mirror
* heartbeat via `_isProcessingLockStale`, 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. Renews when this tab already owns.
* Steals only when visible and the owner is dead (dual-stale heartbeat, or sync mirror
* cleared on unload). Hidden tabs never steal — they renew owned locks only.
* @return {Promise<boolean>}
* @private
*/
async _tryClaimProcessingLeadership()
{
if (this._documentSuspended || !this._cm.canPersist() || !this.isDownloadManagerEnabled()) {
return false
}
// Queue verification tabs must never run processors or hold the lock — if the
// leader refreshes while a CF tab is open, that tab used to steal leadership and
// leave the resume panel stranded until the lock went stale.
if (this._isQueueVerificationTab()) {
let owned = (await this._getState())?.processingTabId === this._tabId
if (owned) {
await this._releaseProcessingLeadership()
} else {
this._stopProcessingHeartbeat()
}
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 (this._documentSuspended || next.processingTabId !== this._tabId) {
return
}
next.processingHeartbeatAt = Date.now()
})
this._writeProcessingLockMirror()
this._startProcessingHeartbeat()
return true
}
if (this._isProcessingOwnerAlive(state)) {
this._stopProcessingHeartbeat()
return false
}
// Hidden tabs renew owned locks (above) but never steal. Stops background
// ping-pong; a visible tab (browse on maximize) may steal a truly dead owner
// (mirror cleared / dual-stale). Browse auto-steal of a live hidden leader is
// prevented by mirror-fused staleness in `_isProcessingLockStale`.
if (document.visibilityState !== 'visible') {
this._stopProcessingHeartbeat()
return false
}
await this._requeueInterruptedPipelineWork({
deferInProgress: document.visibilityState === 'visible' && !this._documentSuspended,
})
// Claim via `_withState` so a concurrent progress increment cannot be wiped by a
// stale full-state snapshot put after the requeue awaits above.
let claimed = false
await this._withState((next) => {
if (this._documentSuspended) {
return
}
if (document.visibilityState !== 'visible') {
return
}
if (this._isProcessingOwnerAlive(next) && next.processingTabId !== this._tabId) {
return
}
next.processingTabId = this._tabId
next.processingHeartbeatAt = Date.now()
if (next.tagDiscoveryPanelTabId && next.tagDiscoveryPanelTabId !== this._tabId) {
next.tagDiscoveryPanelTabId = null
}
this._hydrateInitiationClocksFromState(next)
claimed = true
})
if (!claimed) {
return false
}
this._writeProcessingLockMirror()
this._startProcessingHeartbeat()
// Fresh ledger after taking leadership so duplicate checks match other tabs' claims.
await this._framework._reloadDownloadDuplicateLedgerFromStorage()
// Crash leftovers with fat payloads — prune before processors resume.
await this._pruneAllTerminalQueueRows()
return true
}
/**
* User-driven leadership transfer: take the processor lock even when another tab still
* looks alive. Caller must gate on idle ({@link isDownloadManagerRunning}) first.
* @return {Promise<boolean>}
* @private
*/
async _forceClaimProcessingLeadership()
{
if (this._documentSuspended || !this._cm.canPersist() || !this.isDownloadManagerEnabled()) {
return false
}
if (this._isQueueVerificationTab()) {
let owned = (await this._getState())?.processingTabId === this._tabId
if (owned) {
await this._releaseProcessingLeadership()
} else {
this._stopProcessingHeartbeat()
}
return false
}
let state = await this._getState()
if (state.processingTabId === this._tabId) {
this._hydrateInitiationClocksFromState(state)
await this._withState((next) => {
if (this._documentSuspended || next.processingTabId !== this._tabId) {
return
}
next.processingHeartbeatAt = Date.now()
})
this._writeProcessingLockMirror()
this._startProcessingHeartbeat()
return true
}
let claimed = false
await this._withState((next) => {
if (this._documentSuspended) {
return
}
next.processingTabId = this._tabId
next.processingHeartbeatAt = Date.now()
if (next.tagDiscoveryPanelTabId && next.tagDiscoveryPanelTabId !== this._tabId) {
next.tagDiscoveryPanelTabId = null
}
this._hydrateInitiationClocksFromState(next)
claimed = true
})
if (!claimed) {
return false
}
this._writeProcessingLockMirror()
this._startProcessingHeartbeat()
await this._framework._reloadDownloadDuplicateLedgerFromStorage()
await this._pruneAllTerminalQueueRows()
return true
}
/**
* Requeue orphan `resolving` / `downloading` rows after unload / leadership steal.
* When `deferInProgress` is true on a visible page, `downloading` rows with `inProgress`
* are held for the interruption timed panel instead of being flipped to `queued`.
* @param {{deferInProgress?: boolean}} [options]
* @return {Promise<void>}
* @private
*/
async _requeueInterruptedPipelineWork(options = {})
{
let deferInProgress = options.deferInProgress === true &&
document.visibilityState === 'visible' &&
!this._documentSuspended
let resolutionRows = await this._repos().downloadResolutionQueue.listByStatus('resolving')
for (let row of resolutionRows) {
// Never requeue an item this tab is actively processing.
if (String(this._activeResolutionItemId) === String(row.itemId)) {
continue
}
row.status = 'queued'
row.error = null
await this._repos().downloadResolutionQueue.put(row)
}
let downloadRows = await this._repos().downloadQueue.listByStatus('downloading')
/** @type {object[]} */
let pending = []
for (let row of downloadRows) {
if (String(this._activeDownloadItemId) === String(row.itemId)) {
continue
}
if (deferInProgress && row.inProgress) {
pending.push(row)
continue
}
row.status = 'queued'
row.error = null
row.inProgress = false
// Keep ledgerClaimed — the slot was already reserved before GM_download.
await this._repos().downloadQueue.put(row)
}
if (pending.length) {
this._downloadInterruptionPending = true
this._downloadInterruptionRows = pending
}
}
/**
* @return {Promise<void>}
* @private
*/
async _releaseProcessingLeadership()
{
this._stopProcessingHeartbeat()
this._clearProcessingLockMirrorIfOwned()
// While unloading / bfcache-frozen, do not reopen IDB just to clear the lock —
// the sync mirror clear is enough for the next document to steal.
if (this._documentSuspended || !this._cm.canPersist()) {
return
}
await this._withState((state) => {
if (state.processingTabId !== this._tabId) {
return
}
state.processingTabId = null
state.processingHeartbeatAt = 0
})
}
/**
* @private
*/
_startProcessingHeartbeat()
{
if (this._processingHeartbeatTimer) {
return
}
this._processingHeartbeatTimer = setInterval(() => {
if (this._documentSuspended) {
this._stopProcessingHeartbeat()
return
}
void this._maybeExpireHumanInteractionBlocks()
void this._withState((state) => {
if (this._documentSuspended || 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
}
/**
* Clear human-interaction lanes whose soft-expiry TTL has elapsed (works on hidden leaders).
* @param {object|null|undefined} [state]
* @return {Promise<boolean>} true when any lane was cleared
* @private
*/
async _maybeExpireHumanInteractionBlocks(state = null)
{
let snapshot = state ?? this._getStateSync() ?? await this._getState()
let clearedAny = false
for (let ctx of /** @type {BrazenDownloadManagerLaneId[]} */ (['resolution', 'download'])) {
let lane = this._hiLane(snapshot, ctx)
if (!lane) {
continue
}
let expireMs = Number(
this._config.rateLimitHandlers?.[ctx]?.humanInteraction?.softExpireMs,
)
if (!(Number.isFinite(expireMs) && expireMs > 0)) {
expireMs = HUMAN_INTERACTION_SOFT_EXPIRE_MS
}
if (Date.now() - (Number(lane.at) || 0) < expireMs) {
continue
}
await this._clearHumanInteractionBlock(ctx)
clearedAny = true
snapshot = this._getStateSync() ?? snapshot
}
if (clearedAny) {
this._signalProcessorsWake()
if (this._shouldRunProcessors()) {
void this._runProcessors()
}
}
return clearedAny
}
/**
* Re-show / reclaim HI panels on a visible tab; hide local panels when the lane was cleared remotely.
* @param {object|null|undefined} [state]
* @return {Promise<void>}
* @private
*/
async _restoreHumanInteractionPanelsIfNeeded(state = null)
{
if (this._documentSuspended) {
return
}
await this._maybeExpireHumanInteractionBlocks(state)
let snapshot = this._getStateSync() ?? state ?? await this._getState()
for (let ctx of /** @type {BrazenDownloadManagerLaneId[]} */ (['resolution', 'download'])) {
let lane = this._hiLane(snapshot, ctx)
if (!lane) {
this._hideHumanInteractionPanel(ctx)
continue
}
// Orphan lane (item gone from both queues) — clear so Waiting cannot stick forever.
if (lane.itemId != null && this._cm.canPersist()) {
let itemKey = String(lane.itemId)
let [resolution, download] = await Promise.all([
this._repos().downloadResolutionQueue.get(itemKey),
this._repos().downloadQueue.get(itemKey),
])
let resolutionAlive = !!(resolution && !RESOLUTION_TERMINAL.has(resolution.status))
let downloadAlive = !!(download && !DOWNLOAD_TERMINAL.has(download.status))
if (!resolutionAlive && !downloadAlive) {
await this._clearHumanInteractionBlock(ctx)
this._signalProcessorsWake()
if (this._shouldRunProcessors()) {
void this._runProcessors()
}
snapshot = this._getStateSync() ?? snapshot
continue
}
}
if (document.visibilityState !== 'visible') {
continue
}
void this._promptHumanInteractionResume(ctx)
}
}
/**
* Lightweight periodic reclaim so a stale/closed `promptTabId` cannot leave HI blocked forever.
* @private
*/
_startHumanInteractionWatchdog()
{
if (this._humanInteractionWatchdogTimer) {
return
}
this._humanInteractionWatchdogTimer = setInterval(() => {
if (this._documentSuspended) {
return
}
void this._maybeExpireHumanInteractionBlocks()
if (document.visibilityState !== 'visible') {
return
}
let state = this._getStateSync()
if (!this._anyHumanInteraction(state)) {
// Still hide any leftover local zombie if lanes are clear.
this._hideHumanInteractionPanel()
return
}
void this._restoreHumanInteractionPanelsIfNeeded(state)
}, HUMAN_INTERACTION_WATCHDOG_MS)
}
/**
* @private
*/
_stopHumanInteractionWatchdog()
{
if (!this._humanInteractionWatchdogTimer) {
return
}
clearInterval(this._humanInteractionWatchdogTimer)
this._humanInteractionWatchdogTimer = null
}
/**
* Lane descriptors wrapping peek/claim/process/block for the dispatcher.
* @return {{resolution: object, download: object}}
* @private
*/
_ensureLanes()
{
if (this._lanes) {
return this._lanes
}
this._lanes = {
resolution: {
id: /** @type {BrazenDownloadManagerLaneId} */ ('resolution'),
peek: async () => {
let state = await this._getState()
return this._peekResolutionWork(state)
},
claim: (itemId) => this._claimResolutionWorkItem(itemId),
process: (item) => this._processResolutionWorkItem(item),
ownBlocked: (state) => this._isResolutionPipelineBlocked(state),
getActiveId: () => this._activeResolutionItemId,
setActiveId: (itemId) => { this._activeResolutionItemId = itemId },
processingKey: '_resolutionProcessing',
wakeKey: '_resolutionWakeRequested',
},
download: {
id: /** @type {BrazenDownloadManagerLaneId} */ ('download'),
peek: () => this._repos().downloadQueue.peekNextQueued(),
claim: (itemId) => this._claimDownloadQueueItem(itemId),
process: (item) => this._processDownloadItem(item),
ownBlocked: (state) => this._isDownloadPipelineBlocked(state),
getActiveId: () => this._activeDownloadItemId,
setActiveId: (itemId) => { this._activeDownloadItemId = itemId },
processingKey: '_downloadProcessing',
wakeKey: '_downloadWakeRequested',
},
}
return this._lanes
}
/**
* Scheduling policy from `linkQueues`.
* IndependentPolicy (default / r34xxx): each unblocked lane advances in parallel.
* InterleavedPolicy (linked): single serial selection, shared HI pause, opportunistic
* download during discovery, retractable in-flight jobs.
* @return {{name: string, isLaneBlocked: function(object, object): boolean, run: function(): void}}
* @private
*/
_getSchedulingPolicy()
{
if (this._linkQueues) {
return {
name: 'interleaved',
isLaneBlocked: (lane, state) => {
// Any lane HI pauses the shared regime.
if (this._anyHumanInteraction(state)) {
return true
}
if (lane.id === 'download' && state.paused) {
return true
}
// Discovery gates resolution only — download stays opportunistic.
if (lane.id === 'resolution' && state.resolutionBlocked) {
return true
}
return false
},
run: () => { void this._runInterleavedDispatcher() },
}
}
/** @type {{name: string, isLaneBlocked: function(object, object): boolean, run: function(): void}} */
let independent = {
name: 'independent',
isLaneBlocked: (lane, state) => lane.ownBlocked(state),
run: () => {
let lanes = this._ensureLanes()
void this._runLaneProcessor(lanes.resolution, independent)
void this._runLaneProcessor(lanes.download, independent)
},
}
return independent
}
/**
* Kick entry for init, enqueue, Start, tag confirm/skip, HI resume, and visibility.
* Delegates to IndependentPolicy or InterleavedPolicy from `linkQueues`.
* @return {Promise<void>}
* @private
*/
async _runProcessors()
{
if (!this._shouldRunProcessors()) {
return
}
if (!await this._tryClaimProcessingLeadership()) {
// Do not busy-reenter on wake after a failed claim.
this._dispatcherWakeRequested = false
this._resolutionWakeRequested = false
this._downloadWakeRequested = false
return
}
this._runDispatcher(this._getSchedulingPolicy())
}
/**
* @param {{name: string, isLaneBlocked: function(object, object): boolean, run: function(): void}} policy
* @private
*/
_runDispatcher(policy)
{
policy.run()
}
/**
* IndependentPolicy lane loop (one concurrent loop per unblocked lane).
* @param {object} lane
* @param {{isLaneBlocked: function(object, object): boolean}} policy
* @return {Promise<void>}
* @private
*/
async _runLaneProcessor(lane, policy)
{
if (this[lane.processingKey]) {
this[lane.wakeKey] = true
return
}
let runEpoch = this._processorEpoch
this[lane.processingKey] = true
try {
do {
this[lane.wakeKey] = false
if (this._processorEpoch !== runEpoch || this._documentSuspended) {
this[lane.wakeKey] = false
break
}
if (!await this._tryClaimProcessingLeadership()) {
this[lane.wakeKey] = false
break
}
if (!this._shouldRunProcessors()) {
break
}
while (this._shouldRunProcessors() && this._processorEpoch === runEpoch) {
await this._bumpProcessingHeartbeat()
if (this._processorEpoch !== runEpoch) {
break
}
let state = await this._getState()
await this._maybeExpireHumanInteractionBlocks(state)
state = this._getStateSync() ?? state
if (policy.isLaneBlocked(lane, state)) {
break
}
let next = await lane.peek()
if (!next) {
break
}
let claimed = await lane.claim(next.itemId)
if (!claimed) {
// Another worker claimed it — exit; a wake will restart if needed.
break
}
if (this._processorEpoch !== runEpoch) {
break
}
lane.setActiveId(claimed.itemId)
try {
this._refreshItemProgress(claimed.itemId)
await lane.process(claimed)
if (this._processorEpoch === runEpoch) {
await this._reloadCachedStateQuiet()
}
} finally {
if (this._processorEpoch === runEpoch) {
lane.setActiveId(null)
}
}
}
} while (this[lane.wakeKey] && this._processorEpoch === runEpoch)
} finally {
if (this._processorEpoch === runEpoch) {
this[lane.processingKey] = false
}
if (this._processorEpoch === runEpoch && !this._documentSuspended) {
// Resolution often finishes last — settle download idle here too.
if (lane.id === 'resolution' && await this._tryRecoverStrandedDownloads()) {
this._downloadWakeRequested = true
let lanes = this._ensureLanes()
void this._runLaneProcessor(lanes.download, policy)
} else if (lane.id === 'download' && await this._tryRecoverStrandedDownloads()) {
this[lane.wakeKey] = true
}
await this._pauseDownloadQueueIfIdle()
await this._resetProgressCountersIfIdle()
this._refreshDockProgress({refreshAllItems: false})
if (this[lane.wakeKey]) {
this[lane.wakeKey] = false
void this._runLaneProcessor(lane, policy)
}
}
}
}
/**
* InterleavedPolicy: single serial selection across lanes under one shared regime.
* Prefers resolution; when resolution is discovery-gated, opportunistically downloads.
* Retracts in-flight jobs when a shared gate (any HI / pause) lands mid-process.
* @return {Promise<void>}
* @private
*/
async _runInterleavedDispatcher()
{
if (this._processing) {
this._dispatcherWakeRequested = true
return
}
let runEpoch = this._processorEpoch
this._processing = true
let policy = this._getSchedulingPolicy()
let lanes = this._ensureLanes()
try {
do {
this._dispatcherWakeRequested = false
if (this._processorEpoch !== runEpoch || this._documentSuspended) {
this._dispatcherWakeRequested = false
break
}
if (!await this._tryClaimProcessingLeadership()) {
this._dispatcherWakeRequested = false
break
}
if (!this._shouldRunProcessors()) {
break
}
while (this._shouldRunProcessors() && this._processorEpoch === runEpoch) {
await this._bumpProcessingHeartbeat()
if (this._processorEpoch !== runEpoch) {
break
}
let state = await this._getState()
await this._maybeExpireHumanInteractionBlocks(state)
state = this._getStateSync() ?? state
// Any HI pauses the entire shared regime.
if (this._anyHumanInteraction(state)) {
break
}
let selected = null
// Prefer resolution when it can take work.
if (!policy.isLaneBlocked(lanes.resolution, state)) {
let nextResolution = await lanes.resolution.peek()
if (nextResolution) {
selected = {lane: lanes.resolution, next: nextResolution}
}
}
// Opportunistic download while resolution is discovery-gated or empty.
if (!selected && !policy.isLaneBlocked(lanes.download, state)) {
let nextDownload = await lanes.download.peek()
if (nextDownload) {
selected = {lane: lanes.download, next: nextDownload}
}
}
if (!selected) {
break
}
let claimed = await selected.lane.claim(selected.next.itemId)
if (!claimed) {
break
}
if (this._processorEpoch !== runEpoch) {
break
}
selected.lane.setActiveId(claimed.itemId)
try {
this._refreshItemProgress(claimed.itemId)
await selected.lane.process(claimed)
if (this._processorEpoch === runEpoch) {
await this._reloadCachedStateQuiet()
}
} finally {
if (this._processorEpoch === runEpoch) {
// Retract if a shared gate landed mid-flight and the claim is still open.
await this._retractLaneJobIfGated(selected.lane)
selected.lane.setActiveId(null)
}
}
}
} while (this._dispatcherWakeRequested && this._processorEpoch === runEpoch)
} finally {
if (this._processorEpoch === runEpoch) {
this._processing = false
}
if (this._processorEpoch === runEpoch && !this._documentSuspended) {
if (await this._tryRecoverStrandedDownloads()) {
this._dispatcherWakeRequested = true
}
await this._pauseDownloadQueueIfIdle()
await this._resetProgressCountersIfIdle()
this._refreshDockProgress({refreshAllItems: false})
if (this._dispatcherWakeRequested) {
this._dispatcherWakeRequested = false
void this._runInterleavedDispatcher()
}
}
}
}
/**
* Cooperative retract of an in-flight interleaved job when pause / any-HI gates the regime.
* Clears active-id ownership so commit guards fail, then returns the row to `queued`.
* @param {object} lane
* @return {Promise<void>}
* @private
*/
async _retractLaneJobIfGated(lane)
{
let itemId = lane.getActiveId()
if (!itemId) {
return
}
let state = this._getStateSync() ?? await this._getState()
let gated = this._anyHumanInteraction(state)
|| (lane.id === 'download' && !!state?.paused)
if (!gated) {
return
}
let repo = lane.id === 'resolution'
? this._repos().downloadResolutionQueue
: this._repos().downloadQueue
let expected = lane.id === 'resolution' ? 'resolving' : 'downloading'
let row = await repo.get(itemId)
if (!row || row.status !== expected) {
return
}
// Drop in-tab ownership first so late commits cannot win.
lane.setActiveId(null)
row.status = 'queued'
row.error = null
await repo.put(row)
}
/**
* 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
}
/**
* True while this tab still owns the in-flight resolution claim for `itemId`.
* @param {string} itemId
* @return {Promise<boolean>}
* @private
*/
async _isActiveResolutionClaim(itemId)
{
if (String(this._activeResolutionItemId) !== String(itemId)) {
return false
}
let row = await this._repos().downloadResolutionQueue.get(itemId)
return !!(row && row.status === 'resolving')
}
/**
* True while this tab still owns the in-flight download claim for `itemId`.
* @param {string} itemId
* @return {Promise<boolean>}
* @private
*/
async _isActiveDownloadClaim(itemId)
{
if (String(this._activeDownloadItemId) !== String(itemId)) {
return false
}
let row = await this._repos().downloadQueue.get(itemId)
return !!(row && row.status === 'downloading')
}
/**
* Commit a resolution row only if this tab still owns the `resolving` claim (or is
* intentionally writing `queued` / `tagReview` / terminal from that claim).
* @param {object} item
* @return {Promise<boolean>}
* @private
*/
async _commitResolutionRow(item)
{
if (String(this._activeResolutionItemId) !== String(item.itemId)) {
return false
}
let existing = await this._repos().downloadResolutionQueue.get(item.itemId)
if (!existing) {
return false
}
// Steal requeued to `queued` while we still thought we owned resolving.
if (existing.status !== 'resolving') {
return false
}
await this._repos().downloadResolutionQueue.put(item)
return true
}
/**
* Commit a download row only if this tab still owns the `downloading` claim.
* @param {object} item
* @return {Promise<boolean>}
* @private
*/
async _commitDownloadRow(item)
{
if (String(this._activeDownloadItemId) !== String(item.itemId)) {
return false
}
let existing = await this._repos().downloadQueue.get(item.itemId)
if (!existing) {
// Cleared mid-flight — do not resurrect.
return false
}
if (existing.status !== 'downloading') {
return false
}
await this._repos().downloadQueue.put(item)
return true
}
/**
* Remove a resolution row after successful promote when this tab still owns the claim.
* @param {string} itemId
* @return {Promise<boolean>}
* @private
*/
async _removeResolutionRowIfActive(itemId)
{
if (String(this._activeResolutionItemId) !== String(itemId)) {
return false
}
let existing = await this._repos().downloadResolutionQueue.get(itemId)
if (!existing) {
return false
}
if (existing.status !== 'resolving' && existing.status !== 'tagReview') {
return false
}
await this._repos().downloadResolutionQueue.remove(itemId)
return true
}
/**
* @return {Promise<void>}
* @private
*/
async _bumpProcessingHeartbeat()
{
if (this._documentSuspended) {
return
}
await this._maybeExpireHumanInteractionBlocks()
await this._withState((state) => {
if (this._documentSuspended || state.processingTabId !== this._tabId) {
return
}
state.processingHeartbeatAt = Date.now()
this._writeProcessingLockMirror()
})
}
/**
* Own-lane resolution gate (IndependentPolicy / lane.ownBlocked).
* Linked coupling (any-HI / discovery opportunism) lives in InterleavedPolicy.
* @param {object|null|undefined} state
* @return {boolean}
* @private
*/
_isResolutionPipelineBlocked(state)
{
if (!state) {
return true
}
if (this._hiLane(state, 'resolution')) {
return true
}
return !!state.resolutionBlocked
}
/**
* Own-lane download gate (IndependentPolicy / lane.ownBlocked).
* @param {object|null|undefined} state
* @return {boolean}
* @private
*/
_isDownloadPipelineBlocked(state)
{
if (!state) {
return true
}
if (state.paused) {
return true
}
if (this._downloadInterruptionPending) {
return true
}
return !!this._hiLane(state, 'download')
}
/**
* Whether Start/Pause should surface human-interaction resume instead of toggle.
* Independent: download-lane HI. Interleaved: any lane HI.
* @param {object|null|undefined} state
* @return {boolean}
* @private
*/
_isStartPauseShowingHumanInteraction(state)
{
if (this._linkQueues) {
return this._anyHumanInteraction(state)
}
return !!this._hiLane(state, 'download')
}
/**
* Whether this tab may attempt to own / run processors (leadership + enabled).
* Per-lane / policy gates are checked inside the dispatcher.
* @return {boolean}
* @private
*/
_shouldRunProcessors()
{
if (this._documentSuspended || !this._cm.canPersist()) {
return false
}
let state = this._getStateSync()
if (!state) {
return false
}
if (this._isProcessingOwnerAlive(state)) {
return false
}
if (!this.isDownloadManagerEnabled()) {
return false
}
return true
}
/**
* @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._documentSuspended || !this._cm.canPersist()) {
return Promise.resolve(this._cachedState)
}
let run = this._stateWriteTail.then(async () => {
if (this._documentSuspended) {
return this._cachedState
}
let state = await this._repos().downloadManagerState.get()
await mutator(state)
this._sanitizeStateForPersist(state)
state.id = 'state'
await this._repos().downloadManagerState.put(state)
this._cachedState = state
return state
})
this._stateWriteTail = run.then(() => undefined, () => undefined)
return run
}
/**
* Normalize state before IDB put.
* @param {object} state
* @private
*/
_sanitizeStateForPersist(state)
{
if (!state) {
return
}
}
/**
* 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)
// Mirror before/after the gap so a throttled sleep cannot exceed the stale
// window without updating sync liveness (resolution hits this every item).
this._touchProcessingLockMirrorIfOwned()
let nextAt = await this._awaitInitiationGap(lastAt, gapMs)
this._touchProcessingLockMirrorIfOwned()
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()
{
// Serialize download initiations so concurrent unlinked work still honors the gap.
let run = this._downloadInitiateTail.then(() => this._paceInitiation('download'))
this._downloadInitiateTail = run.then(() => undefined, () => undefined)
await run
}
/**
* Pace the next resolution / same-origin HTML fetch.
* Serialized so concurrent callers (e.g. tag-type lookups during resolve) cannot stampede.
* @return {Promise<void>}
*/
async paceResolutionInitiation()
{
return this._paceResolutionInitiation()
}
/**
* @return {Promise<void>}
* @private
*/
async _paceResolutionInitiation()
{
// Serialize resolve-page + site tag-list fetches so parallel workers honor the gap.
let run = this._resolutionInitiateTail.then(() => this._paceInitiation('resolution'))
this._resolutionInitiateTail = run.then(() => undefined, () => undefined)
await run
}
/**
* When pending downloads exist but none are `queued` (orphan `downloading` rows),
* requeue via the steal/bfcache helper and allow one wake. Latch prevents tight-loop.
* @return {Promise<boolean>} true when a wake should re-enter the download processor
* @private
*/
async _tryRecoverStrandedDownloads()
{
let pending = await this._getPendingDownloadCount()
if (pending <= 0) {
this._orphanDownloadRequeueAttempted = false
return false
}
if (await this._repos().downloadQueue.peekNextQueued()) {
this._orphanDownloadRequeueAttempted = false
return false
}
// Interruption panel owns these orphans — do not requeue under it.
if (this._downloadInterruptionPending) {
return false
}
if (this._orphanDownloadRequeueAttempted) {
return false
}
this._orphanDownloadRequeueAttempted = true
let defer = document.visibilityState === 'visible' && !this._documentSuspended
await this._requeueInterruptedPipelineWork({deferInProgress: defer})
if (this._downloadInterruptionPending) {
this._showDownloadInterruptionPanel(this._downloadInterruptionRows)
return false
}
return !!(await this._repos().downloadQueue.peekNextQueued())
}
/**
* Download batch is idle only when both queues are empty (and resolution is not mid-tick).
* Once started, the download side stays in-batch until resolution empties too.
* @param {number|null} [downloadPending] Omit to use cached sync counts.
* @param {number|null} [resolutionPending]
* @return {boolean}
* @private
*/
_isDownloadBatchIdle(downloadPending = null, resolutionPending = null)
{
if (this._resolutionProcessing || this._resolutionWakeRequested ||
this._processing || this._dispatcherWakeRequested) {
return false
}
let download = downloadPending == null ? (this._cachedDownloadCount ?? 0) : downloadPending
let resolution = resolutionPending == null ? (this._cachedResolutionCount ?? 0) : resolutionPending
return download <= 0 && resolution <= 0
}
/**
* Zero each dock progress counter when that pipeline's batch is fully idle.
* Download completed count clears only when both queues are empty.
* @return {Promise<void>}
* @private
*/
async _resetProgressCountersIfIdle()
{
let downloadPending = await this._getPendingDownloadCount()
let resolutionPending = await this._getPendingResolutionCount()
let cleared = false
await this._withState((state) => {
if (resolutionPending === 0 && (Number(state.completedResolutionCount) || 0) !== 0) {
state.completedResolutionCount = 0
cleared = true
}
if (this._isDownloadBatchIdle(downloadPending, resolutionPending) &&
(Number(state.completedDownloadCount) || 0) !== 0) {
state.completedDownloadCount = 0
cleared = true
}
})
if (cleared) {
this._signalProgressUiWake()
}
}
/**
* Return to idle (Start required) only when both download and resolution queues are empty
* so a later batch does not auto-run. Does not clear a user Pause.
* @return {Promise<void>}
* @private
*/
async _pauseDownloadQueueIfIdle()
{
let downloadPending = await this._getPendingDownloadCount()
let resolutionPending = await this._getPendingResolutionCount()
if (!this._isDownloadBatchIdle(downloadPending, resolutionPending)) {
return
}
await this._withState((state) => {
if (state.paused) {
return
}
state.paused = true
})
}
/**
* @return {Promise<void>}
* @private
*/
async _incrementResolutionProgress()
{
// Serialize with heartbeats — a raw put of a stale snapshot can wipe the count.
await this._withState((state) => {
state.completedResolutionCount = (Number(state.completedResolutionCount) || 0) + 1
})
this._signalProgressUiWake()
this._refreshDockProgress({refreshAllItems: false})
}
/**
* @return {Promise<void>}
* @private
*/
async _incrementDownloadProgress()
{
await this._withState((state) => {
state.completedDownloadCount = (Number(state.completedDownloadCount) || 0) + 1
})
await this._pauseDownloadQueueIfIdle()
this._signalProgressUiWake()
this._refreshDockProgress({refreshAllItems: false})
}
/**
* 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).
* Prunes terminal rows so overnight `listAll` stays O(pending), not O(completed).
* @param {string} itemId
* @return {Promise<void>}
* @private
*/
async _onResolutionTaskComplete(itemId)
{
await this._reloadCachedStateQuiet()
await this._renderItemProgress(itemId)
await this._clearTerminalQueueRows(itemId)
if (!(await this.isQueued(itemId))) {
this._removeSelectionMarkMirrorId(itemId)
this._forgetTrackedItem(itemId)
}
this._refreshDockProgress({refreshAllItems: false})
this._refreshSelectionMarks()
}
/**
* One UI reset after a download task finishes (success, fail, duplicate, or requeue).
* Prunes terminal rows (including fat `resolvedPayload`) after a final progress paint.
* @param {string} itemId
* @return {Promise<void>}
* @private
*/
async _onDownloadTaskComplete(itemId)
{
await this._reloadCachedStateQuiet()
await this._renderItemProgress(itemId)
await this._clearTerminalQueueRows(itemId)
if (!(await this.isQueued(itemId))) {
this._removeSelectionMarkMirrorId(itemId)
this._forgetTrackedItem(itemId)
}
this._refreshDockProgress({refreshAllItems: false})
this._refreshSelectionMarks()
// Hide Downloaded: re-run compliance even if ledger config events coalesced with queue puts.
this._framework._scheduleLedgerComplianceRefresh?.()
}
/**
* @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
}
let hiChanged = false
let immediateCleared = false
if (BrazenDownloadManager._stateNeedsLegacyMigration(state)) {
hiChanged = this._migrateHumanInteractionState(state)
immediateCleared = this._clearLegacyImmediateDownloadState(state)
}
let phaseDefaulted = false
if (!('discoveryLanePhaseActive' in state)) {
state.discoveryLanePhaseActive = false
phaseDefaulted = true
}
if (hiChanged || immediateCleared || phaseDefaulted) {
state.id = 'state'
await this._repos().downloadManagerState.put(state)
this._cachedState = state
this._writeHumanInteractionBlockMirror(this._anyHumanInteraction(state))
}
}
/**
* Drop removed `pendingImmediateDownload` and clear an orphan discovery gate that had
* no queue subject (legacy mid-review immediate downloads after upgrade).
* @param {object} state
* @return {boolean} true when the row was changed
* @private
*/
_clearLegacyImmediateDownloadState(state)
{
if (!state || !('pendingImmediateDownload' in state)) {
return false
}
let hadPending = !!state.pendingImmediateDownload
delete state.pendingImmediateDownload
if (hadPending && state.resolutionBlocked && !state.resolutionBlockedItemId) {
this._clearDiscoveryReviewState(state)
}
return true
}
/**
* Fold legacy single-slot HI fields into `humanInteraction` map. Mutates `state`.
* @param {object} state
* @return {boolean} true when the row was changed
* @private
*/
_migrateHumanInteractionState(state)
{
let changed = false
let priorMap = state.humanInteraction
this._ensureHumanInteractionMap(state)
if (priorMap !== state.humanInteraction || !priorMap ||
!('resolution' in priorMap) || !('download' in priorMap)) {
changed = true
}
if (state.humanInteractionBlocked) {
let ctx = state.humanInteractionContext === 'download' ? 'download' : 'resolution'
if (!state.humanInteraction[ctx]) {
state.humanInteraction[ctx] = {
itemId: state.humanInteractionItemId ?? null,
promptTabId: state.humanInteractionPromptTabId ?? null,
openUrl: state.humanInteractionOpenUrl ?? null,
at: Date.now(),
}
changed = true
}
}
for (let key of [
'humanInteractionBlocked',
'humanInteractionContext',
'humanInteractionItemId',
'humanInteractionPromptTabId',
'humanInteractionOpenUrl',
]) {
if (key in state) {
delete state[key]
changed = true
}
}
return changed
}
/**
* @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)
{
this._sanitizeStateForPersist(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()
await this._refreshCurrentMediaQueueState({refreshDock: false})
this._framework.refreshDockInterface(undefined, {layout: false})
// Selection marks paint queued tiles; skip `_refreshAllItemProgress` on sync.
this._refreshDockProgress({refreshAllItems: false})
this._refreshSelectionMarks()
}
/**
* @private
*/
_setupCrossTabSync()
{
if (this._crossTabVisibilityHandler) {
document.removeEventListener('visibilitychange', this._crossTabVisibilityHandler)
}
this._crossTabVisibilityHandler = () => {
if (document.visibilityState === 'visible') {
this._scheduleResumeFromExternalWake({fullUi: true})
return
}
// Drop panel ownership while hidden so the focused search tab can claim it.
// Keep local DOM visible on minimize — only unload paths hide panels.
void this._releaseTagDiscoveryPanelOwnership({hideLocal: false})
void this._releaseHumanInteractionPanelOwnership({hideLocal: false})
}
document.addEventListener('visibilitychange', this._crossTabVisibilityHandler)
// bfcache back/forward restores the page without a reliable visibility flip — reclaim here.
if (this._crossTabPageshowHandler) {
window.removeEventListener('pageshow', this._crossTabPageshowHandler)
}
this._crossTabPageshowHandler = (event) => {
let persisted = !!event.persisted
if (persisted) {
// Hard interrupt + reopen IDB + requeue orphans (soft resume is not enough).
this._resumeDocumentAfterBfcache()
return
}
if (document.visibilityState === 'visible') {
this._scheduleResumeFromExternalWake({fullUi: true})
}
}
window.addEventListener('pageshow', this._crossTabPageshowHandler)
if (this._crossTabPagehideHandler) {
window.removeEventListener('pagehide', this._crossTabPagehideHandler)
window.removeEventListener('beforeunload', this._crossTabPagehideHandler)
}
this._crossTabPagehideHandler = (event) => {
// Sync interrupt: stop mirror rewrites, clear active ids, close IDB for bfcache.
this._suspendDocumentForUnload()
// Keep overlays on bfcache restore so marks do not blank-flash on back/forward.
if (!event?.persisted) {
this._clearAllItemProgress()
}
}
window.addEventListener('pagehide', this._crossTabPagehideHandler)
window.addEventListener('beforeunload', this._crossTabPagehideHandler)
// Other tabs' enqueue / progress writes wake keys; only other documents receive these.
// Hidden tabs get a cheap leadership resume; visible tabs hydrate UI after debounce.
if (this._crossTabStorageHandler) {
window.removeEventListener('storage', this._crossTabStorageHandler)
}
this._crossTabStorageHandler = (event) => {
let key = event.key
let newValue = event.newValue
if (!newValue) {
return
}
if (key === this._progressUiWakeStorageKey()) {
this._scheduleProgressUiWakeReceive()
return
}
if (key !== this._processorsWakeStorageKey()) {
return
}
this._scheduleResumeFromExternalWake({
fullUi: document.visibilityState === 'visible',
})
}
window.addEventListener('storage', this._crossTabStorageHandler)
}
// -------------------------------------------------------------------------
// 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
* @return {HTMLElement|null}
* @private
*/
_findSelectionItemElement(itemId)
{
let pageConfig = this._getActivePageConfig()
if (!pageConfig?.itemSelector || !pageConfig.resolveItem) {
return null
}
let itemKey = String(itemId)
for (let element of document.querySelectorAll(pageConfig.itemSelector)) {
let resolved = Utilities.callEventHandler(pageConfig.resolveItem, [element], null)
if (resolved?.itemId != null && String(resolved.itemId) === itemKey) {
return element
}
}
return null
}
/**
* @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))
}
/**
* Drops DOM + epoch retention for a tile that is no longer in the active pipeline.
* @param {string|number} itemId
* @private
*/
_forgetTrackedItem(itemId)
{
if (itemId == null) {
return
}
let itemKey = String(itemId)
let element = this._trackedItemElements.get(itemKey)
this._untrackItemElement(itemId)
this._selectionOpEpoch.delete(itemKey)
if (element) {
this._setItemProgress(element, null)
}
}
/**
* @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()
this._selectionUiHidden.clear()
this._selectionUiPending.clear()
this._selectionOpEpoch.clear()
}
/**
* @param {string|number} itemId
* @return {Promise<void>}
* @private
*/
async _renderItemProgress(itemId)
{
let itemKey = String(itemId)
if (this._selectionUiHidden.has(itemKey)) {
// Local cancel won the UI; ignore late reconcile/processor paints until dequeue lands.
if (!(await this.isQueued(itemId))) {
this._selectionUiHidden.delete(itemKey)
}
return
}
let element = this._trackedItemElements.get(itemKey)
if (!element) {
element = this._findSelectionItemElement(itemId)
if (element) {
this._trackItemElement(itemId, element)
}
}
if (!element) {
return
}
let progress = await this._resolveItemPipelineView(itemId)
if (this._selectionUiHidden.has(itemKey)) {
return
}
// Optimistic select painted before IDB put — don't wipe it with a null reconcile.
if (!progress && this._selectionUiPending.has(itemKey)) {
return
}
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 = this._getStateSync() ?? await this._getState()
let itemKey = String(itemId)
let [resolution, download] = await Promise.all([
this._repos().downloadResolutionQueue.get(itemKey),
this._repos().downloadQueue.get(itemKey),
])
let blocked = !!state?.resolutionBlocked
let blockedItemId = state?.resolutionBlockedItemId != null ? String(state.resolutionBlockedItemId) : null
let hiResolution = this._hiLane(state, 'resolution')
let hiDownload = this._hiLane(state, 'download')
for (let hiEntry of [
{ctx: /** @type {BrazenDownloadManagerLaneId} */ ('download'), lane: hiDownload},
{ctx: /** @type {BrazenDownloadManagerLaneId} */ ('resolution'), lane: hiResolution},
]) {
if (!hiEntry.lane) {
continue
}
let humanItemId = hiEntry.lane.itemId != null ? String(hiEntry.lane.itemId) : null
if (humanItemId === itemKey) {
return {
label: 'Verify',
stepIndex: hiEntry.ctx === 'download' ?
layout.indices.downloading :
layout.indices.resolving,
stepCount: layout.stepCount,
}
}
if (humanItemId) {
let showWaiting = this._linkQueues
if (!showWaiting && hiEntry.ctx === 'download') {
showWaiting = !!(download && !DOWNLOAD_TERMINAL.has(download.status))
}
if (!showWaiting && hiEntry.ctx === 'resolution') {
showWaiting = !!(resolution && !RESOLUTION_TERMINAL.has(resolution.status) && !download)
}
if (showWaiting) {
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 === 'discoveryQueued') {
return {
label: 'Discovery',
stepIndex: layout.indices.tagReview >= 0 ? layout.indices.tagReview : layout.indices.resolving,
stepCount: layout.stepCount,
}
}
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 (state?.discoveryLanePhaseActive && (!blocked || (blockedItemId && blockedItemId !== itemKey))) {
return {
label: 'Waiting',
stepIndex: layout.indices.queued,
stepCount: layout.stepCount,
}
}
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
// -------------------------------------------------------------------------
/**
* Push filename/subfolder pattern pins into live `tagDiscovery.tagTypes` so discovery
* scan scope tracks the user's active download-path patterns without app handlers.
* @private
*/
_syncDiscoveryTagTypesFromPatterns()
{
if (!this._config.tagDiscovery) {
return
}
this._config.tagDiscovery.tagTypes = this._getPinnedFilenameTagTypes()
}
/**
* While download selection mode is on, block native drag on search thumbs
* so click-to-queue is not stolen by image/link dragging. Applies body class
* `bv-dm-selection` for consumer CSS (pointer-events on thumb tiles).
* @return {void}
*/
registerSelectionDragGuard()
{
let pageConfig = this._getActivePageConfig()
let active = !!(this.isDownloadManagerEnabled() && this._isSelectionModeActive() &&
this.isDownloadPageRole('selection'))
document.body.classList.toggle('bv-dm-selection', active)
if (this._selectionDragStartHandler) {
document.removeEventListener('dragstart', this._selectionDragStartHandler, true)
this._selectionDragStartHandler = null
}
if (!active || !pageConfig?.itemSelector) {
return
}
let itemSelector = pageConfig.itemSelector
this._selectionDragStartHandler = (event) => {
if (event.target instanceof Element && event.target.closest(itemSelector)) {
event.preventDefault()
}
}
document.addEventListener('dragstart', this._selectionDragStartHandler, true)
}
/**
* @return {boolean}
* @private
*/
_isSelectionModeActive()
{
return !!this._selectionModeActive
}
/**
* @private
*/
_bindSelectionHandlers()
{
if (this._selectionClickHandler) {
document.removeEventListener('click', this._selectionClickHandler)
this._selectionClickHandler = null
}
let pageConfig = this._getActivePageConfig()
if (!pageConfig?.resolveItem || !pageConfig.itemSelector) {
return
}
if (!this._isSelectionModeActive() || !this.isDownloadManagerEnabled()) {
return
}
let itemSelector = pageConfig.itemSelector
this._selectionClickHandler = (event) => {
let target = event.target.closest(itemSelector)
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.addEventListener('click', this._selectionClickHandler)
}
/**
* @param {object} resolved
* @param {HTMLElement} element
* @return {Promise<void>}
* @private
*/
async _toggleSelectionItem(resolved, element)
{
let itemKey = String(resolved.itemId)
let epoch = (this._selectionOpEpoch.get(itemKey) || 0) + 1
this._selectionOpEpoch.set(itemKey, epoch)
// Sync local heuristic only — never await IDB before painting click feedback.
let locallySelected = this._trackedItemElements.has(itemKey) || this._selectionUiPending.has(itemKey)
if (locallySelected) {
// Clear overlay immediately; hide until IDB dequeue finishes so stale sync cannot re-paint.
this._selectionUiPending.delete(itemKey)
this._selectionUiHidden.add(itemKey)
this._untrackItemElement(resolved.itemId)
this._setItemProgress(element, null)
await this.dequeueDownload(resolved.itemId)
if (!(await this.isQueued(resolved.itemId))) {
this._selectionUiHidden.delete(itemKey)
}
return
}
// Optimistic paint before any IDB (isQueued / enqueue) so click→blur is not gated on storage.
this._selectionUiHidden.delete(itemKey)
this._selectionUiPending.add(itemKey)
let layout = this._getPipelineStepLayout()
this._trackItemElement(resolved.itemId, element)
this._setItemProgress(element, {
label: 'Queued',
stepIndex: layout.indices.queued,
stepCount: layout.stepCount,
})
try {
// Rare: queued in IDB but not tracked yet (other tab / refresh race) — treat as deselect.
if (await this.isQueued(resolved.itemId)) {
if (this._selectionOpEpoch.get(itemKey) !== epoch) {
return
}
this._selectionUiPending.delete(itemKey)
this._selectionUiHidden.add(itemKey)
this._untrackItemElement(resolved.itemId)
this._setItemProgress(element, null)
await this.dequeueDownload(resolved.itemId)
if (!(await this.isQueued(resolved.itemId))) {
this._selectionUiHidden.delete(itemKey)
}
return
}
if (this._selectionOpEpoch.get(itemKey) !== epoch) {
return
}
let added = await this.enqueueDownload(resolved)
if (this._selectionOpEpoch.get(itemKey) !== epoch) {
// A later click cancelled this select — keep UI hidden and drop the row if written.
this._selectionUiPending.delete(itemKey)
this._selectionUiHidden.add(itemKey)
this._untrackItemElement(resolved.itemId)
this._setItemProgress(element, null)
if (added) {
await this.dequeueDownload(resolved.itemId)
}
if (!(await this.isQueued(resolved.itemId))) {
this._selectionUiHidden.delete(itemKey)
}
return
}
this._selectionUiPending.delete(itemKey)
if (!added) {
// Another tab may have enqueued first — keep overlay if still in the pipeline.
if (await this.isQueued(resolved.itemId)) {
await this._renderItemProgress(resolved.itemId)
return
}
this._untrackItemElement(resolved.itemId)
this._setItemProgress(element, null)
return
}
await this._renderItemProgress(resolved.itemId)
} catch (e) {
// IDB wedged / closed mid-click — drop optimistic paint so UI matches persistence.
if (this._selectionOpEpoch.get(itemKey) !== epoch) {
return
}
this._selectionUiPending.delete(itemKey)
this._untrackItemElement(resolved.itemId)
this._setItemProgress(element, null)
console.warn('[BrazenDM] selection enqueue failed:', e)
}
}
/**
* @private
*/
_refreshSelectionMarks()
{
let pageConfig = this._getActivePageConfig()
if (!pageConfig?.itemSelector || !this.isDownloadPageRole('selection')) {
return
}
// Synchronous paint from the sessionStorage mirror before async IDB reconcile.
this._paintSelectionMarksFromMirror()
void (async () => {
let selectionActive = this._isSelectionModeActive()
let mirrorIds = this._readSelectionMarkMirror()
let [resolutionIds, downloadIds] = await Promise.all([
this._repos().downloadResolutionQueue.listActiveItemIds(),
this._repos().downloadQueue.listActiveItemIds(),
])
let activeIds = new Set([...resolutionIds, ...downloadIds].map(String))
this._scheduleSelectionMarkMirrorWrite(activeIds)
/** @type {Promise<void>[]} */
let progressPaints = []
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 = activeIds.has(itemKey)
if (this._selectionUiHidden.has(itemKey)) {
if (!queued) {
this._selectionUiHidden.delete(itemKey)
}
continue
}
let selected = selectionActive && queued
let inMirror = mirrorIds.has(itemKey)
let hasOverlay = element.dataset.bvDmProgress != null && element.dataset.bvDmProgress !== ''
// Keep DOM↔item mapping warm so processor claim/complete refreshes find the tile.
if (selected) {
this._trackItemElement(resolved.itemId, element)
progressPaints.push(this._renderItemProgress(resolved.itemId))
continue
}
if (this._selectionUiPending.has(itemKey)) {
// Optimistic select — IDB row not visible yet; leave the painted overlay alone.
continue
}
if (this._trackedItemElements.has(itemKey) || inMirror || hasOverlay) {
progressPaints.push((async () => {
await this._renderItemProgress(resolved.itemId)
if (!queued) {
let progress = await this._resolveItemPipelineView(resolved.itemId)
if (!progress) {
this._untrackItemElement(resolved.itemId)
this._setItemProgress(element, null)
if (inMirror) {
this._removeSelectionMarkMirrorId(resolved.itemId)
}
}
}
})())
}
}
if (progressPaints.length) {
await Promise.all(progressPaints)
}
})()
}
/**
* sessionStorage key for the bounded active-id mirror (selection-mark paint).
* @return {string}
* @private
*/
_selectionMarkMirrorKey()
{
return BrazenDownloadManager.storageKey(this._cm._scriptPrefix, 'dm-active-item-ids')
}
/**
* @return {Set<string>}
* @private
*/
_readSelectionMarkMirror()
{
try {
let raw = sessionStorage.getItem(this._selectionMarkMirrorKey())
if (!raw) {
return new Set()
}
let parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) {
return new Set()
}
return new Set(parsed.map(String).slice(0, SELECTION_MARK_MIRROR_MAX))
} catch (e) {
return new Set()
}
}
/**
* @param {Iterable<string>} ids
* @private
*/
_writeSelectionMarkMirror(ids)
{
try {
let list = [...ids].map(String)
if (list.length > SELECTION_MARK_MIRROR_MAX) {
list = list.slice(0, SELECTION_MARK_MIRROR_MAX)
}
sessionStorage.setItem(this._selectionMarkMirrorKey(), JSON.stringify(list))
} catch (e) {
// ignore unavailable sessionStorage
}
}
/**
* Debounced authoritative mirror write after async active-id reads.
* @param {Set<string>} ids
* @private
*/
_scheduleSelectionMarkMirrorWrite(ids)
{
if (this._selectionMarkMirrorWriteTimer != null) {
clearTimeout(this._selectionMarkMirrorWriteTimer)
}
this._selectionMarkMirrorWriteTimer = setTimeout(() => {
this._selectionMarkMirrorWriteTimer = null
this._writeSelectionMarkMirror(ids)
}, 0)
}
/**
* @param {string|number} itemId
* @private
*/
_addSelectionMarkMirrorId(itemId)
{
let ids = this._readSelectionMarkMirror()
ids.add(String(itemId))
this._writeSelectionMarkMirror(ids)
}
/**
* @param {string|number} itemId
* @private
*/
_removeSelectionMarkMirrorId(itemId)
{
let ids = this._readSelectionMarkMirror()
if (!ids.delete(String(itemId))) {
return
}
this._writeSelectionMarkMirror(ids)
}
/**
* Paint Queued overlays from the sync sessionStorage mirror (before IDB reconcile).
* @private
*/
_paintSelectionMarksFromMirror()
{
if (!this._isSelectionModeActive() || !this.isDownloadPageRole('selection')) {
return
}
let pageConfig = this._getActivePageConfig()
if (!pageConfig?.itemSelector || !pageConfig.resolveItem) {
return
}
let ids = this._readSelectionMarkMirror()
if (!ids.size) {
return
}
let layout = this._getPipelineStepLayout()
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)
if (!ids.has(itemKey) || this._selectionUiHidden.has(itemKey)) {
continue
}
// Do not clobber an accurate overlay (Downloading / Ready / Done) with mirror Queued.
if (element.dataset.bvDmProgress) {
this._trackItemElement(resolved.itemId, element)
continue
}
this._trackItemElement(resolved.itemId, element)
this._setItemProgress(element, {
label: 'Queued',
stepIndex: layout.indices.queued,
stepCount: layout.stepCount,
})
}
}
// -------------------------------------------------------------------------
// Dock UI
// -------------------------------------------------------------------------
/**
* @private
*/
_setupDock()
{
if (this._config.tagDiscovery) {
this._cm.addActionField(DOCK_TAG_DISCOVERY_MODE).
setTitle('Tag Discovery Mode').
setHelp(DOWNLOAD_MANAGER_FIELD_DETAILED_HELP.DOCK_TAG_DISCOVERY_MODE).
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').
setHelp(DOWNLOAD_MANAGER_FIELD_DETAILED_HELP.DOCK_SELECTION_MODE).
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').
setHelp(DOWNLOAD_MANAGER_FIELD_DETAILED_HELP.DOCK_ADD_TO_QUEUE).
setAction(() => { void this.toggleCurrentMediaQueued() }).
setDockButton({
icon: () => this._currentMediaQueued ? 'queue-remove' : 'queue-add',
getState: () => this._currentMediaQueued ? 'bv-dock-btn-active' : '',
tooltip: () => this._currentMediaQueued
? 'In download queue — click to remove this post'
: 'Add this post to the download queue',
include: function() {
return this.isDownloadManagerEnabled() && this.isDownloadPageRole('enqueueMedia')
},
})
this._cm.addActionField(DOCK_DOWNLOAD_LEADER).
setTitle('Download Manager Leader Tab').
setHelp(DOWNLOAD_MANAGER_FIELD_DETAILED_HELP.DOCK_DOWNLOAD_LEADER).
setAction(() => { void this.requestDownloadManagerLeadership() }).
setDockButton({
icon: 'crown',
getState: () => this.isDownloadManagerLeaderTab() ? 'bv-dock-btn-active' : '',
tooltip: () => this.isDownloadManagerLeaderTab()
? 'This tab is the download manager leader'
: 'Make this tab the download manager leader',
include: () => this.isDownloadManagerEnabled(),
})
this._cm.addActionField(DOCK_CLEAR_DOWNLOAD_QUEUE).
setTitle('Clear Download Queue').
setHelp(DOWNLOAD_MANAGER_FIELD_DETAILED_HELP.DOCK_CLEAR_DOWNLOAD_QUEUE).
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').
setHelp(DOWNLOAD_MANAGER_FIELD_DETAILED_HELP.DOCK_DOWNLOAD_START_PAUSE).
setAction(() => { void this.toggleDownloadManagerPaused() }).
setDockButton({
icon: () => {
let state = this._getStateSync()
if (this._isStartPauseShowingHumanInteraction(state)) {
return 'play'
}
// Idle Play only when both queues empty — not when download drained mid-batch.
if (this._isDownloadBatchIdle()) {
return 'play'
}
return state?.paused ? 'play' : 'pause'
},
getState: () => {
let state = this._getStateSync()
if (this._isStartPauseShowingHumanInteraction(state)) {
return ''
}
if (this._isDownloadBatchIdle()) {
return ''
}
return state?.paused ? '' : 'bv-dock-btn-active'
},
isDisabled: () => {
let state = this._getStateSync()
if (this._isStartPauseShowingHumanInteraction(state)) {
return false
}
return this._isDownloadBatchIdle()
},
tooltip: () => {
let state = this._getStateSync()
if (this._isStartPauseShowingHumanInteraction(state)) {
return 'Verification required — click to confirm and resume'
}
if (this._isDownloadBatchIdle()) {
return 'Queues idle — click Start when the next batch has Ready downloads'
}
if (this.getPendingDownloadCountSync() <= 0 && this.getPendingResolutionCountSync() > 0) {
if (state?.paused) {
return 'Batch still resolving — downloads paused; click to start when Ready'
}
return 'Batch still resolving — downloads run as items become Ready; click to pause'
}
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.getPendingResolutionCountSync() > 0 || 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 {HTMLElement[]}
* @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 != null) : []
}
return [this.getOrCreateProgressSlot()]
}
/**
* @return {string[]}
* @private
*/
_getQueueDockSlideOutChildKeys()
{
let slideOut = this._config.queueDockSlideOut
if (Array.isArray(slideOut?.childFields)) {
return slideOut.childFields
}
return [DOCK_DOWNLOAD_LEADER, DOCK_CLEAR_DOWNLOAD_QUEUE]
}
/**
* Coalesced dock progress paint. Concurrent callers set a dirty flag; the runner
* loops until quiet so a resolution refresh cannot cancel a download increment paint.
* @param {{refreshAllItems?: boolean}} [options]
* @private
*/
_refreshDockProgress(options = {})
{
if (options.refreshAllItems === true) {
this._dockProgressRefreshAllItems = true
}
this._dockProgressRefreshWanted = true
if (this._dockProgressRefreshRunning) {
return
}
this._dockProgressRefreshRunning = true
void (async () => {
try {
while (this._dockProgressRefreshWanted) {
this._dockProgressRefreshWanted = false
let refreshAllItems = this._dockProgressRefreshAllItems
this._dockProgressRefreshAllItems = false
await this._paintDockProgress({refreshAllItems})
}
} finally {
this._dockProgressRefreshRunning = false
// A request may have arrived after the loop check but before Running cleared.
if (this._dockProgressRefreshWanted) {
this._refreshDockProgress({refreshAllItems: false})
}
}
})()
}
/**
* Update dock progress counters/bars. Remount slide-out only when pending work
* appears or clears; refresh Start/Pause chrome before painting the live node.
* @param {{refreshAllItems?: boolean}} [options]
* @return {Promise<void>}
* @private
*/
async _paintDockProgress(options = {})
{
// Early DM init runs before `_buildDock` — painting then fills a detached slot and
// leaves the live rail empty until a later wake. Skip until the dock exists.
if (!document.querySelector('.bv-dock .bv-dock-rail-body')) {
return
}
let refreshAllItems = options.refreshAllItems === true
let downloadCount = await this._getPendingDownloadCount()
let resolutionCount = await this._getPendingResolutionCount()
let prevDownload = this._cachedDownloadCount
let prevResolution = this._cachedResolutionCount
this._cachedDownloadCount = downloadCount
this._cachedResolutionCount = resolutionCount
let slideOutBefore = prevDownload > 0 || prevResolution > 0
let slideOutAfter = downloadCount > 0 || resolutionCount > 0
// Chrome first so `_refreshDockRootSlot` cannot detach the node after we paint it.
if (slideOutBefore !== slideOutAfter) {
this._framework.refreshDockInterface(undefined, {layout: false})
} else {
this._cm.refreshDockButtonStates()
}
let showProgress = (resolutionCount > 0 || downloadCount > 0) && this.isDownloadManagerEnabled()
let progressElement = this.getOrCreateProgressSlot()
if (showProgress && !this._isProgressSlotConnected(progressElement)) {
this._remountDownloadStartPauseSlot()
progressElement = this.getOrCreateProgressSlot()
}
BrazenViewLayer.setDownloadManagerProgressSlotVisible(progressElement, showProgress)
if (showProgress) {
let state = await this._getState()
let completedResolution = Number(state.completedResolutionCount) || 0
let completedDownload = Number(state.completedDownloadCount) || 0
BrazenViewLayer.updateDownloadManagerProgressSlot(progressElement, {
resolution: {
current: completedResolution,
total: completedResolution + resolutionCount,
},
download: {
current: completedDownload,
total: completedDownload + downloadCount,
},
})
}
if (refreshAllItems) {
this._refreshAllItemProgress()
}
}
// -------------------------------------------------------------------------
// 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()
}
}
/** @type {Readonly<Record<string, string>>} */
BrazenDownloadManager.FIELD_DETAILED_HELP = DOWNLOAD_MANAGER_FIELD_DETAILED_HELP
/** @type {Readonly<typeof BRAZEN_DOWNLOAD_PATH_FIELD_KEYS>} */
BrazenDownloadManager.BRAZEN_DOWNLOAD_PATH_FIELD_KEYS = BRAZEN_DOWNLOAD_PATH_FIELD_KEYS