Cross-tab download queue, resolution pipeline, and immediate downloads for Brazen user scripts
This script should not be not be installed directly. It is a library for other scripts to include with the meta directive // @require https://update.greasyfork.org/scripts/587126/1877000/Brazen%20Framework%20-%20Download%20Manager.js
// ==UserScript==
// @name Brazen Framework - Download Manager
// @namespace brazenvoid
// @version 1.1.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
}
}
// -------------------------------------------------------------------------
// Constructor
// -------------------------------------------------------------------------
/**
* @param {BrazenFramework} framework
* @param {BrazenConfigurationManager} configurationManager
* @param {object} config
*/
constructor(framework, configurationManager, config)
{
this._framework = framework
this._cm = configurationManager
this._config = config
this._tabId = Utilities.generateId('dm-tab-')
this._processing = false
/** @type {boolean} Set when `_runProcessors` is kicked while already running so the pass re-checks work. */
this._processorsWakeRequested = false
this._immediateQueue = []
this._immediateProcessing = false
this._selectionClickHandler = null
this._selectionModeActive = false
this._progressElement = null
this._tagDiscoveryPanel = null
this._humanInteractionPanel = null
this._trackedItemElements = new Map()
this._cachedResolutionCount = 0
this._cachedDownloadCount = 0
this._processingHeartbeatTimer = null
this._resolutionGapMs = config.resolutionInitiationGapMs ?? 2000
this._downloadGapMs = config.downloadInitiationGapMs ?? 2000
/** @type {number} In-tab clock — avoids IDB lost-update races wiping the download gap. */
this._lastDownloadInitiationAt = 0
/** @type {number} */
this._lastResolutionInitiationAt = 0
/** @type {Promise<void>} Serializes DM state mutations that must re-read before write. */
this._stateWriteTail = Promise.resolve()
this._registerConfigFields()
this._setupDock()
}
/**
* @private
*/
_registerConfigFields()
{
if (this._config.enabled !== true) {
let enableKey = this._config.enableConfigKey ?? OPTION_ENABLE_DOWNLOAD_MANAGER
if (!this._cm.getField(enableKey)) {
this._cm.addFlagField(enableKey).
setTitle('Enable Download Manager').
setHelpText('Show batch download queue controls on the dock and allow selection-mode enqueue.').
setDefault(!!this._config.enableDefault)
}
}
let selectionDefaultKey = this._config.selectionModeDefaultConfigKey ?? OPTION_DOWNLOAD_SELECTION_MODE_DEFAULT
if (!this._cm.getField(selectionDefaultKey)) {
this._cm.addFlagField(selectionDefaultKey).
setTitle('Start in Selection Mode').
setHelpText(
'On load, turn selection mode on for search pages that support batch enqueue. ' +
'You can still toggle it off from the dock for this visit.',
).
setDefault(false)
}
}
/**
* @return {Promise<void>}
*/
async initialize()
{
await this._ensureState()
this._selectionModeActive = this._shouldStartSelectionMode()
try {
sessionStorage.removeItem((this._cm._scriptPrefix ?? 'brazen-') + 'dm-selection')
} catch (error) {
// ignore unavailable sessionStorage
}
this._setupCrossTabSync()
if (this._cm.canPersist()) {
this._hydrateInitiationClocksFromState(await this._getState())
}
this._framework.onConfigurationChange((event) => {
let source = event?.source
// Queue / DM state / ledger puts fire on every processor step — UI updates are owned
// by task-complete hooks, not by this broadcast.
if (source === 'downloadResolutionQueue' || source === 'downloadQueue' ||
source === 'downloadManagerState' || source === 'ledger') {
return
}
this._refreshTagDiscoveryPanel()
this._refreshDockProgress()
this._refreshSelectionMarks()
if (this.isDownloadPageRole('selection')) {
this._bindSelectionHandlers()
}
})
this._framework.onTagSubstitutionUiChange(() => {
this._refreshTagDiscoveryPanel()
})
if (this.isDownloadPageRole('selection')) {
this._bindSelectionHandlers()
}
await this._syncFromStorage()
await this._tryClaimProcessingLeadership()
if (this._getStateSync()?.humanInteractionBlocked) {
void this._promptHumanInteractionResume()
}
await this._restoreTagDiscoveryPanelIfNeeded()
if (this._shouldRunProcessors()) {
void this._runProcessors()
}
}
/**
* @return {boolean}
* @private
*/
_shouldStartSelectionMode()
{
if (!this.isDownloadManagerEnabled() || !this.isDownloadPageRole('selection')) {
return false
}
let key = this._config.selectionModeDefaultConfigKey ?? OPTION_DOWNLOAD_SELECTION_MODE_DEFAULT
return !!this._framework._getConfig(key)
}
// -------------------------------------------------------------------------
// Public API
// -------------------------------------------------------------------------
/**
* @param {{mediaElement: HTMLElement, removeMediaOnSuccess?: boolean, source?: string}} options
* @return {Promise<void>}
*/
async downloadImmediate(options)
{
if (!this.isDownloadPageRole('immediateDownload') && options.source !== 'immediate') {
return
}
let paths = this._config.downloadPaths
let mediaElement = options.mediaElement
let data = paths.extractMediaData(mediaElement)
let tagGroups = paths.extractTagGroups()
let tagIncidences = typeof paths.extractTagIncidences === 'function' ?
(paths.extractTagIncidences() ?? {}) :
{}
let downloadId = this._framework._getDownloadDuplicateLedgerId(mediaElement)
await this._registerResolvedTagGroups(tagGroups)
if (this._isTagDiscoveryActive()) {
let lists = await this._partitionDiscoveryTags(tagGroups, tagIncidences)
if (lists.unknown.length) {
let state = await this._getState()
state.resolutionBlocked = true
state.discoveryPanelTags = this._mergeDiscoveryTags(state.discoveryPanelTags, lists.unknown)
state.discoveryPanelKnownTags = lists.known
state.pendingImmediateDownload = {
mediaElement,
removeMediaOnSuccess: !!options.removeMediaOnSuccess,
data,
tagGroups,
tagIncidences,
downloadId,
sourceUrl: location.href,
}
await this._putState(state)
await this._showTagDiscoveryPanel(state.discoveryPanelTags, state.discoveryPanelKnownTags)
return
}
}
await this._executeImmediateDownload({
mediaElement,
removeMediaOnSuccess: !!options.removeMediaOnSuccess,
data,
tagGroups,
downloadId,
})
}
/**
* Adds a post to the resolution queue when it is not already in the pipeline.
* Dedupe checks both queues: resolution (pending/in-progress resolve) and download
* (already resolved, waiting or downloading). Items already in the download queue
* are never re-enqueued for resolution.
*
* @param {object} context
* @return {Promise<boolean>} whether the item was added to the queue
*/
async enqueueDownload(context)
{
let itemId = this._config.getQueueItemId(context)
if (!itemId) {
return false
}
if (await this.isQueued(itemId)) {
return false
}
// Reset before enqueue so a prior session's completed counts do not inflate dock totals
// (e.g. 4 prior + 1 new looking like a 5-item queue).
await this._resetProgressCountersIfIdle()
await this._clearTerminalQueueRows(itemId)
let row = {
itemId,
downloadType: context.downloadType ?? this._getActivePageConfig()?.defaultDownloadType ?? 'post',
sourceUrl: context.sourceUrl ?? '',
status: 'queued',
originTabId: this._tabId,
resolveContext: context.resolveContext ?? {},
pendingTagGroups: null,
addedAt: Date.now(),
error: null,
}
await this._repos().downloadResolutionQueue.put(row)
await this._tryClaimProcessingLeadership()
await this._syncFromStorage()
if (this._shouldRunProcessors()) {
void this._runProcessors()
}
return true
}
/**
* @param {string} itemId
* @return {Promise<void>}
* @private
*/
async _clearTerminalQueueRows(itemId)
{
let resolution = await this._repos().downloadResolutionQueue.get(itemId)
if (resolution && RESOLUTION_TERMINAL.has(resolution.status)) {
await this._repos().downloadResolutionQueue.remove(itemId)
}
let download = await this._repos().downloadQueue.get(itemId)
if (download && DOWNLOAD_TERMINAL.has(download.status)) {
await this._repos().downloadQueue.remove(itemId)
}
}
/**
* @param {string} itemId
* @return {Promise<void>}
*/
async dequeueDownload(itemId)
{
await this._repos().downloadResolutionQueue.remove(itemId)
await this._repos().downloadQueue.remove(itemId)
await this._syncFromStorage()
this._refreshSelectionMarks()
}
/**
* Whether a post is anywhere in the active download pipeline (either queue).
* Resolution queue: queued, resolving, tagReview, etc.
* Download queue: queued or downloading (already resolved — no re-resolution).
*
* @param {string} itemId
* @return {Promise<boolean>}
*/
async isQueued(itemId)
{
let resolution = await this._repos().downloadResolutionQueue.get(itemId)
if (resolution && !RESOLUTION_TERMINAL.has(resolution.status)) {
return true
}
let download = await this._repos().downloadQueue.get(itemId)
return !!(download && !DOWNLOAD_TERMINAL.has(download.status))
}
/**
* @return {Promise<void>}
*/
async confirmTagDiscoveryMappings()
{
let state = await this._getState()
if (!state.resolutionBlocked) {
return
}
await this._confirmDiscoveryPanelTagTypes(state.discoveryPanelTags)
if (state.pendingImmediateDownload) {
let pending = state.pendingImmediateDownload
state.resolutionBlocked = false
state.discoveryPanelTags = null
state.discoveryPanelKnownTags = null
state.pendingImmediateDownload = null
state.tagDiscoveryPanelTabId = null
await this._putState(state)
this._hideTagDiscoveryPanel()
await this._executeImmediateDownload(pending)
if (this._shouldRunProcessors()) {
void this._runProcessors()
}
return
}
let itemId = state.resolutionBlockedItemId
if (!itemId) {
state.resolutionBlocked = false
state.discoveryPanelTags = null
state.discoveryPanelKnownTags = null
state.tagDiscoveryPanelTabId = null
await this._putState(state)
this._hideTagDiscoveryPanel()
return
}
let item = await this._repos().downloadResolutionQueue.get(itemId)
if (!item?.pendingTagGroups) {
state.resolutionBlocked = false
state.resolutionBlockedItemId = null
state.discoveryPanelTags = null
state.discoveryPanelKnownTags = null
state.tagDiscoveryPanelTabId = null
await this._putState(state)
this._hideTagDiscoveryPanel()
return
}
await this._promoteToDownloadQueue(item, item.pendingTagGroups)
item.status = 'queued'
item.pendingTagGroups = null
await this._repos().downloadResolutionQueue.remove(itemId)
state.resolutionBlocked = false
state.resolutionBlockedItemId = null
state.discoveryPanelTags = null
state.discoveryPanelKnownTags = null
state.tagDiscoveryPanelTabId = null
await this._putState(state)
this._hideTagDiscoveryPanel()
await this._syncFromStorage()
this._refreshItemProgress(itemId)
if (this._shouldRunProcessors()) {
void this._runProcessors()
}
}
/**
* Skip the current resolution item waiting on tag review (do not promote to download;
* do not confirm tag types so discovery can fire again later).
* @return {Promise<void>}
*/
async skipTagDiscoveryInclusion()
{
let state = await this._getState()
if (!state.resolutionBlocked) {
return
}
this._hideTagDiscoveryPanel()
if (state.pendingImmediateDownload) {
state.resolutionBlocked = false
state.discoveryPanelTags = null
state.discoveryPanelKnownTags = null
state.pendingImmediateDownload = null
state.tagDiscoveryPanelTabId = null
await this._putState(state)
await this._syncFromStorage()
if (this._shouldRunProcessors()) {
void this._runProcessors()
}
return
}
let itemId = state.resolutionBlockedItemId
if (itemId) {
await this._repos().downloadResolutionQueue.remove(itemId)
this._trackedItemElements.delete(String(itemId))
this._refreshItemProgress(itemId)
}
state.resolutionBlocked = false
state.resolutionBlockedItemId = null
state.discoveryPanelTags = null
state.discoveryPanelKnownTags = null
state.tagDiscoveryPanelTabId = null
await this._putState(state)
await this._syncFromStorage()
if (this._shouldRunProcessors()) {
void this._runProcessors()
}
}
/**
* Open the media page for the item currently under tag review.
* @return {Promise<void>}
*/
async openTagDiscoveryMedia()
{
let state = await this._getState()
if (!state.resolutionBlocked) {
return
}
let openUrl = state.pendingImmediateDownload?.sourceUrl ?? null
if (!openUrl && state.resolutionBlockedItemId) {
let item = await this._repos().downloadResolutionQueue.get(state.resolutionBlockedItemId)
openUrl = item?.sourceUrl
|| item?.pendingTagGroups?.metadata?.sourceUrl
|| null
}
if (openUrl) {
window.open(openUrl, '_blank')
}
}
/**
* @return {Promise<void>}
*/
async toggleDownloadManagerPaused()
{
let state = await this._getState()
if (state.humanInteractionBlocked) {
void this._promptHumanInteractionResume()
return
}
// Start/Pause only affects the download queue — never resolution.
if ((await this._getPendingDownloadCount()) <= 0) {
return
}
state.paused = !state.paused
await this._putState(state)
if (!state.paused) {
await this._tryClaimProcessingLeadership()
}
await this._syncFromStorage()
if (!state.paused) {
void this._runProcessors()
}
}
/**
* Clears pending/in-progress download-queue rows only. Does not touch the resolution
* queue, tag discovery, selection mode, or processor leadership.
* @return {Promise<void>}
*/
async clearDownloadQueue()
{
let state = await this._getState()
state.paused = true
state.completedDownloadCount = 0
if (state.humanInteractionBlocked && state.humanInteractionContext === 'download') {
this._hideHumanInteractionPanel()
state.humanInteractionBlocked = false
state.humanInteractionContext = null
state.humanInteractionItemId = null
state.humanInteractionPromptTabId = null
state.humanInteractionOpenUrl = null
this._writeHumanInteractionBlockMirror(false)
}
await this._putState(state)
await this._repos().downloadQueue.clearAll()
await this._syncFromStorage()
this._refreshSelectionMarks()
if (this._shouldRunProcessors()) {
void this._runProcessors()
}
}
/**
* @return {Promise<{resolution: {current: number, total: number}, download: {current: number, total: number}}>}
*/
async getDownloadManagerProgress()
{
let downloadRows = await this._repos().downloadQueue.listAll()
let state = await this._getState()
let downloadActive = downloadRows.filter((row) => !DOWNLOAD_TERMINAL.has(row.status))
if (this._isTagDiscoveryActive()) {
return {
resolution: {current: 0, total: 0},
download: {
current: state.completedDownloadCount,
total: state.completedDownloadCount + downloadActive.length,
},
}
}
let resolutionRows = await this._repos().downloadResolutionQueue.listAll()
let resolutionActive = resolutionRows.filter((row) => !RESOLUTION_TERMINAL.has(row.status))
return {
resolution: {
current: state.completedResolutionCount,
total: state.completedResolutionCount + resolutionActive.length,
},
download: {
current: state.completedDownloadCount,
total: state.completedDownloadCount + downloadActive.length,
},
}
}
/**
* @return {Promise<number>}
* @private
*/
async _getPendingDownloadCount()
{
let downloadRows = await this._repos().downloadQueue.listAll()
return downloadRows.filter((row) => !DOWNLOAD_TERMINAL.has(row.status)).length
}
/**
* @return {number}
*/
getPendingResolutionCountSync()
{
return this._cachedResolutionCount ?? 0
}
/**
* @return {Promise<number>}
* @private
*/
async _getPendingResolutionCount()
{
let resolutionRows = await this._repos().downloadResolutionQueue.listAll()
return resolutionRows.filter((row) => !RESOLUTION_TERMINAL.has(row.status)).length
}
/**
* Non-terminal items in both queues (item progress / internal pipeline checks).
* @return {Promise<number>}
*/
async getPendingPipelineCount()
{
return this._getPendingPipelineCount()
}
/**
* @return {Promise<number>}
* @private
*/
async _getPendingPipelineCount()
{
let downloadRows = await this._repos().downloadQueue.listAll()
let downloadCount = downloadRows.filter((row) => !DOWNLOAD_TERMINAL.has(row.status)).length
let resolutionRows = await this._repos().downloadResolutionQueue.listAll()
let resolutionCount = resolutionRows.filter((row) => !RESOLUTION_TERMINAL.has(row.status)).length
return resolutionCount + downloadCount
}
/**
* @return {number}
*/
getPendingDownloadCountSync()
{
return this._cachedDownloadCount ?? 0
}
/**
* @return {Promise<number>}
*/
async getDownloadQueueCount()
{
let downloadRows = await this._repos().downloadQueue.listAll()
let downloadCount = downloadRows.filter((row) => !DOWNLOAD_TERMINAL.has(row.status)).length
if (this._isTagDiscoveryActive()) {
return downloadCount
}
let resolutionRows = await this._repos().downloadResolutionQueue.listAll()
let resolutionCount = resolutionRows.filter((row) => !RESOLUTION_TERMINAL.has(row.status)).length
return resolutionCount + downloadCount
}
/**
* @return {JQuery}
*/
getOrCreateProgressSlot()
{
if (!this._progressElement?.length) {
this._progressElement = BrazenViewLayer.createDownloadManagerProgressSlot()
}
return this._progressElement
}
/**
* @return {Promise<void>}
*/
async toggleTagDiscoveryMode()
{
if (!this._config.tagDiscovery) {
return
}
let state = await this._getState()
state.tagDiscoveryEnabled = !state.tagDiscoveryEnabled
await this._putState(state)
this._framework.refreshDockInterface(undefined, {layout: false})
}
/**
* @return {void}
*/
toggleSelectionMode()
{
if (!this.isDownloadPageRole('selection')) {
return
}
this._selectionModeActive = !this._selectionModeActive
this._framework.refreshDockInterface(undefined, {layout: false})
this._bindSelectionHandlers()
this._refreshSelectionMarks()
}
/**
* @return {Promise<void>}
*/
async toggleCurrentMediaQueued()
{
if (!this.isDownloadPageRole('enqueueMedia')) {
return
}
let itemId = this._config.getQueueItemId({sourceUrl: location.href})
if (!itemId) {
return
}
if (await this.isQueued(itemId)) {
await this.dequeueDownload(itemId)
return
}
await this.enqueueDownload({
itemId,
sourceUrl: location.href,
downloadType: this._getActivePageConfig()?.defaultDownloadType ?? 'post',
resolveContext: {fromMediaPage: true},
})
}
/**
* @param {string} role
* @return {boolean}
*/
isDownloadPageRole(role)
{
let pageName = this._getActivePageName()
if (!pageName) {
return false
}
let pageConfig = this._config.pages?.[pageName]
return !!(pageConfig?.roles?.includes(role))
}
/**
* @return {boolean}
*/
isDownloadManagerEnabled()
{
if (!this._cm.isDockActive()) {
return false
}
if (this._config.enabled === true) {
return true
}
let enableKey = this._config.enableConfigKey ?? OPTION_ENABLE_DOWNLOAD_MANAGER
return !!this._framework._getConfig(enableKey)
}
/**
* @return {boolean}
*/
isDownloadManagerRunning()
{
if (!this.isDownloadManagerEnabled()) {
return false
}
let state = this._getStateSync()
if (!state) {
return false
}
if (this._processing) {
return true
}
if (state.humanInteractionBlocked || state.resolutionBlocked || state.pendingImmediateDownload) {
return true
}
if (!state.resolutionBlocked && this.getPendingResolutionCountSync() > 0) {
return true
}
if (!state.paused && this.getPendingDownloadCountSync() > 0) {
return true
}
return false
}
/**
* True when this tab owns processor leadership ({@link processingTabId}).
* @return {boolean}
*/
isDownloadManagerLeaderTab()
{
return this._getStateSync()?.processingTabId === this._tabId
}
/**
* @param {string[]} lines
* @param {function(string): string} normalizeToken
* @return {{subject: string, replacement: string}[]}
*/
parseDownloadTagSubstitutionLines(lines, normalizeToken)
{
return this._parseDownloadTagSubstitutionLines(lines, normalizeToken)
}
/**
* @param {{subject: string, replacement: string}[]} rules
* @return {Map<string, string>}
*/
buildDownloadTagSubstitutionMap(rules)
{
return this._buildDownloadTagSubstitutionMap(rules)
}
// -------------------------------------------------------------------------
// Path creation (moved from BrazenFramework)
// -------------------------------------------------------------------------
/**
* @param {string} text
* @return {string}
*/
decodeHtmlEntities(text)
{
if (typeof text !== 'string' || text.length === 0) {
return ''
}
if (!text.includes('&')) {
return text
}
let textarea = document.createElement('textarea')
textarea.innerHTML = text
return textarea.value
}
/**
* @param {string} segment
* @return {string}
*/
sanitizePathSegment(segment)
{
return this.decodeHtmlEntities(String(segment)).
replace(/[<>:"/\\|?*]/g, '-').
replace(/[.\s]+$/, '').
trim()
}
/**
* @param {string} folder
* @param {string} name
* @return {string}
*/
buildDownloadPath(folder, name)
{
let folderPath = String(folder).split('/').
map((segment) => this.sanitizePathSegment(segment)).
filter((segment) => segment.length).
join('/').
substring(0, 120).
replace(/[.\s/]+$/, '')
let fileName = this.sanitizePathSegment(name) || 'media'
return folderPath ? folderPath + '/' + fileName : fileName
}
/**
* @param {{}} data
* @param {{}} tagGroups
* @return {string}
*/
buildDownloadPathFromPatterns(data, tagGroups)
{
let paths = this._config.downloadPaths
let resolver = paths.getPatternResolver()
let filenamePattern = this._framework._getConfig(paths.filenamePatternConfigKey)
let name = this._resolveDownloadPattern(filenamePattern, data, tagGroups, resolver)
if (!name) {
name = paths.nameFallback?.(data) ?? (data.md5 || data.id || 'media')
}
if (paths.appendExtension && data.ext) {
name += '.' + data.ext
}
let root = this._framework._getConfig(paths.folderConfigKey) || paths.defaultFolder || ''
let subfolderPattern = paths.subfolderPatternConfigKey ?
this._framework._getConfig(paths.subfolderPatternConfigKey) :
''
let subfolder = subfolderPattern ? this._resolveDownloadPattern(subfolderPattern, data, tagGroups, resolver) : ''
let folder = subfolder ? root + '/' + subfolder : root
return this.buildDownloadPath(folder, name)
}
/**
* @param {string} pattern
* @param {{}} data
* @param {{}} tagGroups
* @param {object} resolver
* @return {string}
* @private
*/
_resolveDownloadPattern(pattern, data, tagGroups, resolver)
{
let resolved = pattern
let chips = [...resolver.chips].sort((a, b) => b.label.length - a.label.length)
for (let chip of chips) {
let value
if (resolver.tagTypes.includes(chip.token)) {
value = this._resolveDownloadTagTypeTokenForPath(pattern, tagGroups[chip.token] ?? [], chip.token, resolver.ignore, resolver)
} else {
value = data[chip.token] ?? ''
}
resolved = resolved.replaceAll(chip.label, value)
}
return resolved.replace(/\s+/g, ' ').trim()
}
/**
* @param {string} pattern
* @param {string[]} tags
* @param {string} type
* @param {Set<string>} ignore
* @param {object} resolver
* @return {string}
* @private
*/
_resolveDownloadTagTypeTokenForPath(pattern, tags, type, ignore, resolver)
{
let value = this._joinTagsForDownloadPath(tags, type, ignore, resolver)
if (!value && this._patternEmploysDownloadTagTypeToken(pattern, type, resolver.chips, resolver.unknownTypes)) {
return resolver.unknownDefault
}
return value
}
/**
* @param {string} pattern
* @param {string} type
* @param {{token: string, label: string}[]} chips
* @param {Set<string>} unknownTypes
* @return {boolean}
* @private
*/
_patternEmploysDownloadTagTypeToken(pattern, type, chips, unknownTypes)
{
if (!unknownTypes.has(type)) {
return false
}
for (let chip of chips) {
if (chip.token === type && pattern.includes(chip.label)) {
return true
}
}
return false
}
/**
* @param {string[]} tags
* @param {string|null} type
* @param {Set<string>|null} ignore
* @param {object} options
* @return {string}
* @private
*/
_joinTagsForDownloadPath(tags, type, ignore, options)
{
if (options.tagRuntime?._warmed) {
tags = options.tagRuntime.applyDownloadAttributesSync(tags, options.stripCharacterSeries)
} else {
tags = this._applyDownloadTagSubstitutions(tags, type, options.substitutions, options.stripCharacterSeries)
if (ignore?.size) {
tags = tags.filter((tag) => !ignore.has(tag))
}
}
let seen = new Set()
let unique = []
for (let tag of tags) {
let formatted = this._formatTagForDownloadPath(tag, type, options.stripCharacterSeries)
if (!formatted || seen.has(formatted)) {
continue
}
seen.add(formatted)
unique.push(formatted)
}
return unique.
sort((left, right) => left.localeCompare(right, undefined, {sensitivity: 'base'})).
join(options.multiTagSeparator)
}
/**
* @param {string[]} tags
* @param {string|null} type
* @param {Map<string, string>} substitutions
* @param {boolean} stripCharacterSeries
* @return {string[]}
* @private
*/
_applyDownloadTagSubstitutions(tags, type, substitutions, stripCharacterSeries)
{
if (!substitutions.size) {
return tags
}
return tags.map((tag) => this._resolveDownloadTagSubstitution(tag, type, substitutions, stripCharacterSeries))
}
/**
* @param {string} tag
* @param {string|null} type
* @param {Map<string, string>} substitutions
* @param {boolean} stripCharacterSeries
* @return {string}
* @private
*/
_resolveDownloadTagSubstitution(tag, type, substitutions, stripCharacterSeries)
{
if (substitutions.has(tag)) {
return substitutions.get(tag)
}
if (type === 'character' && stripCharacterSeries) {
let stripped = tag.replace(/_\([^)]*\)$/, '')
if (stripped !== tag && substitutions.has(stripped)) {
return substitutions.get(stripped)
}
}
return tag
}
/**
* @param {string} tag
* @param {string|null} type
* @param {boolean} stripCharacterSeries
* @return {string}
* @private
*/
_formatTagForDownloadPath(tag, type, stripCharacterSeries)
{
let formatted = this.decodeHtmlEntities(tag)
if (type === 'character' && stripCharacterSeries) {
formatted = formatted.replace(/_\([^)]*\)$/, '')
}
return formatted.replaceAll('_', ' ')
}
/**
* @param {string[]|{subject: string, replacement: string}[]} lines
* @param {function(string): string} normalizeToken
* @return {{subject: string, replacement: string}[]}
* @private
*/
_parseDownloadTagSubstitutionLines(lines, normalizeToken)
{
let rules = []
for (let line of lines) {
if (typeof line === 'object' && line?.subject && line?.replacement) {
let subject = normalizeToken(line.subject)
let replacement = normalizeToken(line.replacement)
if (subject.length && replacement.length) {
rules.push({subject, replacement})
}
continue
}
if (typeof line !== 'string') {
continue
}
let match = line.match(/^(.+?)\s+-\s+(.+)$/)
if (!match) {
continue
}
let subject = normalizeToken(match[1])
let replacement = normalizeToken(match[2])
if (subject.length && replacement.length) {
rules.push({subject, replacement})
}
}
return rules
}
/**
* @param {{subject: string, replacement: string}[]} rules
* @return {Map<string, string>}
* @private
*/
_buildDownloadTagSubstitutionMap(rules)
{
let map = new Map()
for (let rule of rules) {
map.set(rule.subject, rule.replacement)
}
return map
}
// -------------------------------------------------------------------------
// Download execution
// -------------------------------------------------------------------------
/**
* @param {object} pending
* @return {Promise<void>}
* @private
*/
async _executeImmediateDownload(pending)
{
let before = this._config.onBeforeDownload ?
await Utilities.callEventHandler(this._config.onBeforeDownload, [{
mediaElement: pending.mediaElement,
removeMediaOnSuccess: pending.removeMediaOnSuccess,
}], null) :
null
let mediaElement = before?.mediaElement ?? pending.mediaElement
let downloadUrl = before?.downloadUrl ?? mediaElement?.src ?? pending.downloadUrl ?? ''
let restoreOnFailure = before?.restoreOnFailure ?? pending.restoreOnFailure ?? false
if (!downloadUrl) {
return
}
let path = this.buildDownloadPathFromPatterns(pending.data, pending.tagGroups)
let downloadId = pending.downloadId ?? this._framework._getDownloadDuplicateLedgerId(mediaElement)
await this._framework._reloadDownloadDuplicateLedgerFromStorage()
if (this._framework._isDownloadDuplicate(downloadId)) {
this._framework._handleDuplicateDownloadSkipped({name: path, restorePictureOnFailure: restoreOnFailure})
return
}
if (downloadId && this._framework._shouldClaimDownloadDuplicateLedger() &&
!(await this._framework._claimDownloadDuplicateLedgerSlot(downloadId))) {
this._framework._handleDuplicateDownloadSkipped({name: path, restorePictureOnFailure: restoreOnFailure})
return
}
// Defer GM_download until the immediate queue paces — do not start downloads at push time.
this._immediateQueue.push({
name: path,
element: null,
url: downloadUrl,
downloadId,
restorePictureOnFailure: restoreOnFailure,
})
void this._processImmediateQueue()
}
/**
* @param {{name: string|null, element: JQuery|null, url: string, downloadId?: string|null, restorePictureOnFailure?: boolean}} download
* @return {Promise<void>}
* @private
*/
_wrapDownloadTask(download)
{
let path = download.name
if (!path) {
alert('Download failed: missing file path.')
return Promise.resolve()
}
let conflictAction = this._framework._isDownloadDuplicateLedgerActive() ?
DOWNLOAD_DUPLICATE_LEDGER_CONFLICT_ACTION :
'uniquify'
return new Promise((resolve) => {
download.element?.remove()
GM_download({
url: download.url,
name: path,
conflictAction,
onload: () => resolve(),
onerror: (error) => {
this._framework._handleDownloadFailed(download, error)
resolve()
},
})
})
}
/**
* @return {Promise<void>}
* @private
*/
async _processImmediateQueue()
{
if (this._immediateProcessing) {
return
}
this._immediateProcessing = true
while (this._immediateQueue.length > 0) {
await this._paceDownloadInitiation()
let download = this._immediateQueue.shift()
await this._wrapDownloadTask(download)
}
this._immediateProcessing = false
}
/**
* @param {object} item
* @return {Promise<void>}
* @private
*/
async _processDownloadItem(item)
{
try {
// Status is already `downloading` from `_claimDownloadQueueItem`.
let payload = item.resolvedPayload ?? {}
let path = this.buildDownloadPathFromPatterns(payload.data ?? {}, payload.tagGroups ?? {})
let downloadUrl = payload.mediaUrl ?? ''
let downloadId = payload.downloadId ?? item.downloadId ?? item.itemId
// Claim once per queue item. Retries after lock steal / requeue must not re-claim —
// the ledger is reserved before GM_download (Tampermonkey often never fires onload).
if (!item.ledgerClaimed) {
await this._framework._reloadDownloadDuplicateLedgerFromStorage()
if (this._framework._isDownloadDuplicate(downloadId)) {
item.status = 'duplicate'
await this._repos().downloadQueue.put(item)
await this._incrementDownloadProgress()
return
}
if (downloadId && this._framework._shouldClaimDownloadDuplicateLedger() &&
!(await this._framework._claimDownloadDuplicateLedgerSlot(downloadId))) {
item.status = 'duplicate'
await this._repos().downloadQueue.put(item)
await this._incrementDownloadProgress()
return
}
if (this._framework._shouldClaimDownloadDuplicateLedger() && downloadId) {
item.ledgerClaimed = true
await this._repos().downloadQueue.put(item)
}
}
if (!downloadUrl) {
item.status = 'failed'
item.error = 'Missing media URL'
await this._repos().downloadQueue.put(item)
await this._incrementDownloadProgress()
return
}
try {
await this._paceDownloadInitiation()
await this._wrapDownloadTask({name: path, element: null, url: downloadUrl, downloadId})
item.status = 'done'
} catch (error) {
let rateLimit = await this._handleRateLimit('download', error, {sourceUrl: downloadUrl, resolvingUrl: downloadUrl}, item)
if (rateLimit === 'blocked') {
item.status = 'queued'
item.error = null
await this._repos().downloadQueue.put(item)
return
}
item.status = 'failed'
item.error = String(error)
}
await this._repos().downloadQueue.put(item)
await this._incrementDownloadProgress()
} finally {
await this._onDownloadTaskComplete(item.itemId)
}
}
// -------------------------------------------------------------------------
// Resolution pipeline
// -------------------------------------------------------------------------
/**
* @param {string} url
* @return {Promise<Document>}
* @private
*/
async _resolvePage(url)
{
return new Promise((resolve, reject) => {
let rejectWithPayload = (xhr, fallbackMessage) => {
let text = xhr?.responseText ?? ''
let doc = text ? new DOMParser().parseFromString(text, 'text/html') : null
reject(Object.assign(new Error(fallbackMessage), {
status: xhr?.status ?? 0,
doc,
url,
}))
}
$.ajax({
url,
dataType: 'html',
timeout: 30000,
xhrFields: {withCredentials: true},
})
.done((html, _textStatus, xhr) => {
let text = typeof html === 'string' ? html : xhr.responseText ?? ''
let doc = new DOMParser().parseFromString(text, 'text/html')
// Keep status on reject so timedReload / humanInteraction detectors can use it;
// still attach parsed HTML (site 429 / Cloudflare bodies often arrive as 200 or 4xx).
if (xhr.status === 429) {
reject(Object.assign(new Error('HTTP 429'), {status: 429, doc, url}))
return
}
if (xhr.status >= 400) {
reject(Object.assign(new Error('HTTP ' + xhr.status), {status: xhr.status, doc, url}))
return
}
resolve(doc)
})
.fail((xhr, _status, error) => {
rejectWithPayload(xhr, 'Page resolution failed: ' + url + ' — ' + error)
})
})
}
/**
* @param {object} item
* @return {Promise<void>}
* @private
*/
async _processResolutionItem(item)
{
try {
// Status is already `resolving` from `_claimResolutionQueueItem`.
let typeHandler = this._config.downloadTypes?.[item.downloadType]
if (!typeHandler) {
item.status = 'failed'
item.error = 'Unknown download type: ' + item.downloadType
await this._repos().downloadResolutionQueue.put(item)
await this._incrementResolutionProgress()
return
}
try {
let ctx = {
...item.resolveContext,
sourceUrl: item.sourceUrl,
itemId: item.itemId,
downloadType: item.downloadType,
resolvingUrl: item.sourceUrl,
}
if (ctx.fromMediaPage && typeHandler.resolveFromMedia) {
let resolved = await Utilities.callEventHandler(typeHandler.resolveFromMedia, [document, ctx], null)
await this._handleResolvedPayload(item, resolved)
return
}
let searchStep = typeHandler.resolveFromSearch ?
await Utilities.callEventHandler(typeHandler.resolveFromSearch, [ctx], null) :
{nextUrl: item.sourceUrl, downloadType: item.downloadType}
let nextUrl = searchStep?.nextUrl ?? item.sourceUrl
ctx.resolvingUrl = nextUrl
await this._paceResolutionInitiation()
let doc
while (true) {
try {
doc = await this._resolvePage(nextUrl)
} catch (error) {
let rateLimit = await this._handleRateLimit('resolution', error, ctx, item)
if (rateLimit === 'retry') {
continue
}
if (rateLimit === 'blocked') {
item.status = 'queued'
await this._repos().downloadResolutionQueue.put(item)
return
}
throw error
}
let rateLimit = await this._handleRateLimit('resolution', doc, ctx, item)
if (rateLimit === 'retry') {
continue
}
if (rateLimit === 'blocked') {
item.status = 'queued'
await this._repos().downloadResolutionQueue.put(item)
return
}
break
}
if (typeHandler.resolveFromMedia) {
let resolved = await Utilities.callEventHandler(typeHandler.resolveFromMedia, [doc, ctx], null)
if (resolved?.childUrls?.length) {
await this._fanOutChildUrls(item, resolved.childUrls, resolved.downloadType ?? 'post')
item.status = 'done'
await this._repos().downloadResolutionQueue.remove(item.itemId)
await this._incrementResolutionProgress()
return
}
await this._handleResolvedPayload(item, resolved)
return
}
item.status = 'failed'
item.error = 'No resolveFromMedia handler'
await this._repos().downloadResolutionQueue.put(item)
await this._incrementResolutionProgress()
} catch (error) {
item.status = 'failed'
item.error = String(error?.message ?? error)
await this._repos().downloadResolutionQueue.put(item)
await this._incrementResolutionProgress()
}
} finally {
await this._onResolutionTaskComplete(item.itemId)
}
}
/**
* @param {object} item
* @param {object|null} resolved
* @return {Promise<void>}
* @private
*/
async _handleResolvedPayload(item, resolved)
{
if (!resolved?.mediaUrl) {
item.status = 'failed'
item.error = 'Resolution produced no media URL'
await this._repos().downloadResolutionQueue.put(item)
await this._incrementResolutionProgress()
return
}
let tagGroups = resolved.tagGroups ?? {}
let tagIncidences = resolved.tagIncidences ?? {}
await this._registerResolvedTagGroups(tagGroups)
if (this._isTagDiscoveryActive() && this._shouldRunTagDiscovery('mediaPost')) {
let lists = await this._partitionDiscoveryTags(tagGroups, tagIncidences)
if (lists.unknown.length) {
item.status = 'tagReview'
item.pendingTagGroups = resolved
await this._repos().downloadResolutionQueue.put(item)
let state = await this._getState()
state.resolutionBlocked = true
state.resolutionBlockedItemId = item.itemId
state.discoveryPanelTags = this._mergeDiscoveryTags(state.discoveryPanelTags, lists.unknown)
state.discoveryPanelKnownTags = lists.known
await this._putState(state)
await this._showTagDiscoveryPanel(state.discoveryPanelTags, state.discoveryPanelKnownTags)
return
}
}
await this._promoteToDownloadQueue(item, resolved)
await this._repos().downloadResolutionQueue.remove(item.itemId)
await this._incrementResolutionProgress()
}
/**
* @param {object} item
* @param {object} resolved
* @return {Promise<void>}
* @private
*/
async _promoteToDownloadQueue(item, resolved)
{
let row = {
itemId: item.itemId,
downloadType: item.downloadType,
resolvedPayload: resolved,
status: 'queued',
downloadId: resolved.downloadId ?? null,
ledgerClaimed: false,
addedAt: Date.now(),
error: null,
}
await this._repos().downloadQueue.put(row)
}
/**
* @param {object} item
* @param {string[]} childUrls
* @param {string} childType
* @return {Promise<void>}
* @private
*/
async _fanOutChildUrls(item, childUrls, childType)
{
for (let sourceUrl of childUrls) {
let childId = this._config.getQueueItemId({sourceUrl})
if (!childId) {
continue
}
await this.enqueueDownload({
itemId: childId,
sourceUrl,
downloadType: childType,
resolveContext: {parentItemId: item.itemId},
})
}
}
// -------------------------------------------------------------------------
// Rate limits
// -------------------------------------------------------------------------
/**
* @param {'resolution'|'download'} context
* @param {*} signal
* @param {object} ctx
* @param {object} item
* @return {Promise<'retry'|'blocked'|false>}
* @private
*/
async _handleRateLimit(context, signal, ctx, item)
{
let handlers = this._config.rateLimitHandlers?.[context]
if (!handlers) {
return false
}
// Prefer humanInteraction over timedReload. Cloudflare challenges often arrive as
// HTTP 429 with a CAPTCHA body — a bare status===429 must not auto-retry those.
let payload = signal?.doc ?? signal
if (handlers.humanInteraction) {
let detect = handlers.humanInteraction.detect
let matched = detect ?
Utilities.callEventHandler(detect, [payload, ctx], false) :
signal?.status === 429
if (matched) {
let openUrl = handlers.humanInteraction.openUrl?.(ctx) ?? ctx.resolvingUrl ?? ctx.sourceUrl
await this._beginHumanInteractionRateLimit(context, item, openUrl)
return 'blocked'
}
}
if (handlers.timedReload) {
let detect = handlers.timedReload.detect
if (Utilities.callEventHandler(detect, [payload, ctx], false)) {
let delayMs = typeof handlers.timedReload.delayMs === 'function' ?
handlers.timedReload.delayMs(ctx) :
handlers.timedReload.delayMs
await Utilities.sleep(delayMs ?? 5000)
return 'retry'
}
}
return false
}
/**
* @param {'resolution'|'download'} context
* @param {object|null|undefined} item
* @param {string|null|undefined} openUrl
* @return {Promise<void>}
* @private
*/
async _beginHumanInteractionRateLimit(context, item, openUrl)
{
let state = await this._getState()
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 = openUrl ? this._markHumanInteractionOpenUrl(openUrl) : null
// Sync mirror before window.open so Phase-1 mediaCloudflare on that tab stays silent.
this._writeHumanInteractionBlockMirror(true)
await this._putState(state)
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 (state.humanInteractionOpenUrl) {
window.open(state.humanInteractionOpenUrl, '_blank')
}
await this._syncFromStorage()
}
/**
* Marks verification URLs so the opened mediaCloudflare tab stays silent
* (queue confirmation stays on the processor dock panel).
* @param {string} url
* @return {string}
* @private
*/
_markHumanInteractionOpenUrl(url)
{
try {
let parsed = new URL(url, location.href)
parsed.searchParams.set('brazen_hi', '1')
return parsed.href
} catch {
return url
}
}
/**
* @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
}
/**
* 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))
}
/**
* @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
}
window.open(this._markHumanInteractionOpenUrl(openUrl), '_blank')
}
/**
* @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()
let state = await this._getState()
state.humanInteractionBlocked = false
state.humanInteractionContext = null
state.humanInteractionItemId = null
state.humanInteractionPromptTabId = null
state.humanInteractionOpenUrl = null
this._writeHumanInteractionBlockMirror(false)
await this._putState(state)
this._refreshAllItemProgress()
await this._syncFromStorage()
}
// -------------------------------------------------------------------------
// Tag discovery
// -------------------------------------------------------------------------
/**
* Write extracted typed tag groups into the tag registry.
* Discovery on → register-on-seen (`media`, lastSeenTypeEntryId only).
* Discovery off → confirm types as-is (`resolution`, sets typeEntryId).
*
* @param {Record<string, string[]>|null|undefined} tagGroups
* @return {Promise<void>}
* @private
*/
async _registerResolvedTagGroups(tagGroups)
{
if (!this._cm.canPersist() || !tagGroups || typeof tagGroups !== 'object') {
return
}
let hasNames = Object.values(tagGroups).some((names) => Array.isArray(names) && names.some(Boolean))
if (!hasNames) {
return
}
let source = this._isTagDiscoveryActive() ? 'media' : 'resolution'
await this._cm.registerTypedTagGroups(tagGroups, source)
}
/**
* @return {boolean}
*/
isTagDiscoveryEnabled()
{
return !!this._getStateSync()?.tagDiscoveryEnabled
}
/**
* @return {boolean}
* @private
*/
_isTagDiscoveryActive()
{
return !!this._config.tagDiscovery && this.isTagDiscoveryEnabled()
}
/**
* @param {string} step
* @return {boolean}
* @private
*/
_shouldRunTagDiscovery(step)
{
return this._config.tagDiscovery?.discoverAt?.includes(step) ?? false
}
/**
* Promote seen types to confirmed {@link TagEntry.typeEntryId} after discovery review.
* @param {Array<{name: string, type: string|null}>|null} tags
* @return {Promise<void>}
* @private
*/
async _confirmDiscoveryPanelTagTypes(tags)
{
if (!tags?.length) {
return
}
let tagRuntime = this._cm.getTagRuntime()
if (!tagRuntime) {
return
}
for (let tag of tags) {
let entry = await tagRuntime.getTag(tag.name)
if (entry?.typeEntryId != null) {
continue
}
let typeEntryId = entry?.meta?.lastSeenTypeEntryId ?? null
if (typeEntryId == null && tag.type) {
typeEntryId = await this._repos().tags.resolveCanonicalTypeEntryId(tag.type)
}
if (typeEntryId != null) {
await tagRuntime.patchAttributes(tag.name, {typeEntryId})
}
}
await tagRuntime.warmCache()
}
/**
* @param {{}} tagGroups
* @param {Record<string, number|string>|null|undefined} [tagIncidences]
* @return {Promise<{unknown: Array<{name: string, type: string|null, count: number|null}>, known: Array<{name: string, type: string|null, count: number|null}>}>}
* @private
*/
async _partitionDiscoveryTags(tagGroups, tagIncidences = {})
{
let empty = {unknown: [], known: []}
if (!this._config.tagDiscovery) {
return empty
}
let resolver = this._config.downloadPaths.getPatternResolver()
let patterns = this._getActiveDownloadPatterns()
let relevantTypes = new Set()
for (let chip of resolver.chips) {
if (!resolver.tagTypes.includes(chip.token)) {
continue
}
for (let pattern of patterns) {
if (pattern.includes(chip.label)) {
relevantTypes.add(chip.token)
}
}
}
let tagTypes = this._config.tagDiscovery.tagTypes ?? [...relevantTypes]
if (!tagTypes.length) {
return empty
}
let unknown = []
let known = []
let incidences = tagIncidences && typeof tagIncidences === 'object' ? tagIncidences : {}
for (let type of tagTypes) {
for (let tag of tagGroups[type] ?? []) {
if (!tag) {
continue
}
let count = this._resolveTagIncidence(tag, incidences)
let entry = {name: tag, type, count}
if (await this._isTagKnownForDiscovery(tag)) {
known.push(entry)
} else {
unknown.push(entry)
}
}
}
return {unknown, known}
}
/**
* @param {string} tagName
* @param {Record<string, number|string>} incidences
* @return {number|null}
* @private
*/
_resolveTagIncidence(tagName, incidences)
{
if (!incidences || typeof incidences !== 'object') {
return null
}
let raw = incidences[tagName]
if (raw == null) {
let lower = String(tagName).toLowerCase()
for (let [key, value] of Object.entries(incidences)) {
if (String(key).toLowerCase() === lower) {
raw = value
break
}
}
}
if (raw == null || raw === '') {
return null
}
let count = typeof raw === 'number' ? raw : parseInt(String(raw).replace(/[^\d]/g, ''), 10)
return Number.isFinite(count) ? count : null
}
/**
* @param {{}} tagGroups
* @return {Promise<Array<{name: string, type: string|null, count: number|null}>|null>}
* @private
*/
async _findNewDiscoveryTags(tagGroups)
{
let lists = await this._partitionDiscoveryTags(tagGroups, {})
return lists.unknown.length ? lists.unknown : null
}
/**
* Whether a tag is registered with a resolved type ({@link TagRepository.typeEntryId} set).
* Script {@link tagDiscovery.isTagKnown} overrides; default uses {@link TagRepository.getByName}.
*
* @param {string} tagName
* @return {Promise<boolean>}
* @private
*/
async _isTagKnownForDiscovery(tagName)
{
if (this._config.tagDiscovery?.isTagKnown) {
let result = Utilities.callEventHandler(this._config.tagDiscovery.isTagKnown, [tagName], false)
return !!await result
}
if (!this._cm.canPersist()) {
return false
}
let tag = await this._repos().tags.getByName(tagName)
return !!(tag && tag.typeEntryId != null)
}
/**
* @param {Array|null} existing
* @param {Array} discovered
* @return {Array}
* @private
*/
_mergeDiscoveryTags(existing, discovered)
{
let merged = [...(existing ?? [])]
let indexByKey = new Map(merged.map((tag, index) => [tag.name + '\0' + (tag.type ?? ''), index]))
for (let tag of discovered) {
let key = tag.name + '\0' + (tag.type ?? '')
if (indexByKey.has(key)) {
let prior = merged[indexByKey.get(key)]
if ((prior.count == null || prior.count === '') && tag.count != null && tag.count !== '') {
prior.count = tag.count
}
continue
}
indexByKey.set(key, merged.length)
merged.push(tag)
}
return merged
}
/**
* @return {string[]}
* @private
*/
_getActiveDownloadPatterns()
{
let paths = this._config.downloadPaths
let patterns = []
if (paths.filenamePatternConfigKey) {
patterns.push(this._framework._getConfig(paths.filenamePatternConfigKey) ?? '')
}
if (paths.subfolderPatternConfigKey) {
patterns.push(this._framework._getConfig(paths.subfolderPatternConfigKey) ?? '')
}
return patterns
}
/**
* @param {Array} tags
* @param {Array|null|undefined} [knownTags]
* @return {Promise<void>}
* @private
*/
async _showTagDiscoveryPanel(tags, knownTags = null)
{
if (document.visibilityState !== 'visible') {
return
}
await this._claimTagDiscoveryPanel(async (state) => {
if (state.tagDiscoveryPanelTabId && state.tagDiscoveryPanelTabId !== this._tabId) {
return
}
state.tagDiscoveryPanelTabId = this._tabId
if (knownTags != null) {
state.discoveryPanelKnownTags = knownTags
}
await this._putState(state)
this._renderTagDiscoveryPanel(tags, state.discoveryPanelKnownTags ?? knownTags ?? [])
})
}
/**
* @param {Function} callback
* @return {Promise<void>}
* @private
*/
async _claimTagDiscoveryPanel(callback)
{
let state = await this._getState()
if (state.tagDiscoveryPanelTabId && state.tagDiscoveryPanelTabId !== this._tabId && document.visibilityState !== 'visible') {
return
}
await callback(state)
}
/**
* @param {Array} tags
* @param {Array|null|undefined} [knownTags]
* @private
*/
_renderTagDiscoveryPanel(tags, knownTags = null)
{
if (!this._config.tagDiscovery) {
return
}
if (!this._tagDiscoveryPanel) {
this._tagDiscoveryPanel = BrazenViewLayer.createTagDiscoveryPanel({
onConfirm: () => { void this.confirmTagDiscoveryMappings() },
onSkip: () => { void this.skipTagDiscoveryInclusion() },
onOpenMedia: () => { void this.openTagDiscoveryMedia() },
})
BrazenViewLayer.appendToBody($(this._tagDiscoveryPanel))
}
let panelOptions = {
...(this._config.tagDiscovery?.panel ?? {}),
knownTags: knownTags ?? this._getStateSync()?.discoveryPanelKnownTags ?? [],
}
BrazenViewLayer.renderTagDiscoveryPanelContent(
this._tagDiscoveryPanel,
tags,
(row, tag, actionsElement) => {
this._appendTagDiscoveryFrameworkActions(row, tag, actionsElement)
},
panelOptions,
)
if (BrazenViewLayer.isDockSlidePanelVisible(this._tagDiscoveryPanel)) {
// Content-only refresh — keep the open panel still; only re-stack positions.
this._framework._syncDockPanelPosition()
return
}
this._framework._showDockSlidePanel(this._tagDiscoveryPanel)
}
/**
* Framework owns tag-attribute actions; discovery panel maps them internally.
* @param {HTMLElement} row
* @param {{name: string, type: string|null}} tag
* @param {HTMLElement} actionsElement
* @private
*/
_appendTagDiscoveryFrameworkActions(row, tag, actionsElement)
{
let discovery = this._config.tagDiscovery ?? {}
let actions = discovery.actions ?? {}
let $row = $(row)
this._framework.appendTagAttributeActions(actionsElement, tag, {
className: actions.className ?? discovery.actionButtonClass,
iconClass: actions.iconClass ?? discovery.actionIconClass,
ignoreClassName: actions.ignoreClassName,
normalize: actions.normalize ?? discovery.normalizeTag,
bookmark: actions.bookmark,
ignore: actions.ignore,
substitute: actions.substitute ?? {fieldKey: discovery.substitutionFieldKey ?? 'filename-tag-substitutions'},
blacklist: actions.blacklist,
explore: actions.explore,
})
let state = actions.getRowState?.(tag) ?? discovery.getRowState?.(tag)
if (!state) {
return
}
$row.
toggleClass('bv-tag-muted', !!state.muted).
toggleClass('bv-tag-hidden', !!state.hidden)
for (let className of actions.extraMutedClasses ?? discovery.extraMutedClasses ?? []) {
$row.toggleClass(className, !!state.muted)
}
for (let className of actions.extraHiddenClasses ?? discovery.extraHiddenClasses ?? []) {
$row.toggleClass(className, !!state.hidden)
}
}
/**
* @private
*/
_refreshTagDiscoveryPanel()
{
let state = this._getStateSync()
if (!state?.resolutionBlocked || !state.discoveryPanelTags?.length || state.tagDiscoveryPanelTabId !== this._tabId) {
return
}
// Never re-animate an already-open panel on config / action refreshes.
this._renderTagDiscoveryPanel(state.discoveryPanelTags, state.discoveryPanelKnownTags ?? [])
}
/**
* Re-render tag discovery panel rows (e.g. after bookmark/rule toggles).
*/
refreshTagDiscoveryPanel()
{
this._refreshTagDiscoveryPanel()
}
/**
* Re-open the tag discovery panel after reload / tab focus when review is still pending.
* @return {Promise<void>}
* @private
*/
async _restoreTagDiscoveryPanelIfNeeded()
{
if (!this._isTagDiscoveryActive()) {
return
}
let state = await this._getState()
if (state.resolutionBlocked && state.discoveryPanelTags?.length) {
await this._showTagDiscoveryPanel(state.discoveryPanelTags, state.discoveryPanelKnownTags ?? [])
return
}
let resolutionRows = await this._repos().downloadResolutionQueue.listAll()
let stuck = resolutionRows.find((row) => row.status === 'tagReview' && row.pendingTagGroups)
if (!stuck) {
return
}
let pending = stuck.pendingTagGroups
let tagGroups = pending.tagGroups ?? pending
let tagIncidences = pending.tagIncidences ?? {}
let lists = await this._partitionDiscoveryTags(tagGroups, tagIncidences)
state.resolutionBlocked = true
state.resolutionBlockedItemId = stuck.itemId
state.discoveryPanelTags = this._mergeDiscoveryTags(state.discoveryPanelTags, lists.unknown)
state.discoveryPanelKnownTags = lists.known
await this._putState(state)
if (state.discoveryPanelTags?.length) {
await this._showTagDiscoveryPanel(state.discoveryPanelTags, state.discoveryPanelKnownTags)
}
}
/**
* @private
*/
_hideTagDiscoveryPanel()
{
if (!this._tagDiscoveryPanel) {
return
}
this._framework._hideDockSlidePanel(this._tagDiscoveryPanel)
}
// -------------------------------------------------------------------------
// Processors & sync
// -------------------------------------------------------------------------
/**
* @param {object|null} state
* @return {boolean}
* @private
*/
_isProcessingLockStale(state)
{
if (!state?.processingTabId) {
return true
}
let lastBeat = state.processingHeartbeatAt ?? 0
return (Date.now() - lastBeat) > PROCESSING_LOCK_STALE_MS
}
/**
* Sync companion to IndexedDB `processingTabId`. Cleared on `pagehide` so a refresh can
* steal immediately even when the async IDB release never finishes.
* @return {string}
* @private
*/
_processingLockStorageKey()
{
return (this._cm._scriptPrefix ?? 'brazen-') + 'dm-processing-lock'
}
/**
* @return {{tabId: string, at: number}|null}
* @private
*/
_readProcessingLockMirror()
{
try {
let raw = localStorage.getItem(this._processingLockStorageKey())
if (!raw) {
return null
}
let parsed = JSON.parse(raw)
if (!parsed?.tabId) {
return null
}
return parsed
} catch (e) {
return null
}
}
/**
* @private
*/
_writeProcessingLockMirror()
{
try {
localStorage.setItem(this._processingLockStorageKey(), JSON.stringify({
tabId: this._tabId,
at: Date.now(),
}))
} catch (e) {
// ignore quota / private-mode localStorage failures
}
}
/**
* @private
*/
_clearProcessingLockMirrorIfOwned()
{
try {
let key = this._processingLockStorageKey()
let mirror = this._readProcessingLockMirror()
if (mirror?.tabId === this._tabId) {
localStorage.removeItem(key)
}
} catch (e) {
// ignore
}
}
/**
* True when another tab still looks like a live processor owner (fresh IDB heartbeat and
* matching sync localStorage mirror). Missing/mismatched mirror means the owner unloaded.
* @param {object|null} state
* @return {boolean}
* @private
*/
_isProcessingOwnerAlive(state)
{
if (!state?.processingTabId || state.processingTabId === this._tabId) {
return false
}
if (this._isProcessingLockStale(state)) {
return false
}
let mirror = this._readProcessingLockMirror()
return !!(mirror && mirror.tabId === state.processingTabId)
}
/**
* Claim or renew the cross-tab processor lock. Steals a lock whose heartbeat is stale
* or whose sync mirror was cleared on unload (owner refreshed) and requeues in-flight work.
* @return {Promise<boolean>}
* @private
*/
async _tryClaimProcessingLeadership()
{
if (!this._cm.canPersist() || !this.isDownloadManagerEnabled()) {
return false
}
let state = await this._getState()
if (state.processingTabId === this._tabId) {
this._hydrateInitiationClocksFromState(state)
state.processingHeartbeatAt = Date.now()
await this._putState(state)
this._writeProcessingLockMirror()
this._startProcessingHeartbeat()
return true
}
if (this._isProcessingOwnerAlive(state)) {
this._stopProcessingHeartbeat()
return false
}
await this._requeueInterruptedPipelineWork()
state = await this._getState()
if (this._isProcessingOwnerAlive(state)) {
return false
}
state.processingTabId = this._tabId
state.processingHeartbeatAt = Date.now()
if (state.tagDiscoveryPanelTabId && state.tagDiscoveryPanelTabId !== this._tabId) {
state.tagDiscoveryPanelTabId = null
}
this._hydrateInitiationClocksFromState(state)
await this._putState(state)
this._writeProcessingLockMirror()
this._startProcessingHeartbeat()
return true
}
/**
* @return {Promise<void>}
* @private
*/
async _requeueInterruptedPipelineWork()
{
let resolutionRows = await this._repos().downloadResolutionQueue.listAll()
for (let row of resolutionRows) {
if (row.status === 'resolving') {
row.status = 'queued'
row.error = null
await this._repos().downloadResolutionQueue.put(row)
}
}
let downloadRows = await this._repos().downloadQueue.listAll()
for (let row of downloadRows) {
if (row.status === 'downloading') {
row.status = 'queued'
row.error = null
// Keep ledgerClaimed — the slot was already reserved before GM_download.
await this._repos().downloadQueue.put(row)
}
}
}
/**
* @return {Promise<void>}
* @private
*/
async _releaseProcessingLeadership()
{
this._stopProcessingHeartbeat()
this._clearProcessingLockMirrorIfOwned()
if (!this._cm.canPersist()) {
return
}
let state = await this._getState()
if (state.processingTabId !== this._tabId) {
return
}
state.processingTabId = null
state.processingHeartbeatAt = 0
await this._putState(state)
}
/**
* @private
*/
_startProcessingHeartbeat()
{
if (this._processingHeartbeatTimer) {
return
}
this._processingHeartbeatTimer = setInterval(() => {
void this._withState((state) => {
if (state.processingTabId !== this._tabId) {
this._stopProcessingHeartbeat()
this._clearProcessingLockMirrorIfOwned()
return
}
state.processingHeartbeatAt = Date.now()
this._writeProcessingLockMirror()
})
}, PROCESSING_HEARTBEAT_INTERVAL_MS)
}
/**
* @private
*/
_stopProcessingHeartbeat()
{
if (!this._processingHeartbeatTimer) {
return
}
clearInterval(this._processingHeartbeatTimer)
this._processingHeartbeatTimer = null
}
/**
* @return {Promise<void>}
* @private
*/
async _runProcessors()
{
// Take the lock synchronously before any await so concurrent call sites
// (enqueue + sync, two void starts, etc.) cannot both claim the same queued item.
// If already running, request another pass so Start/unpause is not dropped mid-loop.
if (this._processing) {
this._processorsWakeRequested = true
return
}
this._processing = true
try {
do {
this._processorsWakeRequested = false
if (!await this._tryClaimProcessingLeadership()) {
break
}
if (!this._shouldRunProcessors()) {
break
}
while (this._shouldRunProcessors()) {
await this._bumpProcessingHeartbeat()
let state = await this._getState()
if (state.humanInteractionBlocked) {
break
}
let processed = false
if (!state.resolutionBlocked) {
let resolutionRows = await this._repos().downloadResolutionQueue.listAll()
let nextResolution = resolutionRows.find((row) => row.status === 'queued')
if (nextResolution) {
let claimed = await this._claimResolutionQueueItem(nextResolution.itemId)
if (claimed) {
this._refreshItemProgress(claimed.itemId)
await this._processResolutionItem(claimed)
await this._reloadCachedStateQuiet()
}
processed = true
}
}
if (!processed && !state.paused && !state.resolutionBlocked) {
let downloadRows = await this._repos().downloadQueue.listAll()
let nextDownload = downloadRows.find((row) => row.status === 'queued')
if (nextDownload) {
let claimed = await this._claimDownloadQueueItem(nextDownload.itemId)
if (claimed) {
this._refreshItemProgress(claimed.itemId)
await this._processDownloadItem(claimed)
await this._reloadCachedStateQuiet()
}
processed = true
}
}
if (!processed) {
break
}
}
} while (this._processorsWakeRequested)
} finally {
this._processing = false
await this._pauseDownloadQueueIfIdle()
this._refreshDockProgress()
if (this._processorsWakeRequested) {
this._processorsWakeRequested = false
void this._runProcessors()
}
}
}
/**
* Flip a resolution row from `queued` → `resolving`. Returns null if another worker already claimed it.
* @param {string} itemId
* @return {Promise<object|null>}
* @private
*/
async _claimResolutionQueueItem(itemId)
{
let row = await this._repos().downloadResolutionQueue.get(itemId)
if (!row || row.status !== 'queued') {
return null
}
row.status = 'resolving'
row.error = null
await this._repos().downloadResolutionQueue.put(row)
return row
}
/**
* Flip a download row from `queued` → `downloading`. Returns null if another worker already claimed it.
* @param {string} itemId
* @return {Promise<object|null>}
* @private
*/
async _claimDownloadQueueItem(itemId)
{
let row = await this._repos().downloadQueue.get(itemId)
if (!row || row.status !== 'queued') {
return null
}
row.status = 'downloading'
row.error = null
await this._repos().downloadQueue.put(row)
return row
}
/**
* @return {Promise<void>}
* @private
*/
async _bumpProcessingHeartbeat()
{
await this._withState((state) => {
if (state.processingTabId !== this._tabId) {
return
}
state.processingHeartbeatAt = Date.now()
this._writeProcessingLockMirror()
})
}
/**
* @return {boolean}
* @private
*/
_shouldRunProcessors()
{
if (!this._cm.canPersist()) {
return false
}
let state = this._getStateSync()
if (!state) {
return false
}
if (state.humanInteractionBlocked) {
return false
}
if (this._isProcessingOwnerAlive(state)) {
return false
}
return this.isDownloadManagerEnabled()
}
/**
* @param {number} lastAt
* @param {number} gapMs
* @return {Promise<number>}
* @private
*/
async _awaitInitiationGap(lastAt, gapMs)
{
let remaining = gapMs - (Date.now() - (lastAt ?? 0))
if (remaining > 0) {
await Utilities.sleep(remaining)
}
return Date.now()
}
/**
* Seed in-tab initiation clocks from shared state (leadership claim / renew).
* @param {object|null|undefined} state
* @private
*/
_hydrateInitiationClocksFromState(state)
{
this._lastDownloadInitiationAt = Math.max(
this._lastDownloadInitiationAt ?? 0,
state?.lastDownloadInitiationAt ?? 0,
)
this._lastResolutionInitiationAt = Math.max(
this._lastResolutionInitiationAt ?? 0,
state?.lastResolutionInitiationAt ?? 0,
)
}
/**
* Serialize IDB state mutations that re-read before write (avoids lost updates).
* @param {function(object): (void|Promise<void>)} mutator
* @return {Promise<object>}
* @private
*/
_withState(mutator)
{
if (!this._cm.canPersist()) {
return Promise.resolve(this._cachedState)
}
let run = this._stateWriteTail.then(async () => {
let state = await this._repos().downloadManagerState.get()
await mutator(state)
state.id = 'state'
await this._repos().downloadManagerState.put(state)
this._cachedState = state
return state
})
this._stateWriteTail = run.then(() => undefined, () => undefined)
return run
}
/**
* Pace the next GM_download / resolution fetch initiation.
* Uses an in-tab clock so concurrent heartbeat/progress puts cannot wipe lastAt in IDB.
* @param {'download'|'resolution'} kind
* @return {Promise<void>}
* @private
*/
async _paceInitiation(kind)
{
let gapMs = kind === 'resolution' ? this._resolutionGapMs : this._downloadGapMs
let localKey = kind === 'resolution' ? '_lastResolutionInitiationAt' : '_lastDownloadInitiationAt'
let stateKey = kind === 'resolution' ? 'lastResolutionInitiationAt' : 'lastDownloadInitiationAt'
let lastAt = Math.max(this[localKey] ?? 0, this._cachedState?.[stateKey] ?? 0)
let nextAt = await this._awaitInitiationGap(lastAt, gapMs)
this[localKey] = nextAt
if (!this._cm.canPersist()) {
return
}
await this._withState((state) => {
state[stateKey] = Math.max(state[stateKey] ?? 0, nextAt)
})
}
/**
* @return {Promise<void>}
* @private
*/
async _paceDownloadInitiation()
{
await this._paceInitiation('download')
}
/**
* @return {Promise<void>}
* @private
*/
async _paceResolutionInitiation()
{
await this._paceInitiation('resolution')
}
/**
* Zero dock progress counters when both queues are idle so the next run starts at 0/N
* instead of carrying over completed counts from a previous batch.
* @return {Promise<void>}
* @private
*/
async _resetProgressCountersIfIdle()
{
let downloadPending = await this._getPendingDownloadCount()
let resolutionPending = await this._getPendingResolutionCount()
if (downloadPending > 0 || resolutionPending > 0) {
return
}
let state = await this._getState()
if (!state.completedDownloadCount && !state.completedResolutionCount) {
return
}
state.completedDownloadCount = 0
state.completedResolutionCount = 0
await this._putState(state)
}
/**
* When the download queue has no pending work, return to idle (Start required) so the
* next batch does not auto-run because Start was left on from the previous batch.
* @return {Promise<void>}
* @private
*/
async _pauseDownloadQueueIfIdle()
{
if ((await this._getPendingDownloadCount()) > 0) {
return
}
let state = await this._getState()
if (state.paused) {
return
}
state.paused = true
await this._putState(state)
}
/**
* @return {Promise<void>}
* @private
*/
async _incrementResolutionProgress()
{
let state = await this._getState()
state.completedResolutionCount += 1
await this._putState(state)
await this._resetProgressCountersIfIdle()
}
/**
* @return {Promise<void>}
* @private
*/
async _incrementDownloadProgress()
{
let state = await this._getState()
state.completedDownloadCount += 1
await this._putState(state)
await this._pauseDownloadQueueIfIdle()
await this._resetProgressCountersIfIdle()
}
/**
* Reload cached DM state without UI rebuilds (used between processor tasks).
* @return {Promise<void>}
* @private
*/
async _reloadCachedStateQuiet()
{
if (!this._cm.canPersist()) {
return
}
this._cachedState = await this._repos().downloadManagerState.get()
}
/**
* One UI reset after a resolution task finishes (success, fail, block, or requeue).
* @param {string} itemId
* @return {Promise<void>}
* @private
*/
async _onResolutionTaskComplete(itemId)
{
await this._reloadCachedStateQuiet()
this._refreshItemProgress(itemId)
this._refreshDockProgress({refreshAllItems: false})
}
/**
* One UI reset after a download task finishes (success, fail, duplicate, or requeue).
* @param {string} itemId
* @return {Promise<void>}
* @private
*/
async _onDownloadTaskComplete(itemId)
{
await this._reloadCachedStateQuiet()
this._refreshItemProgress(itemId)
this._refreshDockProgress({refreshAllItems: false})
}
/**
* @return {Promise<void>}
* @private
*/
async _ensureState()
{
if (!this._cm.canPersist()) {
return
}
let state = await this._repos().downloadManagerState.get()
if (!state?.id) {
await this._repos().downloadManagerState.reset()
}
}
/**
* @return {object|null}
* @private
*/
_getStateSync()
{
return this._cachedState ?? null
}
/**
* @return {Promise<object>}
* @private
*/
async _getState()
{
let state = await this._repos().downloadManagerState.get()
this._cachedState = state
return state
}
/**
* @param {object} state
* @return {Promise<object>}
* @private
*/
async _putState(state)
{
let row = await this._repos().downloadManagerState.put(state)
this._cachedState = row
return row
}
/**
* @return {Promise<void>}
* @private
*/
async _syncFromStorage()
{
if (!this._cm.canPersist()) {
return
}
this._cachedState = await this._repos().downloadManagerState.get()
await this._resetProgressCountersIfIdle()
this._framework.refreshDockInterface(undefined, {layout: false})
this._refreshDockProgress()
this._refreshSelectionMarks()
this._refreshAllItemProgress()
}
/**
* @private
*/
_setupCrossTabSync()
{
$(document).off('visibilitychange.brazenDownloadManager').on('visibilitychange.brazenDownloadManager', () => {
if (document.visibilityState === 'visible') {
void this._syncFromStorage()
.then(() => this._tryClaimProcessingLeadership())
.then(() => this._restoreTagDiscoveryPanelIfNeeded())
.then(() => {
if (this._getStateSync()?.humanInteractionBlocked) {
void this._promptHumanInteractionResume()
}
if (this._shouldRunProcessors()) {
void this._runProcessors()
}
})
}
})
$(window).off('pagehide.brazenDownloadManager beforeunload.brazenDownloadManager').
on('pagehide.brazenDownloadManager beforeunload.brazenDownloadManager', () => {
// Clear the sync mirror immediately; async IDB release often does not finish on refresh.
this._clearProcessingLockMirrorIfOwned()
void this._releaseProcessingLeadership()
})
}
// -------------------------------------------------------------------------
// Search item progress
// -------------------------------------------------------------------------
/**
* @return {{stepCount: number, indices: {queued: number, resolving: number, tagReview: number, downloadQueued: number, downloading: number}}}
* @private
*/
_getPipelineStepLayout()
{
if (this._isTagDiscoveryActive()) {
return {
stepCount: 5,
indices: {queued: 0, resolving: 1, tagReview: 2, downloadQueued: 3, downloading: 4},
}
}
return {
stepCount: 4,
indices: {queued: 0, resolving: 1, tagReview: -1, downloadQueued: 2, downloading: 3},
}
}
/**
* @param {string|number} itemId
* @param {HTMLElement|null|undefined} element
* @private
*/
_trackItemElement(itemId, element)
{
if (itemId == null || !element) {
return
}
this._trackedItemElements.set(String(itemId), element)
}
/**
* @param {string|number} itemId
* @private
*/
_untrackItemElement(itemId)
{
if (itemId == null) {
return
}
this._trackedItemElements.delete(String(itemId))
}
/**
* @param {string|number} itemId
* @private
*/
_refreshItemProgress(itemId)
{
void this._renderItemProgress(itemId)
}
/**
* @private
*/
_refreshAllItemProgress()
{
for (let itemId of this._trackedItemElements.keys()) {
void this._renderItemProgress(itemId)
}
}
/**
* @private
*/
_clearAllItemProgress()
{
for (let element of this._trackedItemElements.values()) {
this._setItemProgress(element, null)
}
this._trackedItemElements.clear()
}
/**
* @param {string|number} itemId
* @return {Promise<void>}
* @private
*/
async _renderItemProgress(itemId)
{
let element = this._trackedItemElements.get(String(itemId))
if (!element) {
return
}
let progress = await this._resolveItemPipelineView(itemId)
this._setItemProgress(element, progress)
}
/**
* @param {string|number} itemId
* @return {Promise<{label: string, stepIndex: number, stepCount: number, failed?: boolean, complete?: boolean}|null>}
* @private
*/
async _resolveItemPipelineView(itemId)
{
let layout = this._getPipelineStepLayout()
let state = await this._getState()
let resolution = await this._repos().downloadResolutionQueue.get(String(itemId))
let download = await this._repos().downloadQueue.get(String(itemId))
let blocked = !!state?.resolutionBlocked
let blockedItemId = state?.resolutionBlockedItemId != null ? String(state.resolutionBlockedItemId) : null
let humanBlocked = !!state?.humanInteractionBlocked
let humanItemId = state?.humanInteractionItemId != null ? String(state.humanInteractionItemId) : null
let itemKey = String(itemId)
if (humanBlocked) {
if (humanItemId === itemKey) {
return {
label: 'Verify',
stepIndex: state?.humanInteractionContext === 'download' ?
layout.indices.downloading :
layout.indices.resolving,
stepCount: layout.stepCount,
}
}
if (humanItemId) {
return {
label: 'Waiting',
stepIndex: layout.indices.queued,
stepCount: layout.stepCount,
}
}
}
if (download) {
if (download.status === 'downloading') {
return {
label: 'Downloading',
stepIndex: layout.indices.downloading,
stepCount: layout.stepCount,
}
}
if (download.status === 'queued') {
return {
// paused gates starting downloads; queued items stay Ready until Start (not "Paused")
label: 'Ready',
stepIndex: layout.indices.downloadQueued,
stepCount: layout.stepCount,
}
}
if (download.status === 'done') {
return {
label: 'Done',
stepIndex: layout.indices.downloading,
stepCount: layout.stepCount,
complete: true,
}
}
if (download.status === 'duplicate') {
return {
label: 'Duplicate',
stepIndex: layout.indices.downloading,
stepCount: layout.stepCount,
complete: true,
}
}
if (download.status === 'skipped') {
return {
label: 'Skipped',
stepIndex: layout.indices.downloading,
stepCount: layout.stepCount,
complete: true,
}
}
if (download.status === 'failed') {
return {
label: 'Failed',
stepIndex: layout.indices.downloading,
stepCount: layout.stepCount,
failed: true,
}
}
}
if (resolution) {
if (resolution.status === 'failed') {
return {
label: 'Failed',
stepIndex: layout.indices.resolving,
stepCount: layout.stepCount,
failed: true,
}
}
if (resolution.status === 'tagReview') {
return {
label: blocked && blockedItemId === itemKey ? 'Tags' : 'Waiting',
stepIndex: layout.indices.tagReview >= 0 ? layout.indices.tagReview : layout.indices.downloadQueued,
stepCount: layout.stepCount,
}
}
if (resolution.status === 'resolving') {
return {
label: 'Resolving',
stepIndex: layout.indices.resolving,
stepCount: layout.stepCount,
}
}
if (resolution.status === 'queued') {
if (blocked && blockedItemId && blockedItemId !== itemKey) {
return {
label: 'Waiting',
stepIndex: layout.indices.queued,
stepCount: layout.stepCount,
}
}
return {
label: 'Queued',
stepIndex: layout.indices.queued,
stepCount: layout.stepCount,
}
}
}
return null
}
/**
* @param {HTMLElement|null|undefined} element
* @param {{label: string, stepIndex: number, stepCount: number, failed?: boolean, complete?: boolean}|null} progress
* @private
*/
_setItemProgress(element, progress)
{
let pageConfig = this._getActivePageConfig()
if (pageConfig?.setItemProgress) {
Utilities.callEventHandler(pageConfig.setItemProgress, [element, progress], null)
return
}
if (progress) {
BrazenViewLayer.updateDownloadManagerItemProgress(element, progress)
return
}
BrazenViewLayer.clearDownloadManagerItemProgress(element)
}
// -------------------------------------------------------------------------
// Selection mode
// -------------------------------------------------------------------------
/**
* @return {boolean}
* @private
*/
_isSelectionModeActive()
{
return !!this._selectionModeActive
}
/**
* @private
*/
_bindSelectionHandlers()
{
let pageConfig = this._getActivePageConfig()
if (!pageConfig?.resolveItem || !pageConfig.itemSelector) {
return
}
$(document).off('click.brazenDmSelection', pageConfig.itemSelector)
if (!this._isSelectionModeActive() || !this.isDownloadManagerEnabled()) {
return
}
this._selectionClickHandler = (event) => {
let target = $(event.target).closest(pageConfig.itemSelector)[0]
if (!target) {
return
}
event.preventDefault()
event.stopPropagation()
let resolved = Utilities.callEventHandler(pageConfig.resolveItem, [$(target)], null)
if (!resolved?.itemId) {
return
}
void this._toggleSelectionItem(resolved, target)
}
$(document).on('click.brazenDmSelection', pageConfig.itemSelector, this._selectionClickHandler)
}
/**
* @param {object} resolved
* @param {HTMLElement} element
* @return {Promise<void>}
* @private
*/
async _toggleSelectionItem(resolved, element)
{
if (await this.isQueued(resolved.itemId)) {
await this.dequeueDownload(resolved.itemId)
this._untrackItemElement(resolved.itemId)
this._setSelectionMark(element, false)
this._setItemProgress(element, null)
return
}
let added = await this.enqueueDownload(resolved)
if (added) {
this._trackItemElement(resolved.itemId, element)
this._setSelectionMark(element, true)
await this._renderItemProgress(resolved.itemId)
return
}
this._setSelectionMark(element, false)
}
/**
* @param {HTMLElement|null|undefined} element
* @param {boolean} selected
* @private
*/
_setSelectionMark(element, selected)
{
let pageConfig = this._getActivePageConfig()
if (pageConfig?.setSelectionMark) {
Utilities.callEventHandler(pageConfig.setSelectionMark, [element, selected], null)
return
}
BrazenViewLayer.setDownloadManagerSelectionMark(element, selected)
}
/**
* @private
*/
_refreshSelectionMarks()
{
let pageConfig = this._getActivePageConfig()
if (!pageConfig?.itemSelector || !this.isDownloadPageRole('selection')) {
return
}
void (async () => {
let selectionActive = this._isSelectionModeActive()
for (let element of document.querySelectorAll(pageConfig.itemSelector)) {
let resolved = Utilities.callEventHandler(pageConfig.resolveItem, [$(element)], null)
if (!resolved?.itemId) {
continue
}
let itemKey = String(resolved.itemId)
let queued = await this.isQueued(resolved.itemId)
let selected = selectionActive && queued
this._setSelectionMark(element, selected)
// Keep DOM↔item mapping warm so processor claim/complete refreshes find the tile.
if (selected) {
this._trackItemElement(resolved.itemId, element)
await this._renderItemProgress(resolved.itemId)
continue
}
if (this._trackedItemElements.has(itemKey)) {
await this._renderItemProgress(resolved.itemId)
if (!queued) {
let progress = await this._resolveItemPipelineView(resolved.itemId)
if (!progress) {
this._untrackItemElement(resolved.itemId)
this._setItemProgress(element, null)
}
}
}
}
})()
}
// -------------------------------------------------------------------------
// Dock UI
// -------------------------------------------------------------------------
/**
* @private
*/
_setupDock()
{
if (this._config.tagDiscovery) {
this._cm.addActionField(DOCK_TAG_DISCOVERY_MODE).
setTitle('Tag Discovery Mode').
setHelpText('When on, downloads pause when unknown tags appear until you confirm mappings in the discovery panel.').
setAction(() => { void this.toggleTagDiscoveryMode() }).
setDockButton({
icon: 'discovery',
getState: () => this._getStateSync()?.tagDiscoveryEnabled ? 'bv-dock-btn-active' : '',
tooltip: () => this._getStateSync()?.tagDiscoveryEnabled ?
'Tag discovery: on — unknown tags pause downloads until you confirm them' :
'Tag discovery: off — click to pause on unknown tags and review mappings',
include: function() {
return this.isDownloadManagerEnabled() && this.isDownloadPageRole('tagDiscoveryToggle')
},
})
}
this._cm.addActionField(DOCK_SELECTION_MODE).
setTitle('Selection Mode').
setHelpText('Click search items to add or remove them from the download resolution queue.').
setAction(() => this.toggleSelectionMode()).
setDockButton({
icon: 'select',
getState: () => this._isSelectionModeActive() ? 'bv-dock-btn-active' : '',
tooltip: () => this._isSelectionModeActive() ?
'Selection mode: on — click items to queue' :
'Selection mode: off — click to select items for download queue',
include: function() {
return this.isDownloadManagerEnabled() && this.isDownloadPageRole('selection')
},
})
this._cm.addActionField(DOCK_ADD_TO_QUEUE).
setTitle('Add to Download Queue').
setHelpText('Adds or removes the current media page from the download resolution queue.').
setAction(() => { void this.toggleCurrentMediaQueued() }).
setDockButton({
icon: 'queue-add',
getState: () => '',
tooltip: 'Add or remove this post from the download queue',
include: function() {
return this.isDownloadManagerEnabled() && this.isDownloadPageRole('enqueueMedia')
},
})
this._cm.addActionField(DOCK_CLEAR_DOWNLOAD_QUEUE).
setTitle('Clear Download Queue').
setHelpText('Removes pending download-queue items only. Resolution, tag discovery, and selection mode are left alone.').
setAction(() => {
if (confirm('Clear pending downloads? Resolution work and selection mode are not cleared.')) {
void this.clearDownloadQueue()
}
}).
setDockButton({
icon: 'clear',
tooltip: 'Clear download queue',
include: () => this.isDownloadManagerEnabled() && this.getPendingDownloadCountSync() > 0,
})
this._cm.addActionField(DOCK_DOWNLOAD_START_PAUSE).
setTitle('Download Queue Start/Pause').
setHelpText('Starts or pauses the download queue only. Resolution always runs unless rate-limited.').
setAction(() => { void this.toggleDownloadManagerPaused() }).
setDockButton({
icon: () => {
let state = this._getStateSync()
if (state?.humanInteractionBlocked) {
return 'play'
}
if (this.getPendingDownloadCountSync() <= 0) {
return 'play'
}
return state?.paused ? 'play' : 'pause'
},
getState: () => {
let state = this._getStateSync()
if (state?.humanInteractionBlocked) {
return ''
}
if (this.getPendingDownloadCountSync() <= 0) {
return ''
}
return state?.paused ? '' : 'bv-dock-btn-active'
},
isDisabled: () => {
let state = this._getStateSync()
if (state?.humanInteractionBlocked) {
return false
}
// Resolution-only work must not enable Start/Pause — that control is download-queue only.
return this.getPendingDownloadCountSync() <= 0
},
tooltip: () => {
let state = this._getStateSync()
if (state?.humanInteractionBlocked) {
return 'Verification required — click to confirm and resume'
}
if (this.getPendingDownloadCountSync() <= 0) {
return 'Download queue empty — resolution runs on its own; downloads start here when ready'
}
if (state?.paused) {
return 'Download queue paused — click to start downloads'
}
return 'Download queue running — click to pause downloads'
},
slideOutWhen: () => this._isQueueDockSlideOutOpen(),
slideOutPinnedWhen: () => this._isQueueDockSlideOutPinned(),
include: function() {
return this.isDownloadManagerEnabled()
},
}).
setDockSlideOut(this._getQueueDockSlideOutChildKeys()).
setDockSlideOutNodes(() => this._getQueueDockSlideOutNodes())
// Skip Duplicate ships its dock template from Framework; DM only attaches Hide as slide-out.
let skipDuplicateKey = this._framework._downloadDuplicateLedgerConfig?.enableConfigKey ??
(typeof OPTION_ENABLE_DOWNLOAD_DUPLICATE_LEDGER !== 'undefined' ? OPTION_ENABLE_DOWNLOAD_DUPLICATE_LEDGER : null)
let skipDuplicateField = skipDuplicateKey ? this._cm.getField(skipDuplicateKey) : null
if (skipDuplicateField) {
let hideDownloadedKey = typeof OPTION_HIDE_DOWNLOADED_MEDIA !== 'undefined' ?
OPTION_HIDE_DOWNLOADED_MEDIA :
'hide-downloaded-media'
let hideField = this._cm.getField(hideDownloadedKey)
if (hideField && !hideField.dock) {
hideField.applyDockTemplate('hideDownloaded')
}
skipDuplicateField.setDockSlideOut([hideDownloadedKey])
}
}
/**
* @return {boolean}
* @private
*/
_isQueueDockSlideOutOpen()
{
let slideOut = this._config.queueDockSlideOut
if (slideOut?.slideOutWhen) {
return !!Utilities.callEventHandler(slideOut.slideOutWhen, [this], null)
}
return this.getPendingDownloadCountSync() > 0
}
/**
* @return {boolean}
* @private
*/
_isQueueDockSlideOutPinned()
{
let slideOut = this._config.queueDockSlideOut
if (slideOut?.slideOutPinnedWhen) {
return !!Utilities.callEventHandler(slideOut.slideOutPinnedWhen, [this], null)
}
return this._isQueueDockSlideOutOpen()
}
/**
* @return {JQuery[]}
* @private
*/
_getQueueDockSlideOutNodes()
{
let slideOut = this._config.queueDockSlideOut
if (slideOut?.getSlideOutNodes) {
let nodes = Utilities.callEventHandler(slideOut.getSlideOutNodes, [this, this._cm], null)
return Array.isArray(nodes) ? nodes.filter((node) => node?.length) : []
}
return [this.getOrCreateProgressSlot()]
}
/**
* @return {string[]}
* @private
*/
_getQueueDockSlideOutChildKeys()
{
let slideOut = this._config.queueDockSlideOut
if (Array.isArray(slideOut?.childFields)) {
return slideOut.childFields
}
return [DOCK_CLEAR_DOWNLOAD_QUEUE]
}
/**
* @param {{refreshAllItems?: boolean}} [options]
* @private
*/
_refreshDockProgress(options = {})
{
let refreshAllItems = options.refreshAllItems !== false
void (async () => {
let progressElement = this.getOrCreateProgressSlot()
let downloadCount = await this._getPendingDownloadCount()
let resolutionCount = await this._getPendingResolutionCount()
this._cachedDownloadCount = downloadCount
this._cachedResolutionCount = resolutionCount
let showProgress = downloadCount > 0 && this.isDownloadManagerEnabled()
BrazenViewLayer.setDownloadManagerProgressSlotVisible(progressElement, showProgress)
if (showProgress) {
let progress = await this.getDownloadManagerProgress()
BrazenViewLayer.updateDownloadManagerProgressSlot(progressElement, progress)
}
if (refreshAllItems) {
this._refreshAllItemProgress()
}
this._framework.refreshDockInterface(undefined, {layout: false})
})()
}
// -------------------------------------------------------------------------
// Page helpers
// -------------------------------------------------------------------------
/**
* @return {string|null}
* @private
*/
_getActivePageName()
{
for (let pageName of Object.keys(this._config.pages ?? {})) {
if (this._framework.isPage(pageName)) {
return pageName
}
}
return null
}
/**
* @return {object|null}
* @private
*/
_getActivePageConfig()
{
let pageName = this._getActivePageName()
return pageName ? this._config.pages[pageName] : null
}
/**
* @return {BrazenStorageRepositories}
* @private
*/
_repos()
{
return this._cm.getRepos()
}
}