Cross-tab download queue, resolution pipeline, and immediate downloads for Brazen user scripts
بۇ قوليازمىنى بىۋاسىتە قاچىلاشقا بولمايدۇ. بۇ باشقا قوليازمىلارنىڭ ئىشلىتىشى ئۈچۈن تەمىنلەنگەن ئامبار بولۇپ، ئىشلىتىش ئۈچۈن مېتا كۆرسەتمىسىگە قىستۇرىدىغان كود: // @require https://update.greasyfork.org/scripts/587126/1877680/Brazen%20Framework%20-%20Download%20Manager.js
// ==UserScript==
// @name Brazen Framework - Download Manager
// @namespace brazenvoid
// @version 1.2.0
// @author brazenvoid
// @license GPL-3.0-only
// @description Cross-tab download queue, resolution pipeline, and immediate downloads for Brazen user scripts
// @grant GM_download
// ==/UserScript==
const OPTION_ENABLE_DOWNLOAD_MANAGER = 'enable-download-manager'
const OPTION_DOWNLOAD_SELECTION_MODE_DEFAULT = 'download-selection-mode-default'
const DOCK_TAG_DISCOVERY_MODE = 'dock-tag-discovery-mode'
const DOCK_SELECTION_MODE = 'dock-selection-mode'
const DOCK_ADD_TO_QUEUE = 'dock-add-to-queue'
const DOCK_DOWNLOAD_START_PAUSE = 'dock-download-start-pause'
const DOCK_CLEAR_DOWNLOAD_QUEUE = 'dock-clear-download-queue'
const RESOLUTION_TERMINAL = new Set(['failed', 'done'])
const DOWNLOAD_TERMINAL = new Set(['done', 'skipped', 'duplicate', 'failed'])
/** Leader heartbeat interval while this tab owns {@link processingTabId}. */
const PROCESSING_HEARTBEAT_INTERVAL_MS = 3000
/** Steal the processor lock if the owner has not heartbeated within this window (covers refresh / crash). */
const PROCESSING_LOCK_STALE_MS = 12000
class BrazenDownloadManager
{
// -------------------------------------------------------------------------
// Static public methods
// -------------------------------------------------------------------------
/**
* True when this script's shared human-interaction block is mirrored in localStorage.
* Usable in Phase-1 page ops before Download Manager initialize().
* @param {string|null|undefined} scriptPrefix
* @return {boolean}
*/
static peekHumanInteractionBlockedMirror(scriptPrefix)
{
try {
return localStorage.getItem((scriptPrefix ?? 'brazen-') + 'dm-human-interaction-blocked') === '1'
} catch (e) {
return false
}
}
/**
* True when shared DM state has `humanInteractionBlocked` (IndexedDB).
* Usable in Phase-1 before Download Manager initialize() — survives CF stripping
* `brazen_hi` and Chrome's default `noopener` on `window.open`.
* @param {string|null|undefined} scriptPrefix
* @return {Promise<boolean>}
*/
static async peekHumanInteractionBlockedIdb(scriptPrefix)
{
let prefix = scriptPrefix ?? 'brazen-'
try {
if (typeof BrazenStorageRepositories !== 'function') {
return false
}
let repos = new BrazenStorageRepositories(prefix)
if (!repos.storage.available) {
return false
}
await repos.storage.open()
let state = await repos.downloadManagerState.get()
return !!state?.humanInteractionBlocked
} catch (e) {
return false
}
}
/**
* True when the URL still carries a queue-verification marker (`brazen_hi` query or hash).
* @return {boolean}
*/
static hasBrazenHumanInteractionMarker()
{
try {
let liveParams = new URLSearchParams(location.search)
if (liveParams.get('brazen_hi') === '1') {
return true
}
let hash = location.hash || ''
if (/(^|[&#?])brazen_hi=1\b/.test(hash) || hash === '#brazen_hi') {
return true
}
} catch (e) {
// ignore
}
return false
}
/**
* True when this tab was opened by same-origin `window.open` / `<a rel="opener">`
* (queue verification). Chrome's default `noopener` on `window.open(_blank)` breaks this
* unless the opener used {@link BrazenDownloadManager#_openHumanInteractionTab}.
* @return {boolean}
*/
static isQueueOpenedVerificationTab()
{
try {
let opener = window.opener
if (!opener || opener.closed) {
return false
}
void opener.location.href
return true
} catch (e) {
return false
}
}
/**
* Whether a mediaCloudflare / challenge page should stay silent (queue owns the leader pane).
* Fail closed toward silence. Usable in Phase-1 before `initialize()`.
* @param {string|null|undefined} scriptPrefix
* @param {BrazenDownloadManager|null|undefined} [instance] optional live instance for sync state
* @return {Promise<boolean>}
*/
static async shouldSilenceMediaCloudflarePrompt(scriptPrefix, instance = null)
{
if (BrazenDownloadManager.hasBrazenHumanInteractionMarker()) {
return true
}
if (BrazenDownloadManager.peekHumanInteractionBlockedMirror(scriptPrefix)) {
return true
}
if (instance?.isHumanInteractionBlockedSync?.()) {
return true
}
if (BrazenDownloadManager.isQueueOpenedVerificationTab()) {
return true
}
if (await BrazenDownloadManager.peekHumanInteractionBlockedIdb(scriptPrefix)) {
return true
}
return false
}
// -------------------------------------------------------------------------
// Constructor
// -------------------------------------------------------------------------
/**
* @param {BrazenFramework} framework
* @param {BrazenConfigurationManager} configurationManager
* @param {object} config
*/
constructor(framework, configurationManager, config)
{
this._framework = framework
this._cm = configurationManager
this._config = config
this._tabId = Utilities.generateId('dm-tab-')
/**
* When true, resolution and download share one serial loop and shared block gates.
* Default false — pipelines run concurrently with independent gates.
* @type {boolean}
*/
this._linkQueues = config.linkQueues === true
/** @type {boolean} Linked-mode serial processor lock. */
this._processing = false
/** @type {boolean} Set when linked `_runLinkedProcessors` is kicked while already running. */
this._processorsWakeRequested = 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
/** @type {boolean} Download loop should stop after a foreign-pipeline HI deferred a rate limit. */
this._downloadYieldForForeignHi = false
/** @type {boolean} Resolution loop should stop after a foreign-pipeline HI deferred a rate limit. */
this._resolutionYieldForForeignHi = false
this._immediateQueue = []
this._immediateProcessing = false
this._selectionClickHandler = null
this._selectionModeActive = false
this._progressElement = null
this._tagDiscoveryPanel = null
this._humanInteractionPanel = null
this._trackedItemElements = new Map()
/** @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()
this._registerConfigFields()
this._setupDock()
}
/**
* @private
*/
_registerConfigFields()
{
if (this._config.enabled !== true) {
let enableKey = this._config.enableConfigKey ?? OPTION_ENABLE_DOWNLOAD_MANAGER
if (!this._cm.getField(enableKey)) {
this._cm.addFlagField(enableKey).
setTitle('Enable Download Manager').
setHelpText('Show batch download queue controls on the dock and allow selection-mode enqueue.').
setDefault(!!this._config.enableDefault)
}
}
let selectionDefaultKey = this._config.selectionModeDefaultConfigKey ?? OPTION_DOWNLOAD_SELECTION_MODE_DEFAULT
if (!this._cm.getField(selectionDefaultKey)) {
this._cm.addFlagField(selectionDefaultKey).
setTitle('Start in Selection Mode').
setHelpText(
'On load, turn selection mode on for search pages that support batch enqueue. ' +
'You can still toggle it off from the dock for this visit.',
).
setDefault(false)
}
}
/**
* @return {Promise<void>}
*/
async initialize()
{
await this._ensureState()
this._selectionModeActive = this._shouldStartSelectionMode()
try {
sessionStorage.removeItem((this._cm._scriptPrefix ?? 'brazen-') + 'dm-selection')
} catch (error) {
// ignore unavailable sessionStorage
}
this._setupCrossTabSync()
if (this._cm.canPersist()) {
this._hydrateInitiationClocksFromState(await this._getState())
}
this._framework.onConfigurationChange((event) => {
let source = event?.source
// Queue / DM state / ledger puts fire on every processor step — UI updates are owned
// by task-complete hooks, not by this broadcast.
if (source === 'downloadResolutionQueue' || source === 'downloadQueue' ||
source === 'downloadManagerState' || source === 'ledger') {
return
}
this._refreshTagDiscoveryPanel()
this._refreshDockProgress()
this._refreshSelectionMarks()
if (this.isDownloadPageRole('selection')) {
this._bindSelectionHandlers()
}
})
this._framework.onTagSubstitutionUiChange(() => {
this._refreshTagDiscoveryPanel()
})
if (this.isDownloadPageRole('selection')) {
this._bindSelectionHandlers()
}
await this._syncFromStorage()
await this._tryClaimProcessingLeadership()
if (this._getStateSync()?.humanInteractionBlocked) {
void this._promptHumanInteractionResume()
}
await this._restoreTagDiscoveryPanelIfNeeded()
if (this._shouldRunProcessors()) {
void this._runProcessors()
}
}
/**
* @return {boolean}
* @private
*/
_shouldStartSelectionMode()
{
if (!this.isDownloadManagerEnabled() || !this.isDownloadPageRole('selection')) {
return false
}
let key = this._config.selectionModeDefaultConfigKey ?? OPTION_DOWNLOAD_SELECTION_MODE_DEFAULT
return !!this._framework._getConfig(key)
}
// -------------------------------------------------------------------------
// Public API
// -------------------------------------------------------------------------
/**
* @param {{mediaElement: HTMLElement, removeMediaOnSuccess?: boolean, source?: string}} options
* @return {Promise<void>}
*/
async downloadImmediate(options)
{
if (!this.isDownloadPageRole('immediateDownload') && options.source !== 'immediate') {
return
}
let paths = this._config.downloadPaths
let mediaElement = options.mediaElement
let data = paths.extractMediaData(mediaElement)
let tagGroups = paths.extractTagGroups()
let tagIncidences = typeof paths.extractTagIncidences === 'function' ?
(paths.extractTagIncidences() ?? {}) :
{}
let downloadId = this._framework._getDownloadDuplicateLedgerId(mediaElement)
await this._registerResolvedTagGroups(tagGroups)
if (this._isTagDiscoveryActive()) {
let lists = await this._partitionDiscoveryTags(tagGroups, tagIncidences)
if (lists.unknown.length) {
await this._withState((state) => {
state.resolutionBlocked = true
state.discoveryPanelTags = this._mergeDiscoveryTags(state.discoveryPanelTags, lists.unknown)
state.discoveryPanelKnownTags = lists.known
state.pendingImmediateDownload = {
mediaElement,
removeMediaOnSuccess: !!options.removeMediaOnSuccess,
data,
tagGroups,
tagIncidences,
downloadId,
sourceUrl: location.href,
}
})
let state = this._getStateSync()
await this._showTagDiscoveryPanel(state.discoveryPanelTags, state.discoveryPanelKnownTags)
return
}
}
await this._executeImmediateDownload({
mediaElement,
removeMediaOnSuccess: !!options.removeMediaOnSuccess,
data,
tagGroups,
downloadId,
})
}
/**
* Adds a post to the resolution queue when it is not already in the pipeline.
* Dedupe checks both queues: resolution (pending/in-progress resolve) and download
* (already resolved, waiting or downloading). Items already in the download queue
* are never re-enqueued for resolution.
*
* @param {object} context
* @return {Promise<boolean>} whether the item was added to the queue
*/
async enqueueDownload(context)
{
let itemId = this._config.getQueueItemId(context)
if (!itemId) {
return false
}
if (await this.isQueued(itemId)) {
return false
}
// Reset before enqueue so a prior session's completed counts do not inflate dock totals
// (e.g. 4 prior + 1 new looking like a 5-item queue).
await this._resetProgressCountersIfIdle()
await this._clearTerminalQueueRows(itemId)
let row = {
itemId,
downloadType: context.downloadType ?? this._getActivePageConfig()?.defaultDownloadType ?? 'post',
sourceUrl: context.sourceUrl ?? '',
status: 'queued',
originTabId: this._tabId,
resolveContext: context.resolveContext ?? {},
pendingTagGroups: null,
addedAt: Date.now(),
error: null,
}
await this._repos().downloadResolutionQueue.put(row)
await this._tryClaimProcessingLeadership()
await this._syncFromStorage()
if (this._shouldRunProcessors()) {
void this._runProcessors()
}
return true
}
/**
* @param {string} itemId
* @return {Promise<void>}
* @private
*/
async _clearTerminalQueueRows(itemId)
{
let resolution = await this._repos().downloadResolutionQueue.get(itemId)
if (resolution && RESOLUTION_TERMINAL.has(resolution.status)) {
await this._repos().downloadResolutionQueue.remove(itemId)
}
let download = await this._repos().downloadQueue.get(itemId)
if (download && DOWNLOAD_TERMINAL.has(download.status)) {
await this._repos().downloadQueue.remove(itemId)
}
}
/**
* @param {string} itemId
* @return {Promise<void>}
*/
async dequeueDownload(itemId)
{
await this._repos().downloadResolutionQueue.remove(itemId)
await this._repos().downloadQueue.remove(itemId)
await this._syncFromStorage()
this._refreshSelectionMarks()
}
/**
* Whether a post is anywhere in the active download pipeline (either queue).
* Resolution queue: queued, resolving, tagReview, etc.
* Download queue: queued or downloading (already resolved — no re-resolution).
*
* @param {string} itemId
* @return {Promise<boolean>}
*/
async isQueued(itemId)
{
let resolution = await this._repos().downloadResolutionQueue.get(itemId)
if (resolution && !RESOLUTION_TERMINAL.has(resolution.status)) {
return true
}
let download = await this._repos().downloadQueue.get(itemId)
return !!(download && !DOWNLOAD_TERMINAL.has(download.status))
}
/**
* @return {Promise<void>}
*/
async confirmTagDiscoveryMappings()
{
let state = await this._getState()
if (!state.resolutionBlocked) {
return
}
await this._confirmDiscoveryPanelTagTypes(state.discoveryPanelTags)
if (state.pendingImmediateDownload) {
let pending = state.pendingImmediateDownload
await this._withState((next) => {
next.resolutionBlocked = false
next.discoveryPanelTags = null
next.discoveryPanelKnownTags = null
next.pendingImmediateDownload = null
next.tagDiscoveryPanelTabId = null
})
this._hideTagDiscoveryPanel()
await this._executeImmediateDownload(pending)
if (this._shouldRunProcessors()) {
void this._runProcessors()
}
return
}
let itemId = state.resolutionBlockedItemId
if (!itemId) {
await this._withState((next) => {
next.resolutionBlocked = false
next.discoveryPanelTags = null
next.discoveryPanelKnownTags = null
next.tagDiscoveryPanelTabId = null
})
this._hideTagDiscoveryPanel()
return
}
let item = await this._repos().downloadResolutionQueue.get(itemId)
if (!item?.pendingTagGroups) {
await this._withState((next) => {
next.resolutionBlocked = false
next.resolutionBlockedItemId = null
next.discoveryPanelTags = null
next.discoveryPanelKnownTags = null
next.tagDiscoveryPanelTabId = null
})
this._hideTagDiscoveryPanel()
return
}
await this._promoteToDownloadQueue(item, item.pendingTagGroups)
await this._repos().downloadResolutionQueue.remove(itemId)
await this._withState((next) => {
next.resolutionBlocked = false
next.resolutionBlockedItemId = null
next.discoveryPanelTags = null
next.discoveryPanelKnownTags = null
next.tagDiscoveryPanelTabId = null
})
this._hideTagDiscoveryPanel()
await this._syncFromStorage()
this._refreshItemProgress(itemId)
if (this._shouldRunProcessors()) {
void this._runProcessors()
}
}
/**
* Skip the current resolution item waiting on tag review (do not promote to download;
* do not confirm tag types so discovery can fire again later).
* @return {Promise<void>}
*/
async skipTagDiscoveryInclusion()
{
let state = await this._getState()
if (!state.resolutionBlocked) {
return
}
this._hideTagDiscoveryPanel()
let itemId = state.resolutionBlockedItemId
let clearImmediate = !!state.pendingImmediateDownload
if (itemId) {
await this._repos().downloadResolutionQueue.remove(itemId)
// Clear progress on the element before untracking — `_renderItemProgress` no-ops
// when the tile is already missing from `_trackedItemElements`.
let element = this._trackedItemElements.get(String(itemId))
this._untrackItemElement(itemId)
if (element) {
this._setItemProgress(element, null)
}
}
// Serialize gate clear through `_withState` so a concurrent heartbeat put cannot
// resurrect `resolutionBlocked` from a stale snapshot (raw `_putState` race).
await this._withState((next) => {
next.resolutionBlocked = false
next.resolutionBlockedItemId = null
next.discoveryPanelTags = null
next.discoveryPanelKnownTags = null
next.tagDiscoveryPanelTabId = null
if (clearImmediate) {
next.pendingImmediateDownload = null
}
})
await this._syncFromStorage()
if (this._shouldRunProcessors()) {
void this._runProcessors()
}
}
/**
* Open the media page for the item currently under tag review.
* @return {Promise<void>}
*/
async openTagDiscoveryMedia()
{
let state = await this._getState()
if (!state.resolutionBlocked) {
return
}
let openUrl = state.pendingImmediateDownload?.sourceUrl ?? null
if (!openUrl && state.resolutionBlockedItemId) {
let item = await this._repos().downloadResolutionQueue.get(state.resolutionBlockedItemId)
openUrl = item?.sourceUrl
|| item?.pendingTagGroups?.metadata?.sourceUrl
|| null
}
if (openUrl) {
window.open(openUrl, '_blank')
}
}
/**
* @return {Promise<void>}
*/
async toggleDownloadManagerPaused()
{
let state = await this._getState()
if (state.humanInteractionBlocked) {
// Linked: any HI owns Start. Unlinked: only download-context HI.
if (this._linkQueues || state.humanInteractionContext === 'download') {
void this._promptHumanInteractionResume()
return
}
}
// Start/Pause only affects the download queue — never resolution.
if ((await this._getPendingDownloadCount()) <= 0) {
return
}
state.paused = !state.paused
await this._putState(state)
if (!state.paused) {
await this._tryClaimProcessingLeadership()
}
await this._syncFromStorage()
if (!state.paused) {
void this._runProcessors()
}
}
/**
* Clears pending/in-progress download-queue rows only. Does not touch the resolution
* queue, tag discovery, selection mode, or processor leadership.
* @return {Promise<void>}
*/
async clearDownloadQueue()
{
let state = await this._getState()
state.paused = true
state.completedDownloadCount = 0
if (state.humanInteractionBlocked && state.humanInteractionContext === 'download') {
this._hideHumanInteractionPanel()
state.humanInteractionBlocked = false
state.humanInteractionContext = null
state.humanInteractionItemId = null
state.humanInteractionPromptTabId = null
state.humanInteractionOpenUrl = null
this._writeHumanInteractionBlockMirror(false)
}
await this._putState(state)
await this._repos().downloadQueue.clearAll()
await this._syncFromStorage()
this._refreshSelectionMarks()
if (this._shouldRunProcessors()) {
void this._runProcessors()
}
}
/**
* @return {Promise<{resolution: {current: number, total: number}, download: {current: number, total: number}}>}
*/
async getDownloadManagerProgress()
{
let downloadRows = await this._repos().downloadQueue.listAll()
let resolutionRows = await this._repos().downloadResolutionQueue.listAll()
let state = await this._getState()
let downloadActive = downloadRows.filter((row) => !DOWNLOAD_TERMINAL.has(row.status))
let resolutionActive = resolutionRows.filter((row) => !RESOLUTION_TERMINAL.has(row.status))
let completedResolution = Number(state.completedResolutionCount) || 0
let completedDownload = Number(state.completedDownloadCount) || 0
return {
resolution: {
current: completedResolution,
total: completedResolution + resolutionActive.length,
},
download: {
current: completedDownload,
total: completedDownload + downloadActive.length,
},
}
}
/**
* @return {Promise<number>}
* @private
*/
async _getPendingDownloadCount()
{
let downloadRows = await this._repos().downloadQueue.listAll()
return downloadRows.filter((row) => !DOWNLOAD_TERMINAL.has(row.status)).length
}
/**
* @return {number}
*/
getPendingResolutionCountSync()
{
return this._cachedResolutionCount ?? 0
}
/**
* @return {Promise<number>}
* @private
*/
async _getPendingResolutionCount()
{
let resolutionRows = await this._repos().downloadResolutionQueue.listAll()
return resolutionRows.filter((row) => !RESOLUTION_TERMINAL.has(row.status)).length
}
/**
* Non-terminal items in both queues (item progress / internal pipeline checks).
* @return {Promise<number>}
*/
async getPendingPipelineCount()
{
return this._getPendingPipelineCount()
}
/**
* @return {Promise<number>}
* @private
*/
async _getPendingPipelineCount()
{
let downloadRows = await this._repos().downloadQueue.listAll()
let downloadCount = downloadRows.filter((row) => !DOWNLOAD_TERMINAL.has(row.status)).length
let resolutionRows = await this._repos().downloadResolutionQueue.listAll()
let resolutionCount = resolutionRows.filter((row) => !RESOLUTION_TERMINAL.has(row.status)).length
return resolutionCount + downloadCount
}
/**
* @return {number}
*/
getPendingDownloadCountSync()
{
return this._cachedDownloadCount ?? 0
}
/**
* @return {Promise<number>}
*/
async getDownloadQueueCount()
{
let downloadRows = await this._repos().downloadQueue.listAll()
let downloadCount = downloadRows.filter((row) => !DOWNLOAD_TERMINAL.has(row.status)).length
if (this._isTagDiscoveryActive()) {
return downloadCount
}
let resolutionRows = await this._repos().downloadResolutionQueue.listAll()
let resolutionCount = resolutionRows.filter((row) => !RESOLUTION_TERMINAL.has(row.status)).length
return resolutionCount + downloadCount
}
/**
* @return {JQuery}
*/
getOrCreateProgressSlot()
{
if (!this._progressElement?.length) {
this._progressElement = BrazenViewLayer.createDownloadManagerProgressSlot()
}
return this._progressElement
}
/**
* @return {Promise<void>}
*/
async toggleTagDiscoveryMode()
{
if (!this._config.tagDiscovery) {
return
}
let state = await this._getState()
state.tagDiscoveryEnabled = !state.tagDiscoveryEnabled
await this._putState(state)
this._framework.refreshDockInterface(undefined, {layout: false})
}
/**
* @return {void}
*/
toggleSelectionMode()
{
if (!this.isDownloadPageRole('selection')) {
return
}
this._selectionModeActive = !this._selectionModeActive
this._framework.refreshDockInterface(undefined, {layout: false})
this._bindSelectionHandlers()
this._refreshSelectionMarks()
}
/**
* @return {Promise<void>}
*/
async toggleCurrentMediaQueued()
{
if (!this.isDownloadPageRole('enqueueMedia')) {
return
}
let itemId = this._config.getQueueItemId({sourceUrl: location.href})
if (!itemId) {
return
}
if (await this.isQueued(itemId)) {
await this.dequeueDownload(itemId)
return
}
await this.enqueueDownload({
itemId,
sourceUrl: location.href,
downloadType: this._getActivePageConfig()?.defaultDownloadType ?? 'post',
resolveContext: {fromMediaPage: true},
})
}
/**
* @param {string} role
* @return {boolean}
*/
isDownloadPageRole(role)
{
let pageName = this._getActivePageName()
if (!pageName) {
return false
}
let pageConfig = this._config.pages?.[pageName]
return !!(pageConfig?.roles?.includes(role))
}
/**
* @return {boolean}
*/
isDownloadManagerEnabled()
{
if (!this._cm.isDockActive()) {
return false
}
if (this._config.enabled === true) {
return true
}
let enableKey = this._config.enableConfigKey ?? OPTION_ENABLE_DOWNLOAD_MANAGER
return !!this._framework._getConfig(enableKey)
}
/**
* @return {boolean}
*/
isDownloadManagerRunning()
{
if (!this.isDownloadManagerEnabled()) {
return false
}
let state = this._getStateSync()
if (!state) {
return false
}
if (this._processing || this._resolutionProcessing || this._downloadProcessing) {
return true
}
if (state.humanInteractionBlocked || state.resolutionBlocked || state.pendingImmediateDownload) {
return true
}
if (!state.resolutionBlocked && this.getPendingResolutionCountSync() > 0) {
return true
}
if (!state.paused && this.getPendingDownloadCountSync() > 0) {
return true
}
return false
}
/**
* True when this tab owns processor leadership ({@link processingTabId}).
* @return {boolean}
*/
isDownloadManagerLeaderTab()
{
return this._getStateSync()?.processingTabId === this._tabId
}
/**
* @param {string[]} lines
* @param {function(string): string} normalizeToken
* @return {{subject: string, replacement: string}[]}
*/
parseDownloadTagSubstitutionLines(lines, normalizeToken)
{
return this._parseDownloadTagSubstitutionLines(lines, normalizeToken)
}
/**
* @param {{subject: string, replacement: string}[]} rules
* @return {Map<string, string>}
*/
buildDownloadTagSubstitutionMap(rules)
{
return this._buildDownloadTagSubstitutionMap(rules)
}
// -------------------------------------------------------------------------
// Path creation (moved from BrazenFramework)
// -------------------------------------------------------------------------
/**
* @param {string} text
* @return {string}
*/
decodeHtmlEntities(text)
{
if (typeof text !== 'string' || text.length === 0) {
return ''
}
if (!text.includes('&')) {
return text
}
let textarea = document.createElement('textarea')
textarea.innerHTML = text
return textarea.value
}
/**
* @param {string} segment
* @return {string}
*/
sanitizePathSegment(segment)
{
return this.decodeHtmlEntities(String(segment)).
replace(/[<>:"/\\|?*]/g, '-').
replace(/[.\s]+$/, '').
trim()
}
/**
* @param {string} folder
* @param {string} name
* @return {string}
*/
buildDownloadPath(folder, name)
{
let folderPath = String(folder).split('/').
map((segment) => this.sanitizePathSegment(segment)).
filter((segment) => segment.length).
join('/').
substring(0, 120).
replace(/[.\s/]+$/, '')
let fileName = this.sanitizePathSegment(name) || 'media'
return folderPath ? folderPath + '/' + fileName : fileName
}
/**
* @param {{}} data
* @param {{}} tagGroups
* @return {string}
*/
buildDownloadPathFromPatterns(data, tagGroups)
{
let paths = this._config.downloadPaths
let resolver = paths.getPatternResolver()
let filenamePattern = this._framework._getConfig(paths.filenamePatternConfigKey)
let name = this._resolveDownloadPattern(filenamePattern, data, tagGroups, resolver)
if (!name) {
name = paths.nameFallback?.(data) ?? (data.md5 || data.id || 'media')
}
if (paths.appendExtension && data.ext) {
name += '.' + data.ext
}
let root = this._framework._getConfig(paths.folderConfigKey) || paths.defaultFolder || ''
let subfolderPattern = paths.subfolderPatternConfigKey ?
this._framework._getConfig(paths.subfolderPatternConfigKey) :
''
let subfolder = subfolderPattern ? this._resolveDownloadPattern(subfolderPattern, data, tagGroups, resolver) : ''
let folder = subfolder ? root + '/' + subfolder : root
return this.buildDownloadPath(folder, name)
}
/**
* @param {string} pattern
* @param {{}} data
* @param {{}} tagGroups
* @param {object} resolver
* @return {string}
* @private
*/
_resolveDownloadPattern(pattern, data, tagGroups, resolver)
{
let resolved = pattern
let chips = [...resolver.chips].sort((a, b) => b.label.length - a.label.length)
for (let chip of chips) {
let value
if (resolver.tagTypes.includes(chip.token)) {
value = this._resolveDownloadTagTypeTokenForPath(pattern, tagGroups[chip.token] ?? [], chip.token, resolver.ignore, resolver)
} else {
value = data[chip.token] ?? ''
}
resolved = resolved.replaceAll(chip.label, value)
}
return resolved.replace(/\s+/g, ' ').trim()
}
/**
* @param {string} pattern
* @param {string[]} tags
* @param {string} type
* @param {Set<string>} ignore
* @param {object} resolver
* @return {string}
* @private
*/
_resolveDownloadTagTypeTokenForPath(pattern, tags, type, ignore, resolver)
{
let value = this._joinTagsForDownloadPath(tags, type, ignore, resolver)
if (!value && this._patternEmploysDownloadTagTypeToken(pattern, type, resolver.chips, resolver.unknownTypes)) {
return resolver.unknownDefault
}
return value
}
/**
* @param {string} pattern
* @param {string} type
* @param {{token: string, label: string}[]} chips
* @param {Set<string>} unknownTypes
* @return {boolean}
* @private
*/
_patternEmploysDownloadTagTypeToken(pattern, type, chips, unknownTypes)
{
if (!unknownTypes.has(type)) {
return false
}
for (let chip of chips) {
if (chip.token === type && pattern.includes(chip.label)) {
return true
}
}
return false
}
/**
* @param {string[]} tags
* @param {string|null} type
* @param {Set<string>|null} ignore
* @param {object} options
* @return {string}
* @private
*/
_joinTagsForDownloadPath(tags, type, ignore, options)
{
if (options.tagRuntime?._warmed) {
tags = options.tagRuntime.applyDownloadAttributesSync(tags, options.stripCharacterSeries)
} else {
tags = this._applyDownloadTagSubstitutions(tags, type, options.substitutions, options.stripCharacterSeries)
if (ignore?.size) {
tags = tags.filter((tag) => !ignore.has(tag))
}
}
let seen = new Set()
let unique = []
for (let tag of tags) {
let formatted = this._formatTagForDownloadPath(tag, type, options.stripCharacterSeries)
if (!formatted || seen.has(formatted)) {
continue
}
seen.add(formatted)
unique.push(formatted)
}
return unique.
sort((left, right) => left.localeCompare(right, undefined, {sensitivity: 'base'})).
join(options.multiTagSeparator)
}
/**
* @param {string[]} tags
* @param {string|null} type
* @param {Map<string, string>} substitutions
* @param {boolean} stripCharacterSeries
* @return {string[]}
* @private
*/
_applyDownloadTagSubstitutions(tags, type, substitutions, stripCharacterSeries)
{
if (!substitutions.size) {
return tags
}
return tags.map((tag) => this._resolveDownloadTagSubstitution(tag, type, substitutions, stripCharacterSeries))
}
/**
* @param {string} tag
* @param {string|null} type
* @param {Map<string, string>} substitutions
* @param {boolean} stripCharacterSeries
* @return {string}
* @private
*/
_resolveDownloadTagSubstitution(tag, type, substitutions, stripCharacterSeries)
{
if (substitutions.has(tag)) {
return substitutions.get(tag)
}
if (type === 'character' && stripCharacterSeries) {
let stripped = tag.replace(/_\([^)]*\)$/, '')
if (stripped !== tag && substitutions.has(stripped)) {
return substitutions.get(stripped)
}
}
return tag
}
/**
* @param {string} tag
* @param {string|null} type
* @param {boolean} stripCharacterSeries
* @return {string}
* @private
*/
_formatTagForDownloadPath(tag, type, stripCharacterSeries)
{
let formatted = this.decodeHtmlEntities(tag)
if (type === 'character' && stripCharacterSeries) {
formatted = formatted.replace(/_\([^)]*\)$/, '')
}
return formatted.replaceAll('_', ' ')
}
/**
* @param {string[]|{subject: string, replacement: string}[]} lines
* @param {function(string): string} normalizeToken
* @return {{subject: string, replacement: string}[]}
* @private
*/
_parseDownloadTagSubstitutionLines(lines, normalizeToken)
{
let rules = []
for (let line of lines) {
if (typeof line === 'object' && line?.subject && line?.replacement) {
let subject = normalizeToken(line.subject)
let replacement = normalizeToken(line.replacement)
if (subject.length && replacement.length) {
rules.push({subject, replacement})
}
continue
}
if (typeof line !== 'string') {
continue
}
let match = line.match(/^(.+?)\s+-\s+(.+)$/)
if (!match) {
continue
}
let subject = normalizeToken(match[1])
let replacement = normalizeToken(match[2])
if (subject.length && replacement.length) {
rules.push({subject, replacement})
}
}
return rules
}
/**
* @param {{subject: string, replacement: string}[]} rules
* @return {Map<string, string>}
* @private
*/
_buildDownloadTagSubstitutionMap(rules)
{
let map = new Map()
for (let rule of rules) {
map.set(rule.subject, rule.replacement)
}
return map
}
// -------------------------------------------------------------------------
// Download execution
// -------------------------------------------------------------------------
/**
* @param {object} pending
* @return {Promise<void>}
* @private
*/
async _executeImmediateDownload(pending)
{
let before = this._config.onBeforeDownload ?
await Utilities.callEventHandler(this._config.onBeforeDownload, [{
mediaElement: pending.mediaElement,
removeMediaOnSuccess: pending.removeMediaOnSuccess,
}], null) :
null
let mediaElement = before?.mediaElement ?? pending.mediaElement
let downloadUrl = before?.downloadUrl ?? mediaElement?.src ?? pending.downloadUrl ?? ''
let restoreOnFailure = before?.restoreOnFailure ?? pending.restoreOnFailure ?? false
if (!downloadUrl) {
return
}
let path = this.buildDownloadPathFromPatterns(pending.data, pending.tagGroups)
let downloadId = pending.downloadId ?? this._framework._getDownloadDuplicateLedgerId(mediaElement)
await this._framework._reloadDownloadDuplicateLedgerFromStorage()
if (this._framework._isDownloadDuplicate(downloadId)) {
this._framework._handleDuplicateDownloadSkipped({name: path, restorePictureOnFailure: restoreOnFailure})
return
}
if (downloadId && this._framework._shouldClaimDownloadDuplicateLedger() &&
!(await this._framework._claimDownloadDuplicateLedgerSlot(downloadId))) {
this._framework._handleDuplicateDownloadSkipped({name: path, restorePictureOnFailure: restoreOnFailure})
return
}
// Defer GM_download until the immediate queue paces — do not start downloads at push time.
this._immediateQueue.push({
name: path,
element: null,
url: downloadUrl,
downloadId,
restorePictureOnFailure: restoreOnFailure,
})
void this._processImmediateQueue()
}
/**
* @param {{name: string|null, element: JQuery|null, url: string, downloadId?: string|null, restorePictureOnFailure?: boolean}} download
* @return {Promise<void>}
* @private
*/
_wrapDownloadTask(download)
{
let path = download.name
if (!path) {
alert('Download failed: missing file path.')
return Promise.resolve()
}
let conflictAction = this._framework._isDownloadDuplicateLedgerActive() ?
DOWNLOAD_DUPLICATE_LEDGER_CONFLICT_ACTION :
'uniquify'
return new Promise((resolve) => {
download.element?.remove()
GM_download({
url: download.url,
name: path,
conflictAction,
onload: () => resolve(),
onerror: (error) => {
this._framework._handleDownloadFailed(download, error)
resolve()
},
})
})
}
/**
* @return {Promise<void>}
* @private
*/
async _processImmediateQueue()
{
if (this._immediateProcessing) {
return
}
this._immediateProcessing = true
while (this._immediateQueue.length > 0) {
await this._paceDownloadInitiation()
let download = this._immediateQueue.shift()
await this._wrapDownloadTask(download)
}
this._immediateProcessing = false
}
/**
* @param {object} item
* @return {Promise<void>}
* @private
*/
async _processDownloadItem(item)
{
try {
// Status is already `downloading` from `_claimDownloadQueueItem`.
let payload = item.resolvedPayload ?? {}
let path = this.buildDownloadPathFromPatterns(payload.data ?? {}, payload.tagGroups ?? {})
let downloadUrl = payload.mediaUrl ?? ''
let downloadId = payload.downloadId ?? item.downloadId ?? item.itemId
// Claim once per queue item. Retries after lock steal / requeue must not re-claim —
// the ledger is reserved before GM_download (Tampermonkey often never fires onload).
if (!item.ledgerClaimed) {
await this._framework._reloadDownloadDuplicateLedgerFromStorage()
if (this._framework._isDownloadDuplicate(downloadId)) {
item.status = 'duplicate'
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
}
await this._wrapDownloadTask({name: path, element: null, url: downloadUrl, downloadId})
item.status = 'done'
} catch (error) {
let rateLimit = await this._handleRateLimit('download', error, {sourceUrl: downloadUrl, resolvingUrl: downloadUrl}, item)
if (rateLimit === 'blocked') {
item.status = 'queued'
item.error = null
await this._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)
{
return new Promise((resolve, reject) => {
let rejectWithPayload = (xhr, fallbackMessage) => {
let text = xhr?.responseText ?? ''
let doc = text ? new DOMParser().parseFromString(text, 'text/html') : null
reject(Object.assign(new Error(fallbackMessage), {
status: xhr?.status ?? 0,
doc,
url,
}))
}
$.ajax({
url,
dataType: 'html',
timeout: 30000,
xhrFields: {withCredentials: true},
})
.done((html, _textStatus, xhr) => {
let text = typeof html === 'string' ? html : xhr.responseText ?? ''
let doc = new DOMParser().parseFromString(text, 'text/html')
// Keep status on reject so timedReload / humanInteraction detectors can use it;
// still attach parsed HTML (site 429 / Cloudflare bodies often arrive as 200 or 4xx).
if (xhr.status === 429) {
reject(Object.assign(new Error('HTTP 429'), {status: 429, doc, url}))
return
}
if (xhr.status >= 400) {
reject(Object.assign(new Error('HTTP ' + xhr.status), {status: xhr.status, doc, url}))
return
}
resolve(doc)
})
.fail((xhr, _status, error) => {
rejectWithPayload(xhr, 'Page resolution failed: ' + url + ' — ' + error)
})
})
}
/**
* @param {object} item
* @return {Promise<void>}
* @private
*/
async _processResolutionItem(item)
{
try {
// Status is already `resolving` from `_claimResolutionQueueItem`.
let typeHandler = this._config.downloadTypes?.[item.downloadType]
if (!typeHandler) {
item.status = 'failed'
item.error = 'Unknown download type: ' + item.downloadType
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 = resolved.tagGroups ?? {}
let tagIncidences = resolved.tagIncidences ?? {}
await this._registerResolvedTagGroups(tagGroups)
if (this._isTagDiscoveryActive() && this._shouldRunTagDiscovery('mediaPost')) {
let lists = await this._partitionDiscoveryTags(tagGroups, tagIncidences)
if (lists.unknown.length) {
item.status = 'tagReview'
item.pendingTagGroups = resolved
if (!(await this._commitResolutionRow(item))) {
return
}
await this._withState((state) => {
state.resolutionBlocked = true
state.resolutionBlockedItemId = item.itemId
state.discoveryPanelTags = this._mergeDiscoveryTags(state.discoveryPanelTags, lists.unknown)
state.discoveryPanelKnownTags = lists.known
})
let state = this._getStateSync()
await this._showTagDiscoveryPanel(state.discoveryPanelTags, state.discoveryPanelKnownTags)
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,
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()
{
if (!this._shouldRunProcessors()) {
return
}
if (this._linkQueues) {
void this._runProcessors()
return
}
void this._runDownloadProcessor()
}
/**
* @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
let prior = await this._getState()
// Other pipeline already owns HI — requeue and yield this loop until that HI clears
// (do not clobber the open panel / context).
if (prior?.humanInteractionBlocked && prior.humanInteractionContext !== context) {
if (context === 'download') {
this._downloadYieldForForeignHi = true
} else {
this._resolutionYieldForForeignHi = true
}
return 'blocked'
}
await this._beginHumanInteractionRateLimit(context, item, openUrl)
return 'blocked'
}
}
if (handlers.timedReload) {
let detect = handlers.timedReload.detect
if (Utilities.callEventHandler(detect, [payload, ctx], false)) {
let delayMs = typeof handlers.timedReload.delayMs === 'function' ?
handlers.timedReload.delayMs(ctx) :
handlers.timedReload.delayMs
await Utilities.sleep(delayMs ?? 5000)
return 'retry'
}
}
return false
}
/**
* @param {'resolution'|'download'} context
* @param {object|null|undefined} item
* @param {string|null|undefined} openUrl
* @return {Promise<void>}
* @private
*/
async _beginHumanInteractionRateLimit(context, item, openUrl)
{
let prior = await this._getState()
// Single-flight: never clobber an existing HI panel/context from the other pipeline.
if (prior.humanInteractionBlocked) {
if (prior.humanInteractionContext !== context) {
if (context === 'download') {
this._downloadYieldForForeignHi = true
} else {
this._resolutionYieldForForeignHi = true
}
}
return
}
let openUrlMarked = openUrl ? this._markHumanInteractionOpenUrl(openUrl) : null
// Sync mirror before window.open so Phase-1 mediaCloudflare on that tab stays silent.
this._writeHumanInteractionBlockMirror(true)
// Serialize through `_withState` so a concurrent heartbeat/progress put cannot wipe
// initiation clocks (or clear this block) via a stale raw `_putState` snapshot.
let began = false
await this._withState((state) => {
if (state.humanInteractionBlocked) {
return
}
state.humanInteractionBlocked = true
state.humanInteractionContext = context
state.humanInteractionItemId = item?.itemId ?? null
// Pin the confirm dialog to this processor tab before opening the verification
// tab so that tab (and any later tabs) cannot claim the prompt on init/focus.
state.humanInteractionPromptTabId = this._tabId
state.humanInteractionOpenUrl = openUrlMarked
began = true
})
if (!began) {
if (context === 'download') {
this._downloadYieldForForeignHi = true
} else {
this._resolutionYieldForForeignHi = true
}
return
}
this._refreshAllItemProgress()
// Show the dock panel while this tab is still focused. window.open usually steals
// focus immediately, and a visibility gate would skip the panel until the user returns.
await this._promptHumanInteractionResume({force: true})
if (openUrlMarked) {
this._openHumanInteractionTab(openUrlMarked)
}
await this._syncFromStorage()
}
/**
* Marks verification URLs so the opened mediaCloudflare tab stays silent
* (queue confirmation stays on the processor dock panel).
* Query `brazen_hi=1` plus hash `#brazen_hi=1` — CF often replaceState-strips
* search params but leaves the hash.
* @param {string} url
* @return {string}
* @private
*/
_markHumanInteractionOpenUrl(url)
{
try {
let parsed = new URL(url, location.href)
parsed.searchParams.set('brazen_hi', '1')
if (!/(^|[&#])brazen_hi=1\b/.test(parsed.hash)) {
parsed.hash = 'brazen_hi=1'
}
return parsed.href
} catch {
return url
}
}
/**
* Open the verification tab while keeping `window.opener` set.
* Chrome 88+ treats `window.open(url, '_blank')` as `noopener` by default, which
* made queue CF tabs look "standalone" and revived the old grey confirm dialog.
* @param {string} url
* @private
*/
_openHumanInteractionTab(url)
{
let anchor = document.createElement('a')
anchor.href = url
anchor.target = '_blank'
anchor.rel = 'opener'
anchor.style.display = 'none'
document.documentElement.appendChild(anchor)
anchor.click()
anchor.remove()
}
/**
* @return {string}
* @private
*/
_humanInteractionBlockMirrorKey()
{
return (this._cm._scriptPrefix ?? 'brazen-') + 'dm-human-interaction-blocked'
}
/**
* @param {boolean} blocked
* @private
*/
_writeHumanInteractionBlockMirror(blocked)
{
try {
let key = this._humanInteractionBlockMirrorKey()
if (blocked) {
localStorage.setItem(key, '1')
} else {
localStorage.removeItem(key)
}
} catch (e) {
// ignore quota / private-mode localStorage failures
}
}
/**
* @return {boolean}
*/
isHumanInteractionBlockedSync()
{
return !!this._getStateSync()?.humanInteractionBlocked
}
/**
* Phase-1 mediaCloudflare (or equivalent) page operation.
* Queue verification tabs stay silent — the processor-leader dock pane owns confirm.
* Standalone browsing shows the same themed dock pane with reload (never a grey modal).
* @param {{title?: string, message?: string, confirmLabel?: string}} [options]
* @return {Promise<void>}
*/
async handleMediaCloudflarePage(options = {})
{
let scriptPrefix = this._cm?._scriptPrefix ?? 'brazen-'
if (await BrazenDownloadManager.shouldSilenceMediaCloudflarePrompt(scriptPrefix, this)) {
return
}
this._showStandaloneCloudflareReloadPanel(options)
}
/**
* @return {Promise<boolean>}
*/
async shouldSilenceMediaCloudflarePrompt()
{
let scriptPrefix = this._cm?._scriptPrefix ?? 'brazen-'
return BrazenDownloadManager.shouldSilenceMediaCloudflarePrompt(scriptPrefix, this)
}
/**
* Show the human-interaction dock panel on the processor-leader tab only.
* Verification / other tabs must not show it.
* @param {{force?: boolean}} [options] `force` skips the visibility gate (used when opening
* the verification window — focus will leave this tab immediately after).
* @return {Promise<void>}
* @private
*/
async _promptHumanInteractionResume(options = {})
{
let state = await this._getState()
if (!state?.humanInteractionBlocked) {
return
}
if (!options.force && document.visibilityState !== 'visible') {
return
}
if (!this._canOwnHumanInteractionPrompt(state)) {
return
}
if (state.humanInteractionPromptTabId !== this._tabId) {
state.humanInteractionPromptTabId = this._tabId
await this._putState(state)
}
let context = state.humanInteractionContext ?? 'resolution'
let handlers = this._config.rateLimitHandlers?.[context]?.humanInteraction ?? {}
this._ensureHumanInteractionPanel()
BrazenViewLayer.updateHumanInteractionPanelContent(this._humanInteractionPanel, {
title: handlers.confirmTitle ?? 'Verification required',
message: handlers.confirmMessage ??
'Complete the verification in the new tab, then resume the queue here.',
confirmLabel: handlers.confirmLabel ?? 'Done — resume',
reopenLabel: handlers.reopenLabel ?? 'Reopen verification tab',
})
if (!BrazenViewLayer.isDockSlidePanelVisible(this._humanInteractionPanel)) {
this._framework._showDockSlidePanel(this._humanInteractionPanel)
} else {
this._framework._syncDockPanelPosition()
}
}
/**
* @private
*/
_ensureHumanInteractionPanel()
{
if (this._humanInteractionPanel) {
return
}
this._humanInteractionPanel = BrazenViewLayer.createHumanInteractionPanel({
onConfirm: () => { void this._confirmHumanInteractionResume() },
onReopen: () => { void this._reopenHumanInteractionTab() },
})
BrazenViewLayer.appendToBody($(this._humanInteractionPanel))
}
/**
* Themed dock pane on a browsed challenge page (not queue-driven). Same chrome as the
* leader verification panel — never a grey modal overlay.
* @param {{title?: string, message?: string, confirmLabel?: string}} [options]
* @private
*/
_showStandaloneCloudflareReloadPanel(options = {})
{
if (document.getElementById('bv-human-interaction-panel')) {
return
}
let panel = BrazenViewLayer.createHumanInteractionPanel({
showReopen: false,
title: options.title ?? 'Cloudflare challenge',
message: options.message ??
'Complete the CAPTCHA on this page, then confirm to reload and continue.',
confirmLabel: options.confirmLabel ?? 'Done — reload',
onConfirm: () => location.reload(),
})
BrazenViewLayer.appendToBody($(panel))
this._framework._showDockSlidePanel(panel)
}
/**
* @return {Promise<void>}
* @private
*/
async _confirmHumanInteractionResume()
{
this._hideHumanInteractionPanel()
await this._clearHumanInteractionBlock()
if (this._shouldRunProcessors()) {
void this._runProcessors()
}
}
/**
* Re-open the verification URL if the user closed that tab by mistake.
* @return {Promise<void>}
* @private
*/
async _reopenHumanInteractionTab()
{
let state = await this._getState()
let openUrl = state?.humanInteractionOpenUrl
if (!openUrl) {
return
}
this._openHumanInteractionTab(this._markHumanInteractionOpenUrl(openUrl))
}
/**
* @private
*/
_hideHumanInteractionPanel()
{
if (!this._humanInteractionPanel) {
return
}
this._framework._hideDockSlidePanel(this._humanInteractionPanel)
}
/**
* True when this tab may show the human-interaction panel — only the
* processor-leader tab (where the resolution/download queue runs).
* @param {object} state
* @return {boolean}
* @private
*/
_canOwnHumanInteractionPrompt(state)
{
return state.processingTabId === this._tabId
}
/**
* @return {Promise<void>}
* @private
*/
async _clearHumanInteractionBlock()
{
this._hideHumanInteractionPanel()
this._writeHumanInteractionBlockMirror(false)
let state = await this._getState()
let context = state?.humanInteractionContext === 'download' ? 'download' : 'resolution'
// Stamp only the blocked pipeline's clock so resolution (2s) and download (1s) gaps stay independent.
this._stampInitiationClock(context, Date.now())
await this._withState((next) => {
next.humanInteractionBlocked = false
next.humanInteractionContext = null
next.humanInteractionItemId = null
next.humanInteractionPromptTabId = null
next.humanInteractionOpenUrl = null
let now = Date.now()
if (context === 'download') {
next.lastDownloadInitiationAt = Math.max(next.lastDownloadInitiationAt ?? 0, now)
} else {
next.lastResolutionInitiationAt = Math.max(next.lastResolutionInitiationAt ?? 0, now)
}
})
this._refreshAllItemProgress()
await this._syncFromStorage()
// Sync reloads `_cachedState` only — re-apply local stamp in case IDB lagged.
this._stampInitiationClock(context, Date.now())
}
/**
* Advance one pipeline's in-tab initiation clock to at least `at`.
* @param {'resolution'|'download'} kind
* @param {number} at
* @private
*/
_stampInitiationClock(kind, at)
{
let ts = at ?? Date.now()
let localKey = kind === 'download' ? '_lastDownloadInitiationAt' : '_lastResolutionInitiationAt'
let stateKey = kind === 'download' ? 'lastDownloadInitiationAt' : 'lastResolutionInitiationAt'
this[localKey] = Math.max(this[localKey] ?? 0, ts)
if (this._cachedState) {
this._cachedState[stateKey] = Math.max(this._cachedState[stateKey] ?? 0, ts)
}
}
// -------------------------------------------------------------------------
// Tag discovery
// -------------------------------------------------------------------------
/**
* Write extracted typed tag groups into the tag registry.
* Discovery on → register-on-seen (`media`, lastSeenTypeEntryId only).
* Discovery off → confirm types as-is (`resolution`, sets typeEntryId).
*
* @param {Record<string, string[]>|null|undefined} tagGroups
* @return {Promise<void>}
* @private
*/
async _registerResolvedTagGroups(tagGroups)
{
if (!this._cm.canPersist() || !tagGroups || typeof tagGroups !== 'object') {
return
}
let hasNames = Object.values(tagGroups).some((names) => Array.isArray(names) && names.some(Boolean))
if (!hasNames) {
return
}
let source = this._isTagDiscoveryActive() ? 'media' : 'resolution'
await this._cm.registerTypedTagGroups(tagGroups, source)
}
/**
* @return {boolean}
*/
isTagDiscoveryEnabled()
{
return !!this._getStateSync()?.tagDiscoveryEnabled
}
/**
* @return {boolean}
* @private
*/
_isTagDiscoveryActive()
{
return !!this._config.tagDiscovery && this.isTagDiscoveryEnabled()
}
/**
* @param {string} step
* @return {boolean}
* @private
*/
_shouldRunTagDiscovery(step)
{
return this._config.tagDiscovery?.discoverAt?.includes(step) ?? false
}
/**
* Promote seen types to confirmed {@link TagEntry.typeEntryId} after discovery review.
* @param {Array<{name: string, type: string|null}>|null} tags
* @return {Promise<void>}
* @private
*/
async _confirmDiscoveryPanelTagTypes(tags)
{
if (!tags?.length) {
return
}
let tagRuntime = this._cm.getTagRuntime()
if (!tagRuntime) {
return
}
for (let tag of tags) {
let entry = await tagRuntime.getTag(tag.name)
if (entry?.typeEntryId != null) {
continue
}
let typeEntryId = entry?.meta?.lastSeenTypeEntryId ?? null
if (typeEntryId == null && tag.type) {
typeEntryId = await this._repos().tags.resolveCanonicalTypeEntryId(tag.type)
}
if (typeEntryId != null) {
await tagRuntime.patchAttributes(tag.name, {typeEntryId})
}
}
await tagRuntime.warmCache()
}
/**
* @param {{}} tagGroups
* @param {Record<string, number|string>|null|undefined} [tagIncidences]
* @return {Promise<{unknown: Array<{name: string, type: string|null, count: number|null}>, known: Array<{name: string, type: string|null, count: number|null}>}>}
* @private
*/
async _partitionDiscoveryTags(tagGroups, tagIncidences = {})
{
let empty = {unknown: [], known: []}
if (!this._config.tagDiscovery) {
return empty
}
let resolver = this._config.downloadPaths.getPatternResolver()
let patterns = this._getActiveDownloadPatterns()
let relevantTypes = new Set()
for (let chip of resolver.chips) {
if (!resolver.tagTypes.includes(chip.token)) {
continue
}
for (let pattern of patterns) {
if (pattern.includes(chip.label)) {
relevantTypes.add(chip.token)
}
}
}
let tagTypes = this._config.tagDiscovery.tagTypes ?? [...relevantTypes]
if (!tagTypes.length) {
return empty
}
let unknown = []
let known = []
let incidences = tagIncidences && typeof tagIncidences === 'object' ? tagIncidences : {}
for (let type of tagTypes) {
for (let tag of tagGroups[type] ?? []) {
if (!tag) {
continue
}
let count = this._resolveTagIncidence(tag, incidences)
let entry = {name: tag, type, count}
if (await this._isTagKnownForDiscovery(tag)) {
known.push(entry)
} else {
unknown.push(entry)
}
}
}
return {unknown, known}
}
/**
* @param {string} tagName
* @param {Record<string, number|string>} incidences
* @return {number|null}
* @private
*/
_resolveTagIncidence(tagName, incidences)
{
if (!incidences || typeof incidences !== 'object') {
return null
}
let raw = incidences[tagName]
if (raw == null) {
let lower = String(tagName).toLowerCase()
for (let [key, value] of Object.entries(incidences)) {
if (String(key).toLowerCase() === lower) {
raw = value
break
}
}
}
if (raw == null || raw === '') {
return null
}
let count = typeof raw === 'number' ? raw : parseInt(String(raw).replace(/[^\d]/g, ''), 10)
return Number.isFinite(count) ? count : null
}
/**
* @param {{}} tagGroups
* @return {Promise<Array<{name: string, type: string|null, count: number|null}>|null>}
* @private
*/
async _findNewDiscoveryTags(tagGroups)
{
let lists = await this._partitionDiscoveryTags(tagGroups, {})
return lists.unknown.length ? lists.unknown : null
}
/**
* Whether a tag is registered with a resolved type ({@link TagRepository.typeEntryId} set).
* Script {@link tagDiscovery.isTagKnown} overrides; default uses {@link TagRepository.getByName}.
*
* @param {string} tagName
* @return {Promise<boolean>}
* @private
*/
async _isTagKnownForDiscovery(tagName)
{
if (this._config.tagDiscovery?.isTagKnown) {
let result = Utilities.callEventHandler(this._config.tagDiscovery.isTagKnown, [tagName], false)
return !!await result
}
if (!this._cm.canPersist()) {
return false
}
let tag = await this._repos().tags.getByName(tagName)
return !!(tag && tag.typeEntryId != null)
}
/**
* @param {Array|null} existing
* @param {Array} discovered
* @return {Array}
* @private
*/
_mergeDiscoveryTags(existing, discovered)
{
let merged = [...(existing ?? [])]
let indexByKey = new Map(merged.map((tag, index) => [tag.name + '\0' + (tag.type ?? ''), index]))
for (let tag of discovered) {
let key = tag.name + '\0' + (tag.type ?? '')
if (indexByKey.has(key)) {
let prior = merged[indexByKey.get(key)]
if ((prior.count == null || prior.count === '') && tag.count != null && tag.count !== '') {
prior.count = tag.count
}
continue
}
indexByKey.set(key, merged.length)
merged.push(tag)
}
return merged
}
/**
* @return {string[]}
* @private
*/
_getActiveDownloadPatterns()
{
let paths = this._config.downloadPaths
let patterns = []
if (paths.filenamePatternConfigKey) {
patterns.push(this._framework._getConfig(paths.filenamePatternConfigKey) ?? '')
}
if (paths.subfolderPatternConfigKey) {
patterns.push(this._framework._getConfig(paths.subfolderPatternConfigKey) ?? '')
}
return patterns
}
/**
* @param {Array} tags
* @param {Array|null|undefined} [knownTags]
* @return {Promise<void>}
* @private
*/
async _showTagDiscoveryPanel(tags, knownTags = null)
{
if (document.visibilityState !== 'visible') {
return
}
await this._claimTagDiscoveryPanel(async (state) => {
if (state.tagDiscoveryPanelTabId && state.tagDiscoveryPanelTabId !== this._tabId) {
return
}
state.tagDiscoveryPanelTabId = this._tabId
if (knownTags != null) {
state.discoveryPanelKnownTags = knownTags
}
await this._putState(state)
this._renderTagDiscoveryPanel(tags, state.discoveryPanelKnownTags ?? knownTags ?? [])
})
}
/**
* @param {Function} callback
* @return {Promise<void>}
* @private
*/
async _claimTagDiscoveryPanel(callback)
{
let state = await this._getState()
if (state.tagDiscoveryPanelTabId && state.tagDiscoveryPanelTabId !== this._tabId && document.visibilityState !== 'visible') {
return
}
await callback(state)
}
/**
* @param {Array} tags
* @param {Array|null|undefined} [knownTags]
* @private
*/
_renderTagDiscoveryPanel(tags, knownTags = null)
{
if (!this._config.tagDiscovery) {
return
}
if (!this._tagDiscoveryPanel) {
this._tagDiscoveryPanel = BrazenViewLayer.createTagDiscoveryPanel({
onConfirm: () => { void this.confirmTagDiscoveryMappings() },
onSkip: () => { void this.skipTagDiscoveryInclusion() },
onOpenMedia: () => { void this.openTagDiscoveryMedia() },
})
BrazenViewLayer.appendToBody($(this._tagDiscoveryPanel))
}
let panelOptions = {
...(this._config.tagDiscovery?.panel ?? {}),
knownTags: knownTags ?? this._getStateSync()?.discoveryPanelKnownTags ?? [],
}
BrazenViewLayer.renderTagDiscoveryPanelContent(
this._tagDiscoveryPanel,
tags,
(row, tag, actionsElement) => {
this._appendTagDiscoveryFrameworkActions(row, tag, actionsElement)
},
panelOptions,
)
if (BrazenViewLayer.isDockSlidePanelVisible(this._tagDiscoveryPanel)) {
// Content-only refresh — keep the open panel still; only re-stack positions.
this._framework._syncDockPanelPosition()
return
}
this._framework._showDockSlidePanel(this._tagDiscoveryPanel)
}
/**
* Framework owns tag-attribute actions; discovery panel maps them internally.
* @param {HTMLElement} row
* @param {{name: string, type: string|null}} tag
* @param {HTMLElement} actionsElement
* @private
*/
_appendTagDiscoveryFrameworkActions(row, tag, actionsElement)
{
let discovery = this._config.tagDiscovery ?? {}
let actions = discovery.actions ?? {}
let $row = $(row)
this._framework.appendTagAttributeActions(actionsElement, tag, {
className: actions.className ?? discovery.actionButtonClass,
iconClass: actions.iconClass ?? discovery.actionIconClass,
ignoreClassName: actions.ignoreClassName,
normalize: actions.normalize ?? discovery.normalizeTag,
bookmark: actions.bookmark,
ignore: actions.ignore,
substitute: actions.substitute ?? {fieldKey: discovery.substitutionFieldKey ?? 'filename-tag-substitutions'},
blacklist: actions.blacklist,
explore: actions.explore,
onAfterToggle: () => {
this._refreshTagDiscoveryPanel()
actions.onAfterToggle?.()
},
})
let state = actions.getRowState?.(tag) ?? discovery.getRowState?.(tag)
if (!state) {
return
}
$row.
toggleClass('bv-tag-muted', !!state.muted).
toggleClass('bv-tag-hidden', !!state.hidden)
for (let className of actions.extraMutedClasses ?? discovery.extraMutedClasses ?? []) {
$row.toggleClass(className, !!state.muted)
}
for (let className of actions.extraHiddenClasses ?? discovery.extraHiddenClasses ?? []) {
$row.toggleClass(className, !!state.hidden)
}
}
/**
* @private
*/
_refreshTagDiscoveryPanel()
{
let state = this._getStateSync()
if (!state?.resolutionBlocked || !state.discoveryPanelTags?.length || state.tagDiscoveryPanelTabId !== this._tabId) {
return
}
// Never re-animate an already-open panel on config / action refreshes.
this._renderTagDiscoveryPanel(state.discoveryPanelTags, state.discoveryPanelKnownTags ?? [])
}
/**
* Re-render tag discovery panel rows (e.g. after bookmark/rule toggles).
*/
refreshTagDiscoveryPanel()
{
this._refreshTagDiscoveryPanel()
}
/**
* Re-open the tag discovery panel after reload / tab focus when review is still pending.
* @return {Promise<void>}
* @private
*/
async _restoreTagDiscoveryPanelIfNeeded()
{
if (!this._isTagDiscoveryActive()) {
return
}
let state = await this._getState()
if (state.resolutionBlocked && state.discoveryPanelTags?.length) {
await this._showTagDiscoveryPanel(state.discoveryPanelTags, state.discoveryPanelKnownTags ?? [])
return
}
let resolutionRows = await this._repos().downloadResolutionQueue.listAll()
let stuck = resolutionRows.find((row) => row.status === 'tagReview' && row.pendingTagGroups)
if (!stuck) {
return
}
let pending = stuck.pendingTagGroups
let tagGroups = pending.tagGroups ?? pending
let tagIncidences = pending.tagIncidences ?? {}
let lists = await this._partitionDiscoveryTags(tagGroups, tagIncidences)
await this._withState((next) => {
next.resolutionBlocked = true
next.resolutionBlockedItemId = stuck.itemId
next.discoveryPanelTags = this._mergeDiscoveryTags(next.discoveryPanelTags, lists.unknown)
next.discoveryPanelKnownTags = lists.known
})
let stateAfter = this._getStateSync()
if (stateAfter?.discoveryPanelTags?.length) {
await this._showTagDiscoveryPanel(stateAfter.discoveryPanelTags, stateAfter.discoveryPanelKnownTags)
}
}
/**
* @private
*/
_hideTagDiscoveryPanel()
{
if (!this._tagDiscoveryPanel) {
return
}
this._framework._hideDockSlidePanel(this._tagDiscoveryPanel)
}
// -------------------------------------------------------------------------
// Processors & sync
// -------------------------------------------------------------------------
/**
* @param {object|null} state
* @return {boolean}
* @private
*/
_isProcessingLockStale(state)
{
if (!state?.processingTabId) {
return true
}
let lastBeat = state.processingHeartbeatAt ?? 0
return (Date.now() - lastBeat) > PROCESSING_LOCK_STALE_MS
}
/**
* Sync companion to IndexedDB `processingTabId`. Cleared on `pagehide` so a refresh can
* steal immediately even when the async IDB release never finishes.
* @return {string}
* @private
*/
_processingLockStorageKey()
{
return (this._cm._scriptPrefix ?? 'brazen-') + 'dm-processing-lock'
}
/**
* @return {{tabId: string, at: number}|null}
* @private
*/
_readProcessingLockMirror()
{
try {
let raw = localStorage.getItem(this._processingLockStorageKey())
if (!raw) {
return null
}
let parsed = JSON.parse(raw)
if (!parsed?.tabId) {
return null
}
return parsed
} catch (e) {
return null
}
}
/**
* @private
*/
_writeProcessingLockMirror()
{
try {
localStorage.setItem(this._processingLockStorageKey(), JSON.stringify({
tabId: this._tabId,
at: Date.now(),
}))
} catch (e) {
// ignore quota / private-mode localStorage failures
}
}
/**
* @private
*/
_clearProcessingLockMirrorIfOwned()
{
try {
let key = this._processingLockStorageKey()
let mirror = this._readProcessingLockMirror()
if (mirror?.tabId === this._tabId) {
localStorage.removeItem(key)
}
} catch (e) {
// ignore
}
}
/**
* True when another tab still looks like a live processor owner (fresh IDB heartbeat and
* matching sync localStorage mirror). Missing/mismatched mirror means the owner unloaded.
* @param {object|null} state
* @return {boolean}
* @private
*/
_isProcessingOwnerAlive(state)
{
if (!state?.processingTabId || state.processingTabId === this._tabId) {
return false
}
if (this._isProcessingLockStale(state)) {
return false
}
let mirror = this._readProcessingLockMirror()
return !!(mirror && mirror.tabId === state.processingTabId)
}
/**
* Claim or renew the cross-tab processor lock. Steals a lock whose heartbeat is stale
* or whose sync mirror was cleared on unload (owner refreshed) and requeues in-flight work.
* @return {Promise<boolean>}
* @private
*/
async _tryClaimProcessingLeadership()
{
if (!this._cm.canPersist() || !this.isDownloadManagerEnabled()) {
return false
}
let state = await this._getState()
if (state.processingTabId === this._tabId) {
this._hydrateInitiationClocksFromState(state)
// Heartbeat renew via `_withState` — a raw put of a stale snapshot can wipe
// `lastResolutionInitiationAt` / human-interaction flags written by the processor.
await this._withState((next) => {
if (next.processingTabId !== this._tabId) {
return
}
next.processingHeartbeatAt = Date.now()
})
this._writeProcessingLockMirror()
this._startProcessingHeartbeat()
return true
}
if (this._isProcessingOwnerAlive(state)) {
this._stopProcessingHeartbeat()
return false
}
await this._requeueInterruptedPipelineWork()
state = await this._getState()
if (this._isProcessingOwnerAlive(state)) {
return false
}
state.processingTabId = this._tabId
state.processingHeartbeatAt = Date.now()
if (state.tagDiscoveryPanelTabId && state.tagDiscoveryPanelTabId !== this._tabId) {
state.tagDiscoveryPanelTabId = null
}
this._hydrateInitiationClocksFromState(state)
await this._putState(state)
this._writeProcessingLockMirror()
this._startProcessingHeartbeat()
return true
}
/**
* @return {Promise<void>}
* @private
*/
async _requeueInterruptedPipelineWork()
{
let resolutionRows = await this._repos().downloadResolutionQueue.listAll()
for (let row of resolutionRows) {
if (row.status === 'resolving') {
// 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.listAll()
for (let row of downloadRows) {
if (row.status === 'downloading') {
if (String(this._activeDownloadItemId) === String(row.itemId)) {
continue
}
row.status = 'queued'
row.error = null
// Keep ledgerClaimed — the slot was already reserved before GM_download.
await this._repos().downloadQueue.put(row)
}
}
}
/**
* @return {Promise<void>}
* @private
*/
async _releaseProcessingLeadership()
{
this._stopProcessingHeartbeat()
this._clearProcessingLockMirrorIfOwned()
if (!this._cm.canPersist()) {
return
}
let state = await this._getState()
if (state.processingTabId !== this._tabId) {
return
}
state.processingTabId = null
state.processingHeartbeatAt = 0
await this._putState(state)
}
/**
* @private
*/
_startProcessingHeartbeat()
{
if (this._processingHeartbeatTimer) {
return
}
this._processingHeartbeatTimer = setInterval(() => {
void this._withState((state) => {
if (state.processingTabId !== this._tabId) {
this._stopProcessingHeartbeat()
this._clearProcessingLockMirrorIfOwned()
return
}
state.processingHeartbeatAt = Date.now()
this._writeProcessingLockMirror()
})
}, PROCESSING_HEARTBEAT_INTERVAL_MS)
}
/**
* @private
*/
_stopProcessingHeartbeat()
{
if (!this._processingHeartbeatTimer) {
return
}
clearInterval(this._processingHeartbeatTimer)
this._processingHeartbeatTimer = null
}
/**
* Kick entry for init, enqueue, Start, tag confirm/skip, HI resume, and visibility.
* Unlinked: starts resolution and download loops concurrently.
* Linked: runs one serial resolution-first loop.
* @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._processorsWakeRequested = false
this._resolutionWakeRequested = false
this._downloadWakeRequested = false
return
}
if (this._linkQueues) {
void this._runLinkedProcessors()
return
}
void this._runResolutionProcessor()
void this._runDownloadProcessor()
}
/**
* Serial resolution-then-download loop when `linkQueues` is true.
* @return {Promise<void>}
* @private
*/
async _runLinkedProcessors()
{
if (this._processing) {
this._processorsWakeRequested = true
return
}
this._processing = true
try {
do {
this._processorsWakeRequested = false
if (!await this._tryClaimProcessingLeadership()) {
this._processorsWakeRequested = false
break
}
if (!this._shouldRunProcessors()) {
break
}
while (this._shouldRunProcessors()) {
await this._bumpProcessingHeartbeat()
let state = await this._getState()
if (this._isLinkedPipelinesBlocked(state)) {
break
}
let processed = false
if (!this._isResolutionPipelineBlocked(state)) {
let resolutionRows = await this._repos().downloadResolutionQueue.listAll()
let nextResolution = resolutionRows.find((row) => row.status === 'queued')
if (nextResolution) {
let claimed = await this._claimResolutionQueueItem(nextResolution.itemId)
if (claimed) {
this._activeResolutionItemId = claimed.itemId
try {
this._refreshItemProgress(claimed.itemId)
await this._processResolutionItem(claimed)
await this._reloadCachedStateQuiet()
} finally {
this._activeResolutionItemId = null
}
processed = true
if (this._resolutionYieldForForeignHi) {
this._resolutionYieldForForeignHi = false
break
}
}
}
}
if (!processed && !this._isDownloadPipelineBlocked(state)) {
let downloadRows = await this._repos().downloadQueue.listAll()
let nextDownload = downloadRows.find((row) => row.status === 'queued')
if (nextDownload) {
let claimed = await this._claimDownloadQueueItem(nextDownload.itemId)
if (claimed) {
this._activeDownloadItemId = claimed.itemId
try {
this._refreshItemProgress(claimed.itemId)
await this._processDownloadItem(claimed)
await this._reloadCachedStateQuiet()
} finally {
this._activeDownloadItemId = null
}
processed = true
if (this._downloadYieldForForeignHi) {
this._downloadYieldForForeignHi = false
break
}
}
}
}
if (!processed) {
break
}
}
} while (this._processorsWakeRequested)
} finally {
this._processing = false
await this._pauseDownloadQueueIfIdle()
this._refreshDockProgress()
if (this._processorsWakeRequested) {
this._processorsWakeRequested = false
void this._runLinkedProcessors()
}
}
}
/**
* Unlinked resolution-only processor loop.
* @return {Promise<void>}
* @private
*/
async _runResolutionProcessor()
{
if (this._resolutionProcessing) {
this._resolutionWakeRequested = true
return
}
this._resolutionProcessing = true
try {
do {
this._resolutionWakeRequested = false
if (!await this._tryClaimProcessingLeadership()) {
this._resolutionWakeRequested = false
break
}
if (!this._shouldRunProcessors()) {
break
}
while (this._shouldRunProcessors()) {
await this._bumpProcessingHeartbeat()
let state = await this._getState()
if (this._isResolutionPipelineBlocked(state)) {
break
}
let resolutionRows = await this._repos().downloadResolutionQueue.listAll()
let nextResolution = resolutionRows.find((row) => row.status === 'queued')
if (!nextResolution) {
break
}
let claimed = await this._claimResolutionQueueItem(nextResolution.itemId)
if (!claimed) {
// Another worker claimed it — exit; a wake will restart if needed.
break
}
this._activeResolutionItemId = claimed.itemId
try {
this._refreshItemProgress(claimed.itemId)
await this._processResolutionItem(claimed)
await this._reloadCachedStateQuiet()
} finally {
this._activeResolutionItemId = null
}
if (this._resolutionYieldForForeignHi) {
this._resolutionYieldForForeignHi = false
break
}
}
} while (this._resolutionWakeRequested)
} finally {
this._resolutionProcessing = false
this._refreshDockProgress()
if (this._resolutionWakeRequested) {
this._resolutionWakeRequested = false
void this._runResolutionProcessor()
}
}
}
/**
* Unlinked download-only processor loop.
* @return {Promise<void>}
* @private
*/
async _runDownloadProcessor()
{
if (this._downloadProcessing) {
this._downloadWakeRequested = true
return
}
this._downloadProcessing = true
try {
do {
this._downloadWakeRequested = false
if (!await this._tryClaimProcessingLeadership()) {
this._downloadWakeRequested = false
break
}
if (!this._shouldRunProcessors()) {
break
}
while (this._shouldRunProcessors()) {
await this._bumpProcessingHeartbeat()
let state = await this._getState()
if (this._isDownloadPipelineBlocked(state)) {
break
}
let downloadRows = await this._repos().downloadQueue.listAll()
let nextDownload = downloadRows.find((row) => row.status === 'queued')
if (!nextDownload) {
break
}
let claimed = await this._claimDownloadQueueItem(nextDownload.itemId)
if (!claimed) {
break
}
this._activeDownloadItemId = claimed.itemId
try {
this._refreshItemProgress(claimed.itemId)
await this._processDownloadItem(claimed)
await this._reloadCachedStateQuiet()
} finally {
this._activeDownloadItemId = null
}
if (this._downloadYieldForForeignHi) {
this._downloadYieldForForeignHi = false
break
}
}
} while (this._downloadWakeRequested)
} finally {
this._downloadProcessing = false
await this._pauseDownloadQueueIfIdle()
this._refreshDockProgress()
if (this._downloadWakeRequested) {
this._downloadWakeRequested = false
void this._runDownloadProcessor()
}
}
}
/**
* 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()
{
await this._withState((state) => {
if (state.processingTabId !== this._tabId) {
return
}
state.processingHeartbeatAt = Date.now()
this._writeProcessingLockMirror()
})
}
/**
* Whether HI should stop the given pipeline under current linkQueues mode.
* @param {object|null|undefined} state
* @param {'resolution'|'download'} pipeline
* @return {boolean}
* @private
*/
_isHumanInteractionBlockingPipeline(state, pipeline)
{
if (!state?.humanInteractionBlocked) {
return false
}
if (this._linkQueues) {
return true
}
return state.humanInteractionContext === pipeline
}
/**
* @param {object|null|undefined} state
* @return {boolean}
* @private
*/
_isResolutionPipelineBlocked(state)
{
if (!state) {
return true
}
if (this._isHumanInteractionBlockingPipeline(state, 'resolution')) {
return true
}
return !!state.resolutionBlocked
}
/**
* @param {object|null|undefined} state
* @return {boolean}
* @private
*/
_isDownloadPipelineBlocked(state)
{
if (!state) {
return true
}
if (state.paused) {
return true
}
if (this._linkQueues) {
if (state.resolutionBlocked) {
return true
}
if (state.humanInteractionBlocked) {
return true
}
return false
}
return this._isHumanInteractionBlockingPipeline(state, 'download')
}
/**
* Linked mode: stop the shared loop only when neither pipeline can take work.
* @param {object|null|undefined} state
* @return {boolean}
* @private
*/
_isLinkedPipelinesBlocked(state)
{
return this._isResolutionPipelineBlocked(state) && this._isDownloadPipelineBlocked(state)
}
/**
* Whether Start/Pause should surface human-interaction resume instead of toggle.
* @param {object|null|undefined} state
* @return {boolean}
* @private
*/
_isStartPauseShowingHumanInteraction(state)
{
if (!state?.humanInteractionBlocked) {
return false
}
if (this._linkQueues) {
return true
}
return state.humanInteractionContext === 'download'
}
/**
* Whether this tab may attempt to own / run processors (leadership + enabled).
* Per-pipeline gates are checked inside each loop.
* @return {boolean}
* @private
*/
_shouldRunProcessors()
{
if (!this._cm.canPersist()) {
return false
}
let state = this._getStateSync()
if (!state) {
return false
}
if (this._isProcessingOwnerAlive(state)) {
return false
}
if (!this.isDownloadManagerEnabled()) {
return false
}
if (this._linkQueues && state.humanInteractionBlocked) {
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._cm.canPersist()) {
return Promise.resolve(this._cachedState)
}
let run = this._stateWriteTail.then(async () => {
let state = await this._repos().downloadManagerState.get()
await mutator(state)
state.id = 'state'
await this._repos().downloadManagerState.put(state)
this._cachedState = state
return state
})
this._stateWriteTail = run.then(() => undefined, () => undefined)
return run
}
/**
* Pace the next GM_download / resolution fetch initiation.
* Uses an in-tab clock so concurrent heartbeat/progress puts cannot wipe lastAt in IDB.
* @param {'download'|'resolution'} kind
* @return {Promise<void>}
* @private
*/
async _paceInitiation(kind)
{
let gapMs = kind === 'resolution' ? this._resolutionGapMs : this._downloadGapMs
let localKey = kind === 'resolution' ? '_lastResolutionInitiationAt' : '_lastDownloadInitiationAt'
let stateKey = kind === 'resolution' ? 'lastResolutionInitiationAt' : 'lastDownloadInitiationAt'
let lastAt = Math.max(this[localKey] ?? 0, this._cachedState?.[stateKey] ?? 0)
let nextAt = await this._awaitInitiationGap(lastAt, gapMs)
this[localKey] = nextAt
// Keep the sync cache in lockstep with the in-tab clock so a later
// `_reloadCachedStateQuiet` of a briefly stale IDB row cannot undercut pacing.
if (this._cachedState) {
this._cachedState[stateKey] = Math.max(this._cachedState[stateKey] ?? 0, nextAt)
}
if (!this._cm.canPersist()) {
return
}
await this._withState((state) => {
state[stateKey] = Math.max(state[stateKey] ?? 0, nextAt)
})
}
/**
* @return {Promise<void>}
* @private
*/
async _paceDownloadInitiation()
{
// Serialize batch + immediate so concurrent unlinked work still honors the gap.
let run = this._downloadInitiateTail.then(() => this._paceInitiation('download'))
this._downloadInitiateTail = run.then(() => undefined, () => undefined)
await run
}
/**
* @return {Promise<void>}
* @private
*/
async _paceResolutionInitiation()
{
await this._paceInitiation('resolution')
}
/**
* Zero each dock progress counter when its own queue is idle so a later batch on that
* queue starts at 0/N even if the other queue is still busy.
* @return {Promise<void>}
* @private
*/
async _resetProgressCountersIfIdle()
{
let downloadPending = await this._getPendingDownloadCount()
let resolutionPending = await this._getPendingResolutionCount()
await this._withState((state) => {
if (resolutionPending === 0 && (Number(state.completedResolutionCount) || 0) !== 0) {
state.completedResolutionCount = 0
}
if (downloadPending === 0 && (Number(state.completedDownloadCount) || 0) !== 0) {
state.completedDownloadCount = 0
}
})
}
/**
* When the download queue has no pending work, return to idle (Start required) so the
* next batch does not auto-run because Start was left on from the previous batch.
* Unlinked: also wait until resolution is idle so promotes cannot leave Ready rows paused.
* @return {Promise<void>}
* @private
*/
async _pauseDownloadQueueIfIdle()
{
if ((await this._getPendingDownloadCount()) > 0) {
return
}
if (!this._linkQueues && (await this._getPendingResolutionCount()) > 0) {
return
}
let state = await this._getState()
if (state.paused) {
return
}
state.paused = true
await this._putState(state)
}
/**
* @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
})
await this._resetProgressCountersIfIdle()
}
/**
* @return {Promise<void>}
* @private
*/
async _incrementDownloadProgress()
{
await this._withState((state) => {
state.completedDownloadCount = (Number(state.completedDownloadCount) || 0) + 1
})
await this._pauseDownloadQueueIfIdle()
await this._resetProgressCountersIfIdle()
}
/**
* Reload cached DM state without UI rebuilds (used between processor tasks).
* @return {Promise<void>}
* @private
*/
async _reloadCachedStateQuiet()
{
if (!this._cm.canPersist()) {
return
}
this._cachedState = await this._repos().downloadManagerState.get()
}
/**
* One UI reset after a resolution task finishes (success, fail, block, or requeue).
* @param {string} itemId
* @return {Promise<void>}
* @private
*/
async _onResolutionTaskComplete(itemId)
{
await this._reloadCachedStateQuiet()
this._refreshItemProgress(itemId)
this._refreshDockProgress({refreshAllItems: false})
}
/**
* One UI reset after a download task finishes (success, fail, duplicate, or requeue).
* @param {string} itemId
* @return {Promise<void>}
* @private
*/
async _onDownloadTaskComplete(itemId)
{
await this._reloadCachedStateQuiet()
this._refreshItemProgress(itemId)
this._refreshDockProgress({refreshAllItems: false})
}
/**
* @return {Promise<void>}
* @private
*/
async _ensureState()
{
if (!this._cm.canPersist()) {
return
}
let state = await this._repos().downloadManagerState.get()
if (!state?.id) {
await this._repos().downloadManagerState.reset()
}
}
/**
* @return {object|null}
* @private
*/
_getStateSync()
{
return this._cachedState ?? null
}
/**
* @return {Promise<object>}
* @private
*/
async _getState()
{
let state = await this._repos().downloadManagerState.get()
this._cachedState = state
return state
}
/**
* @param {object} state
* @return {Promise<object>}
* @private
*/
async _putState(state)
{
let row = await this._repos().downloadManagerState.put(state)
this._cachedState = row
return row
}
/**
* @return {Promise<void>}
* @private
*/
async _syncFromStorage()
{
if (!this._cm.canPersist()) {
return
}
this._cachedState = await this._repos().downloadManagerState.get()
await this._resetProgressCountersIfIdle()
this._framework.refreshDockInterface(undefined, {layout: false})
this._refreshDockProgress()
this._refreshSelectionMarks()
this._refreshAllItemProgress()
}
/**
* @private
*/
_setupCrossTabSync()
{
$(document).off('visibilitychange.brazenDownloadManager').on('visibilitychange.brazenDownloadManager', () => {
if (document.visibilityState === 'visible') {
void this._syncFromStorage()
.then(() => this._tryClaimProcessingLeadership())
.then(() => this._restoreTagDiscoveryPanelIfNeeded())
.then(() => {
if (this._getStateSync()?.humanInteractionBlocked) {
void this._promptHumanInteractionResume()
}
if (this._shouldRunProcessors()) {
void this._runProcessors()
}
})
}
})
$(window).off('pagehide.brazenDownloadManager beforeunload.brazenDownloadManager').
on('pagehide.brazenDownloadManager beforeunload.brazenDownloadManager', () => {
// Clear the sync mirror immediately; async IDB release often does not finish on refresh.
this._clearProcessingLockMirrorIfOwned()
void this._releaseProcessingLeadership()
})
}
// -------------------------------------------------------------------------
// Search item progress
// -------------------------------------------------------------------------
/**
* @return {{stepCount: number, indices: {queued: number, resolving: number, tagReview: number, downloadQueued: number, downloading: number}}}
* @private
*/
_getPipelineStepLayout()
{
if (this._isTagDiscoveryActive()) {
return {
stepCount: 5,
indices: {queued: 0, resolving: 1, tagReview: 2, downloadQueued: 3, downloading: 4},
}
}
return {
stepCount: 4,
indices: {queued: 0, resolving: 1, tagReview: -1, downloadQueued: 2, downloading: 3},
}
}
/**
* @param {string|number} itemId
* @param {HTMLElement|null|undefined} element
* @private
*/
_trackItemElement(itemId, element)
{
if (itemId == null || !element) {
return
}
this._trackedItemElements.set(String(itemId), element)
}
/**
* @param {string|number} itemId
* @private
*/
_untrackItemElement(itemId)
{
if (itemId == null) {
return
}
this._trackedItemElements.delete(String(itemId))
}
/**
* @param {string|number} itemId
* @private
*/
_refreshItemProgress(itemId)
{
void this._renderItemProgress(itemId)
}
/**
* @private
*/
_refreshAllItemProgress()
{
for (let itemId of this._trackedItemElements.keys()) {
void this._renderItemProgress(itemId)
}
}
/**
* @private
*/
_clearAllItemProgress()
{
for (let element of this._trackedItemElements.values()) {
this._setItemProgress(element, null)
}
this._trackedItemElements.clear()
}
/**
* @param {string|number} itemId
* @return {Promise<void>}
* @private
*/
async _renderItemProgress(itemId)
{
let 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) {
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 = await this._getState()
let resolution = await this._repos().downloadResolutionQueue.get(String(itemId))
let download = await this._repos().downloadQueue.get(String(itemId))
let blocked = !!state?.resolutionBlocked
let blockedItemId = state?.resolutionBlockedItemId != null ? String(state.resolutionBlockedItemId) : null
let humanBlocked = !!state?.humanInteractionBlocked
let humanItemId = state?.humanInteractionItemId != null ? String(state.humanInteractionItemId) : null
let itemKey = String(itemId)
if (humanBlocked) {
if (humanItemId === itemKey) {
return {
label: 'Verify',
stepIndex: state?.humanInteractionContext === 'download' ?
layout.indices.downloading :
layout.indices.resolving,
stepCount: layout.stepCount,
}
}
if (humanItemId) {
let hiCtx = state?.humanInteractionContext
let showWaiting = this._linkQueues
if (!showWaiting && hiCtx === 'download') {
showWaiting = !!(download && !DOWNLOAD_TERMINAL.has(download.status))
}
if (!showWaiting && hiCtx === '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 === 'tagReview') {
return {
label: blocked && blockedItemId === itemKey ? 'Tags' : 'Waiting',
stepIndex: layout.indices.tagReview >= 0 ? layout.indices.tagReview : layout.indices.downloadQueued,
stepCount: layout.stepCount,
}
}
if (resolution.status === 'resolving') {
return {
label: 'Resolving',
stepIndex: layout.indices.resolving,
stepCount: layout.stepCount,
}
}
if (resolution.status === 'queued') {
if (blocked && blockedItemId && blockedItemId !== itemKey) {
return {
label: 'Waiting',
stepIndex: layout.indices.queued,
stepCount: layout.stepCount,
}
}
return {
label: 'Queued',
stepIndex: layout.indices.queued,
stepCount: layout.stepCount,
}
}
}
return null
}
/**
* @param {HTMLElement|null|undefined} element
* @param {{label: string, stepIndex: number, stepCount: number, failed?: boolean, complete?: boolean}|null} progress
* @private
*/
_setItemProgress(element, progress)
{
let pageConfig = this._getActivePageConfig()
if (pageConfig?.setItemProgress) {
Utilities.callEventHandler(pageConfig.setItemProgress, [element, progress], null)
return
}
if (progress) {
BrazenViewLayer.updateDownloadManagerItemProgress(element, progress)
return
}
BrazenViewLayer.clearDownloadManagerItemProgress(element)
}
// -------------------------------------------------------------------------
// Selection mode
// -------------------------------------------------------------------------
/**
* @return {boolean}
* @private
*/
_isSelectionModeActive()
{
return !!this._selectionModeActive
}
/**
* @private
*/
_bindSelectionHandlers()
{
let pageConfig = this._getActivePageConfig()
if (!pageConfig?.resolveItem || !pageConfig.itemSelector) {
return
}
$(document).off('click.brazenDmSelection', pageConfig.itemSelector)
if (!this._isSelectionModeActive() || !this.isDownloadManagerEnabled()) {
return
}
this._selectionClickHandler = (event) => {
let target = $(event.target).closest(pageConfig.itemSelector)[0]
if (!target) {
return
}
event.preventDefault()
event.stopPropagation()
let resolved = Utilities.callEventHandler(pageConfig.resolveItem, [$(target)], null)
if (!resolved?.itemId) {
return
}
void this._toggleSelectionItem(resolved, target)
}
$(document).on('click.brazenDmSelection', pageConfig.itemSelector, this._selectionClickHandler)
}
/**
* @param {object} resolved
* @param {HTMLElement} element
* @return {Promise<void>}
* @private
*/
async _toggleSelectionItem(resolved, element)
{
let itemKey = String(resolved.itemId)
let epoch = (this._selectionOpEpoch.get(itemKey) || 0) + 1
this._selectionOpEpoch.set(itemKey, epoch)
let queued = this._trackedItemElements.has(itemKey) || await this.isQueued(resolved.itemId)
if (this._selectionOpEpoch.get(itemKey) !== epoch) {
return
}
if (queued) {
// 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 IDB enqueue so click→feedback 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,
})
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)
}
/**
* @private
*/
_refreshSelectionMarks()
{
let pageConfig = this._getActivePageConfig()
if (!pageConfig?.itemSelector || !this.isDownloadPageRole('selection')) {
return
}
void (async () => {
let selectionActive = this._isSelectionModeActive()
for (let element of document.querySelectorAll(pageConfig.itemSelector)) {
let resolved = Utilities.callEventHandler(pageConfig.resolveItem, [$(element)], null)
if (!resolved?.itemId) {
continue
}
let itemKey = String(resolved.itemId)
let queued = await this.isQueued(resolved.itemId)
if (this._selectionUiHidden.has(itemKey)) {
if (!queued) {
this._selectionUiHidden.delete(itemKey)
}
continue
}
let selected = selectionActive && queued
// Keep DOM↔item mapping warm so processor claim/complete refreshes find the tile.
if (selected) {
this._trackItemElement(resolved.itemId, element)
await this._renderItemProgress(resolved.itemId)
continue
}
if (this._selectionUiPending.has(itemKey)) {
// Optimistic select — IDB row not visible yet; leave the painted overlay alone.
continue
}
if (this._trackedItemElements.has(itemKey)) {
await this._renderItemProgress(resolved.itemId)
if (!queued) {
let progress = await this._resolveItemPipelineView(resolved.itemId)
if (!progress) {
this._untrackItemElement(resolved.itemId)
this._setItemProgress(element, null)
}
}
}
}
})()
}
// -------------------------------------------------------------------------
// Dock UI
// -------------------------------------------------------------------------
/**
* @private
*/
_setupDock()
{
if (this._config.tagDiscovery) {
this._cm.addActionField(DOCK_TAG_DISCOVERY_MODE).
setTitle('Tag Discovery Mode').
setHelpText('When on, downloads pause when unknown tags appear until you confirm mappings in the discovery panel.').
setAction(() => { void this.toggleTagDiscoveryMode() }).
setDockButton({
icon: 'discovery',
getState: () => this._getStateSync()?.tagDiscoveryEnabled ? 'bv-dock-btn-active' : '',
tooltip: () => this._getStateSync()?.tagDiscoveryEnabled ?
'Tag discovery: on — unknown tags pause downloads until you confirm them' :
'Tag discovery: off — click to pause on unknown tags and review mappings',
include: function() {
return this.isDownloadManagerEnabled() && this.isDownloadPageRole('tagDiscoveryToggle')
},
})
}
this._cm.addActionField(DOCK_SELECTION_MODE).
setTitle('Selection Mode').
setHelpText('Click search items to add or remove them from the download resolution queue.').
setAction(() => this.toggleSelectionMode()).
setDockButton({
icon: 'select',
getState: () => this._isSelectionModeActive() ? 'bv-dock-btn-active' : '',
tooltip: () => this._isSelectionModeActive() ?
'Selection mode: on — click items to queue' :
'Selection mode: off — click to select items for download queue',
include: function() {
return this.isDownloadManagerEnabled() && this.isDownloadPageRole('selection')
},
})
this._cm.addActionField(DOCK_ADD_TO_QUEUE).
setTitle('Add to Download Queue').
setHelpText('Adds or removes the current media page from the download resolution queue.').
setAction(() => { void this.toggleCurrentMediaQueued() }).
setDockButton({
icon: 'queue-add',
getState: () => '',
tooltip: 'Add or remove this post from the download queue',
include: function() {
return this.isDownloadManagerEnabled() && this.isDownloadPageRole('enqueueMedia')
},
})
this._cm.addActionField(DOCK_CLEAR_DOWNLOAD_QUEUE).
setTitle('Clear Download Queue').
setHelpText('Removes pending download-queue items only. Resolution, tag discovery, and selection mode are left alone.').
setAction(() => {
if (confirm('Clear pending downloads? Resolution work and selection mode are not cleared.')) {
void this.clearDownloadQueue()
}
}).
setDockButton({
icon: 'clear',
tooltip: 'Clear download queue',
include: () => this.isDownloadManagerEnabled() && this.getPendingDownloadCountSync() > 0,
})
this._cm.addActionField(DOCK_DOWNLOAD_START_PAUSE).
setTitle('Download Queue Start/Pause').
setHelpText('Starts or pauses the download queue only. Resolution always runs unless rate-limited.').
setAction(() => { void this.toggleDownloadManagerPaused() }).
setDockButton({
icon: () => {
let state = this._getStateSync()
if (this._isStartPauseShowingHumanInteraction(state)) {
return 'play'
}
if (this.getPendingDownloadCountSync() <= 0) {
return 'play'
}
return state?.paused ? 'play' : 'pause'
},
getState: () => {
let state = this._getStateSync()
if (this._isStartPauseShowingHumanInteraction(state)) {
return ''
}
if (this.getPendingDownloadCountSync() <= 0) {
return ''
}
return state?.paused ? '' : 'bv-dock-btn-active'
},
isDisabled: () => {
let state = this._getStateSync()
if (this._isStartPauseShowingHumanInteraction(state)) {
return false
}
// Resolution-only work must not enable Start/Pause — that control is download-queue only.
return this.getPendingDownloadCountSync() <= 0
},
tooltip: () => {
let state = this._getStateSync()
if (this._isStartPauseShowingHumanInteraction(state)) {
return 'Verification required — click to confirm and resume'
}
if (this.getPendingDownloadCountSync() <= 0) {
return 'Download queue empty — resolution runs on its own; downloads start here when ready'
}
if (state?.paused) {
return 'Download queue paused — click to start downloads'
}
return 'Download queue running — click to pause downloads'
},
slideOutWhen: () => this._isQueueDockSlideOutOpen(),
slideOutPinnedWhen: () => this._isQueueDockSlideOutPinned(),
include: function() {
return this.isDownloadManagerEnabled()
},
}).
setDockSlideOut(this._getQueueDockSlideOutChildKeys()).
setDockSlideOutNodes(() => this._getQueueDockSlideOutNodes())
// Skip Duplicate ships its dock template from Framework; DM only attaches Hide as slide-out.
let skipDuplicateKey = this._framework._downloadDuplicateLedgerConfig?.enableConfigKey ??
(typeof OPTION_ENABLE_DOWNLOAD_DUPLICATE_LEDGER !== 'undefined' ? OPTION_ENABLE_DOWNLOAD_DUPLICATE_LEDGER : null)
let skipDuplicateField = skipDuplicateKey ? this._cm.getField(skipDuplicateKey) : null
if (skipDuplicateField) {
let hideDownloadedKey = typeof OPTION_HIDE_DOWNLOADED_MEDIA !== 'undefined' ?
OPTION_HIDE_DOWNLOADED_MEDIA :
'hide-downloaded-media'
let hideField = this._cm.getField(hideDownloadedKey)
if (hideField && !hideField.dock) {
hideField.applyDockTemplate('hideDownloaded')
}
skipDuplicateField.setDockSlideOut([hideDownloadedKey])
}
}
/**
* @return {boolean}
* @private
*/
_isQueueDockSlideOutOpen()
{
let slideOut = this._config.queueDockSlideOut
if (slideOut?.slideOutWhen) {
return !!Utilities.callEventHandler(slideOut.slideOutWhen, [this], null)
}
return this.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 {JQuery[]}
* @private
*/
_getQueueDockSlideOutNodes()
{
let slideOut = this._config.queueDockSlideOut
if (slideOut?.getSlideOutNodes) {
let nodes = Utilities.callEventHandler(slideOut.getSlideOutNodes, [this, this._cm], null)
return Array.isArray(nodes) ? nodes.filter((node) => node?.length) : []
}
return [this.getOrCreateProgressSlot()]
}
/**
* @return {string[]}
* @private
*/
_getQueueDockSlideOutChildKeys()
{
let slideOut = this._config.queueDockSlideOut
if (Array.isArray(slideOut?.childFields)) {
return slideOut.childFields
}
return [DOCK_CLEAR_DOWNLOAD_QUEUE]
}
/**
* @param {{refreshAllItems?: boolean}} [options]
* @private
*/
_refreshDockProgress(options = {})
{
let refreshAllItems = options.refreshAllItems !== false
void (async () => {
let progressElement = this.getOrCreateProgressSlot()
let downloadCount = await this._getPendingDownloadCount()
let resolutionCount = await this._getPendingResolutionCount()
this._cachedDownloadCount = downloadCount
this._cachedResolutionCount = resolutionCount
let showProgress = (resolutionCount > 0 || downloadCount > 0) && this.isDownloadManagerEnabled()
BrazenViewLayer.setDownloadManagerProgressSlotVisible(progressElement, showProgress)
if (showProgress) {
let progress = await this.getDownloadManagerProgress()
BrazenViewLayer.updateDownloadManagerProgressSlot(progressElement, progress)
}
if (refreshAllItems) {
this._refreshAllItemProgress()
}
this._framework.refreshDockInterface(undefined, {layout: false})
})()
}
// -------------------------------------------------------------------------
// Page helpers
// -------------------------------------------------------------------------
/**
* @return {string|null}
* @private
*/
_getActivePageName()
{
for (let pageName of Object.keys(this._config.pages ?? {})) {
if (this._framework.isPage(pageName)) {
return pageName
}
}
return null
}
/**
* @return {object|null}
* @private
*/
_getActivePageConfig()
{
let pageName = this._getActivePageName()
return pageName ? this._config.pages[pageName] : null
}
/**
* @return {BrazenStorageRepositories}
* @private
*/
_repos()
{
return this._cm.getRepos()
}
}