Twitter/X Media Batch Downloader

Batch download all images and videos from a Twitter/X account, including withheld accounts, in original quality.

// ==UserScript==
// @name         Twitter/X Media Batch Downloader
// @description  Batch download all images and videos from a Twitter/X account, including withheld accounts, in original quality.
// @icon         https://raw.githubusercontent.com/afkarxyz/Twitter-X-Media-Batch-Downloader/refs/heads/main/Archived/icon.svg
// @version      2.9
// @author       afkarxyz
// @namespace    https://github.com/afkarxyz/userscripts/
// @supportURL   https://github.com/afkarxyz/userscripts/issues
// @license      MIT
// @match        https://twitter.com/*
// @match        https://x.com/*
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_download
// @connect      gallerydl.vercel.app
// @connect      pbs.twimg.com
// @connect      video.twimg.com
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/jszip.min.js
// ==/UserScript==

;(() => {
  const defaultSettings = {
    authToken: "",
    batchEnabled: true,
    batchSize: 100,
    timelineType: "media",
    mediaType: "all",
    concurrentDownloads: 25,
    cacheDuration: 360,
  }

  function getSettings() {
    return {
      authToken: GM_getValue("authToken", defaultSettings.authToken),
      batchEnabled: GM_getValue("batchEnabled", defaultSettings.batchEnabled),
      batchSize: GM_getValue("batchSize", defaultSettings.batchSize),
      timelineType: GM_getValue("timelineType", defaultSettings.timelineType),
      mediaType: GM_getValue("mediaType", defaultSettings.mediaType),
      concurrentDownloads: GM_getValue("concurrentDownloads", defaultSettings.concurrentDownloads),
      cacheDuration: GM_getValue("cacheDuration", defaultSettings.cacheDuration),
    }
  }

  function saveSettings(settings) {
    GM_setValue("authToken", settings.authToken)
    GM_setValue("batchEnabled", settings.batchEnabled)
    GM_setValue("batchSize", settings.batchSize)
    GM_setValue("timelineType", settings.timelineType)
    GM_setValue("mediaType", settings.mediaType)
    GM_setValue("concurrentDownloads", settings.concurrentDownloads)
    GM_setValue("cacheDuration", settings.cacheDuration)
  }

  function formatNumber(num) {
    return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",")
  }

  const cacheManager = {
    set: (key, data) => {
      const settings = getSettings()
      const cacheItem = {
        data: data,
        timestamp: Date.now(),
        expiry: Date.now() + settings.cacheDuration * 60 * 1000,
      }
      localStorage.setItem(`twitter_dl_${key}`, JSON.stringify(cacheItem))
    },

    get: (key) => {
      const cacheItem = localStorage.getItem(`twitter_dl_${key}`)
      if (!cacheItem) return null

      try {
        const parsed = JSON.parse(cacheItem)
        if (Date.now() > parsed.expiry) {
          localStorage.removeItem(`twitter_dl_${key}`)
          return null
        }
        return parsed.data
      } catch (e) {
        localStorage.removeItem(`twitter_dl_${key}`)
        return null
      }
    },

    clear: () => {
      const keysToRemove = []
      for (let i = 0; i < localStorage.length; i++) {
        const key = localStorage.key(i)
        if (key.startsWith("twitter_dl_")) {
          keysToRemove.push(key)
        }
      }

      keysToRemove.forEach((key) => localStorage.removeItem(key))
    },
  }

  function createDownloadIcon() {
    const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg")
    svg.setAttribute("xmlns", "http://www.w3.org/2000/svg")
    svg.setAttribute("viewBox", "0 0 512 512")
    svg.setAttribute("width", "18")
    svg.setAttribute("height", "18")
    svg.style.verticalAlign = "middle"
    svg.style.cursor = "pointer"

    const defs = document.createElementNS("http://www.w3.org/2000/svg", "defs")
    const style = document.createElementNS("http://www.w3.org/2000/svg", "style")
    style.textContent = ".fa-secondary{opacity:.4}"
    defs.appendChild(style)
    svg.appendChild(defs)

    const secondaryPath = document.createElementNS("http://www.w3.org/2000/svg", "path")
    secondaryPath.setAttribute("class", "fa-secondary")
    secondaryPath.setAttribute("fill", "currentColor")
    secondaryPath.setAttribute(
      "d",
      "M0 256C0 397.4 114.6 512 256 512s256-114.6 256-256c0-17.7-14.3-32-32-32s-32 14.3-32 32c0 106-86 192-192 192S64 362 64 256c0-17.7-14.3-32-32-32s-32 14.3-32 32z",
    )
    svg.appendChild(secondaryPath)

    const primaryPath = document.createElementNS("http://www.w3.org/2000/svg", "path")
    primaryPath.setAttribute("class", "fa-primary")
    primaryPath.setAttribute("fill", "currentColor")
    primaryPath.setAttribute(
      "d",
      "M390.6 185.4c12.5 12.5 12.5 32.8 0 45.3l-112 112c-12.5 12.5-32.8 12.5-45.3 0l-112-112c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0L224 242.7 224 32c0-17.7 14.3-32 32-32s32 14.3 32 32l0 210.7 57.4-57.4c12.5-12.5 32.8-12.5 45.3 0z",
    )
    svg.appendChild(primaryPath)

    return svg
  }

  function createGithubIcon() {
    const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg")
    svg.setAttribute("xmlns", "http://www.w3.org/2000/svg")
    svg.setAttribute("viewBox", "0 0 24 24")
    svg.setAttribute("width", "24")
    svg.setAttribute("height", "24")
    svg.style.verticalAlign = "middle"
    svg.style.marginRight = "8px"

    const path = document.createElementNS("http://www.w3.org/2000/svg", "path")
    path.setAttribute("fill", "currentColor")
    path.setAttribute(
      "d",
      "M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z",
    )
    svg.appendChild(path)

    return svg
  }

  function createConfirmDialog(message, onConfirm, onCancel) {
    const overlay = document.createElement("div")
    overlay.style.cssText = `
        position: fixed;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
        background-color: rgba(0, 0, 0, 0.35);
        backdrop-filter: blur(2.5px);
        display: flex;
        justify-content: center;
        align-items: center;
        z-index: 10001;
        font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
    `

    const dialog = document.createElement("div")
    dialog.style.cssText = `
        background-color: #ffffff;
        color: #0f172a;
        border-radius: 16px;
        width: 300px;
        max-width: 90%;
        box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
        overflow: hidden;
    `

    const header = document.createElement("div")
    header.style.cssText = `
        padding: 16px;
        border-bottom: 1px solid #e2e8f0;
        font-weight: bold;
        font-size: 16px;
        text-align: center;
    `
    header.textContent = "Confirmation"

    const content = document.createElement("div")
    content.style.cssText = `
        padding: 16px;
        text-align: center;
    `
    content.textContent = message

    const buttons = document.createElement("div")
    buttons.style.cssText = `
        display: flex;
        padding: 16px;
        border-top: 1px solid #e2e8f0;
    `

    const cancelButton = document.createElement("button")
    cancelButton.style.cssText = `
        flex: 1;
        background-color: #94a3b8;
        color: white;
        border: none;
        border-radius: 9999px;
        padding: 8px 16px;
        margin-right: 8px;
        font-weight: bold;
        cursor: pointer;
        text-align: center;
        transition: background-color 0.2s;
    `
    cancelButton.textContent = "No"
    cancelButton.addEventListener("mouseenter", () => {
      cancelButton.style.backgroundColor = "#64748b"
    })
    cancelButton.addEventListener("mouseleave", () => {
      cancelButton.style.backgroundColor = "#94a3b8"
    })
    cancelButton.onclick = () => {
      document.body.removeChild(overlay)
      if (onCancel) onCancel()
    }

    const confirmButton = document.createElement("button")
    confirmButton.style.cssText = `
        flex: 1;
        background-color: #ef4444;
        color: white;
        border: none;
        border-radius: 9999px;
        padding: 8px 16px;
        font-weight: bold;
        cursor: pointer;
        text-align: center;
        transition: background-color 0.2s;
    `
    confirmButton.textContent = "Yes"
    confirmButton.addEventListener("mouseenter", () => {
      confirmButton.style.backgroundColor = "#dc2626"
    })
    confirmButton.addEventListener("mouseleave", () => {
      confirmButton.style.backgroundColor = "#ef4444"
    })
    confirmButton.onclick = () => {
      document.body.removeChild(overlay)
      if (onConfirm) onConfirm()
    }

    buttons.appendChild(cancelButton)
    buttons.appendChild(confirmButton)

    dialog.appendChild(header)
    dialog.appendChild(content)
    dialog.appendChild(buttons)
    overlay.appendChild(dialog)

    document.body.appendChild(overlay)
  }

  function formatDate(dateString) {
    const date = new Date(dateString)
    const year = date.getFullYear()
    const month = String(date.getMonth() + 1).padStart(2, "0")
    const day = String(date.getDate()).padStart(2, "0")
    const hours = String(date.getHours()).padStart(2, "0")
    const minutes = String(date.getMinutes()).padStart(2, "0")
    const seconds = String(date.getSeconds()).padStart(2, "0")

    return `${year}${month}${day}_${hours}${minutes}${seconds}`
  }

  function getCurrentTimestamp() {
    const now = new Date()
    const year = now.getFullYear()
    const month = String(now.getMonth() + 1).padStart(2, "0")
    const day = String(now.getDate()).padStart(2, "0")
    const hours = String(now.getHours()).padStart(2, "0")
    const minutes = String(now.getMinutes()).padStart(2, "0")
    const seconds = String(now.getSeconds()).padStart(2, "0")

    return `${year}${month}${day}_${hours}${minutes}${seconds}`
  }

  function fetchData(url) {
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: "GET",
        url: url,
        responseType: "json",
        onload: (response) => {
          if (response.status >= 200 && response.status < 300) {
            resolve(response.response)
          } else {
            reject(new Error(`Request failed with status ${response.status}`))
          }
        },
        onerror: () => {
          reject(new Error("Network error"))
        },
      })
    })
  }

  function fetchBinary(url) {
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: "GET",
        url: url,
        responseType: "blob",
        onload: (response) => {
          if (response.status >= 200 && response.status < 300) {
            resolve(response.response)
          } else {
            reject(new Error(`Request failed with status ${response.status}`))
          }
        },
        onerror: () => {
          reject(new Error("Network error"))
        },
      })
    })
  }

  function getMediaTypeLabel(mediaType) {
    switch (mediaType) {
      case "image":
        return "Image"
      case "video":
        return "Video"
      case "gif":
        return "GIF"
      default:
        return "Media"
    }
  }

  function createModal(username) {
    const existingModal = document.getElementById("media-downloader-modal")
    if (existingModal) {
      existingModal.remove()
    }

    const settings = getSettings()

    const modal = document.createElement("div")
    modal.id = "media-downloader-modal"
    modal.style.cssText = `
        position: fixed;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
        background-color: rgba(0, 0, 0, 0.35);
        backdrop-filter: blur(2.5px);
        display: flex;
        justify-content: center;
        align-items: center;
        z-index: 10000;
        font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
    `

    const modalContent = document.createElement("div")
    modalContent.style.cssText = `
        background-color: #ffffff;
        color: #0f172a;
        border-radius: 16px;
        width: 500px;
        max-width: 90%;
        max-height: 90vh;
        overflow-y: auto;
        box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
    `

    const header = document.createElement("div")
    header.style.cssText = `
        display: flex;
        justify-content: space-between;
        align-items: center;
        padding: 16px;
        border-bottom: 1px solid #e2e8f0;
    `

    const title = document.createElement("h2")
    title.textContent = `Download ${getMediaTypeLabel(settings.mediaType)}: ${username}`
    title.style.cssText = `
        margin: 0;
        font-size: 18px;
        font-weight: bold;
    `

    const closeButton = document.createElement("button")
    closeButton.innerHTML = "&times;"
    closeButton.style.cssText = `
        background: none;
        border: none;
        color: #0f172a;
        font-size: 24px;
        cursor: pointer;
        padding: 0;
        line-height: 1;
        transition: color 0.2s;
    `
    closeButton.addEventListener("mouseenter", () => {
      closeButton.style.color = "#0ea5e9"
    })
    closeButton.addEventListener("mouseleave", () => {
      closeButton.style.color = "#0f172a"
    })
    closeButton.onclick = () => modal.remove()

    header.appendChild(title)
    header.appendChild(closeButton)

    const tabs = document.createElement("div")
    tabs.style.cssText = `
        display: flex;
        border-bottom: 1px solid #e2e8f0;
    `

    const mainTab = document.createElement("div")
    mainTab.textContent = "Main"
    mainTab.className = "active-tab"
    mainTab.style.cssText = `
        padding: 12px 16px;
        cursor: pointer;
        flex: 1;
        text-align: center;
        border-bottom: 2px solid #0ea5e9;
    `

    const settingsTab = document.createElement("div")
    settingsTab.textContent = "Settings"
    settingsTab.style.cssText = `
        padding: 12px 16px;
        cursor: pointer;
        flex: 1;
        text-align: center;
        color: #64748b;
    `

    tabs.appendChild(mainTab)
    tabs.appendChild(settingsTab)

    const mainContent = document.createElement("div")
    mainContent.style.cssText = `
        padding: 16px;
    `

    const settingsContent = document.createElement("div")
    settingsContent.style.cssText = `
        padding: 16px;
        display: none;
    `

    const fetchButton = document.createElement("button")
    const mediaTypeLabelText = getMediaTypeLabel(settings.mediaType).toLowerCase()
    fetchButton.textContent =
      settings.mediaType === "all"
        ? "Fetch Media"
        : `Fetch ${mediaTypeLabelText === "gif" ? "GIF" : mediaTypeLabelText.charAt(0).toUpperCase() + mediaTypeLabelText.slice(1)}`

    fetchButton.style.cssText = `
        background-color: #22c55e;
        color: white;
        border: none;
        border-radius: 9999px;
        padding: 8px 16px;
        font-weight: bold;
        cursor: pointer;
        margin-top: 16px;
        margin-bottom: 16px;
        width: 50%;
        margin-left: auto;
        margin-right: auto;
        display: flex;
        justify-content: center;
        align-items: center;
        text-align: center;
        transition: background-color 0.2s;
    `
    fetchButton.addEventListener("mouseenter", () => {
      fetchButton.style.backgroundColor = "#16a34a"
    })
    fetchButton.addEventListener("mouseleave", () => {
      fetchButton.style.backgroundColor = "#22c55e"
    })

    const infoContainer = document.createElement("div")
    infoContainer.style.cssText = `
        background-color: #f1f5f9;
        border-radius: 8px;
        padding: 12px;
        margin-bottom: 16px;
        display: none;
    `

    const buttonContainer = document.createElement("div")
    buttonContainer.style.cssText = `
        display: none;
        gap: 8px;
        margin-bottom: 16px;
    `

    const downloadCurrentButton = document.createElement("button")
    downloadCurrentButton.textContent = "Download Current Batch"
    downloadCurrentButton.style.cssText = `
        background-color: #0ea5e9;
        color: white;
        border: none;
        border-radius: 9999px;
        padding: 8px 16px;
        font-weight: bold;
        cursor: pointer;
        flex: 1;
        display: block;
        text-align: center;
        transition: background-color 0.2s;
    `
    downloadCurrentButton.addEventListener("mouseenter", () => {
      downloadCurrentButton.style.backgroundColor = "#0284c7"
    })
    downloadCurrentButton.addEventListener("mouseleave", () => {
      downloadCurrentButton.style.backgroundColor = "#0ea5e9"
    })

    const downloadAllButton = document.createElement("button")
    downloadAllButton.textContent = "Download All Batches"
    downloadAllButton.style.cssText = `
        background-color: #0ea5e9;
        color: white;
        border: none;
        border-radius: 9999px;
        padding: 8px 16px;
        font-weight: bold;
        cursor: pointer;
        flex: 1;
        display: block;
        text-align: center;
        transition: background-color 0.2s;
    `
    downloadAllButton.addEventListener("mouseenter", () => {
      downloadAllButton.style.backgroundColor = "#0284c7"
    })
    downloadAllButton.addEventListener("mouseleave", () => {
      downloadAllButton.style.backgroundColor = "#0ea5e9"
    })

    const downloadButton = document.createElement("button")
    downloadButton.textContent = "Download"
    downloadButton.style.cssText = `
        background-color: #0ea5e9;
        color: white;
        border: none;
        border-radius: 9999px;
        padding: 8px 16px;
        font-weight: bold;
        cursor: pointer;
        width: 50%;
        margin-left: auto;
        margin-right: auto;
        display: block;
        text-align: center;
        transition: background-color 0.2s;
    `
    downloadButton.addEventListener("mouseenter", () => {
      downloadButton.style.backgroundColor = "#0284c7"
    })
    downloadButton.addEventListener("mouseleave", () => {
      downloadButton.style.backgroundColor = "#0ea5e9"
    })
    downloadButton.onclick = () => downloadMedia(false)

    if (settings.batchEnabled) {
      buttonContainer.style.display = "none"
    } else {
      buttonContainer.appendChild(downloadButton)
    }

    const batchButtonsContainer = document.createElement("div")
    batchButtonsContainer.style.cssText = `
        display: none;
        gap: 8px;
        margin-bottom: 16px;
    `

    const nextBatchButton = document.createElement("button")
    nextBatchButton.textContent = "Next Batch"
    nextBatchButton.style.cssText = `
        background-color: #6366f1;
        color: white;
        border: none;
        border-radius: 9999px;
        padding: 8px 16px;
        font-weight: bold;
        cursor: pointer;
        flex: 1;
        display: block;
        text-align: center;
        transition: background-color 0.2s;
    `
    nextBatchButton.addEventListener("mouseenter", () => {
      nextBatchButton.style.backgroundColor = "#4f46e5"
    })
    nextBatchButton.addEventListener("mouseleave", () => {
      nextBatchButton.style.backgroundColor = "#6366f1"
    })

    const autoBatchButton = document.createElement("button")
    autoBatchButton.textContent = "Auto Batch"
    autoBatchButton.style.cssText = `
        background-color: #6366f1;
        color: white;
        border: none;
        border-radius: 9999px;
        padding: 8px 16px;
        font-weight: bold;
        cursor: pointer;
        flex: 1;
        display: block;
        text-align: center;
        transition: background-color 0.2s;
    `
    autoBatchButton.addEventListener("mouseenter", () => {
      autoBatchButton.style.backgroundColor = "#4f46e5"
    })
    autoBatchButton.addEventListener("mouseleave", () => {
      autoBatchButton.style.backgroundColor = "#6366f1"
    })

    batchButtonsContainer.appendChild(nextBatchButton)
    batchButtonsContainer.appendChild(autoBatchButton)

    const stopBatchButton = document.createElement("button")
    stopBatchButton.textContent = "Stop Batch"
    stopBatchButton.style.cssText = `
        background-color: #ef4444;
        color: white;
        border: none;
        border-radius: 9999px;
        padding: 8px 16px;
        font-weight: bold;
        cursor: pointer;
        margin-bottom: 16px;
        width: 100%;
        display: none;
        text-align: center;
        transition: background-color 0.2s;
    `
    stopBatchButton.addEventListener("mouseenter", () => {
      stopBatchButton.style.backgroundColor = "#dc2626"
    })
    stopBatchButton.addEventListener("mouseleave", () => {
      stopBatchButton.style.backgroundColor = "#ef4444"
    })

    const progressContainer = document.createElement("div")
    progressContainer.style.cssText = `
        margin-top: 16px;
        display: none;
    `

    const progressText = document.createElement("div")
    progressText.style.cssText = `
        margin-bottom: 8px;
        font-size: 14px;
        text-align: center;
    `
    progressText.textContent = "Downloading..."

    const progressBar = document.createElement("div")
    progressBar.style.cssText = `
        width: 100%;
        height: 8px;
        background-color: #f1f5f9;
        border-radius: 4px;
        overflow: hidden;
    `

    const progressFill = document.createElement("div")
    progressFill.style.cssText = `
        height: 100%;
        width: 0%;
        background-color: #0ea5e9;
        transition: width 0.3s;
    `

    progressBar.appendChild(progressFill)
    progressContainer.appendChild(progressText)
    progressContainer.appendChild(progressBar)

    mainContent.appendChild(fetchButton)
    mainContent.appendChild(infoContainer)
    mainContent.appendChild(buttonContainer)
    mainContent.appendChild(batchButtonsContainer)
    mainContent.appendChild(stopBatchButton)
    mainContent.appendChild(progressContainer)

    const settingsForm = document.createElement("div")
    settingsForm.style.cssText = `
        display: flex;
        flex-direction: column;
        gap: 16px;
    `

    const tokenGroup = document.createElement("div")
    tokenGroup.style.cssText = `
        display: flex;
        flex-direction: column;
        gap: 8px;
    `

    const tokenLabel = document.createElement("label")
    tokenLabel.textContent = "Auth Token:"
    tokenLabel.style.cssText = `
        font-size: 14px;
        font-weight: bold;
    `

    const tokenInputContainer = document.createElement("div")
    tokenInputContainer.style.cssText = `
        position: relative;
        display: flex;
        align-items: center;
    `

    const tokenInput = document.createElement("input")
    tokenInput.type = "text"
    tokenInput.value = settings.authToken
    tokenInput.style.cssText = `
        background-color: #f1f5f9;
        border: 1px solid #cbd5e1;
        border-radius: 4px;
        padding: 8px 12px;
        color: #0f172a;
        width: 100%;
        box-sizing: border-box;
    `
    tokenInput.addEventListener("input", () => {
      const newSettings = getSettings()
      newSettings.authToken = tokenInput.value
      saveSettings(newSettings)
      tokenClearButton.style.display = tokenInput.value ? "block" : "none"
    })

    const tokenClearButton = document.createElement("button")
    tokenClearButton.innerHTML = "&times;"
    tokenClearButton.style.cssText = `
        position: absolute;
        right: 8px;
        background: none;
        border: none;
        color: #64748b;
        font-size: 18px;
        cursor: pointer;
        padding: 0;
        display: ${settings.authToken ? "block" : "none"};
    `
    tokenClearButton.addEventListener("click", () => {
      tokenInput.value = ""
      const newSettings = getSettings()
      newSettings.authToken = ""
      saveSettings(newSettings)
      tokenClearButton.style.display = "none"
    })

    tokenInputContainer.appendChild(tokenInput)
    tokenInputContainer.appendChild(tokenClearButton)
    tokenGroup.appendChild(tokenLabel)
    tokenGroup.appendChild(tokenInputContainer)

    const batchGroup = document.createElement("div")
    batchGroup.style.cssText = `
        display: flex;
        align-items: center;
        gap: 8px;
    `

    const batchLabel = document.createElement("label")
    batchLabel.textContent = "Batch:"
    batchLabel.style.cssText = `
        font-size: 14px;
        font-weight: bold;
        flex: 1;
    `

    const batchToggle = document.createElement("div")
    batchToggle.style.cssText = `
        position: relative;
        width: 50px;
        height: 24px;
        background-color: ${settings.batchEnabled ? "#0ea5e9" : "#cbd5e1"};
        border-radius: 12px;
        cursor: pointer;
        transition: background-color 0.3s;
    `

    const batchToggleHandle = document.createElement("div")
    batchToggleHandle.style.cssText = `
        position: absolute;
        top: 2px;
        left: ${settings.batchEnabled ? "28px" : "2px"};
        width: 20px;
        height: 20px;
        background-color: white;
        border-radius: 50%;
        transition: left 0.3s;
    `

    batchToggle.appendChild(batchToggleHandle)
    batchToggle.addEventListener("click", () => {
      const newSettings = getSettings()
      newSettings.batchEnabled = !newSettings.batchEnabled
      saveSettings(newSettings)
      batchToggle.style.backgroundColor = newSettings.batchEnabled ? "#0ea5e9" : "#cbd5e1"
      batchToggleHandle.style.left = newSettings.batchEnabled ? "28px" : "2px"
      batchSizeGroup.style.display = newSettings.batchEnabled ? "flex" : "none"

      if (!newSettings.batchEnabled) {
        buttonContainer.innerHTML = ""
        buttonContainer.appendChild(downloadButton)
        buttonContainer.style.display = infoContainer.style.display === "block" ? "block" : "none"
      } else {
        buttonContainer.innerHTML = ""
        buttonContainer.style.display = "none"
      }
    })

    batchGroup.appendChild(batchLabel)
    batchGroup.appendChild(batchToggle)

    const batchSizeGroup = document.createElement("div")
    batchSizeGroup.style.cssText = `
        display: ${settings.batchEnabled ? "flex" : "none"};
        flex-direction: column;
        gap: 8px;
    `

    const batchSizeLabel = document.createElement("label")
    batchSizeLabel.textContent = "Batch Size:"
    batchSizeLabel.style.cssText = `
        font-size: 14px;
        font-weight: bold;
    `

    const batchSizeSelect = document.createElement("select")
    batchSizeSelect.style.cssText = `
        background-color: #f1f5f9;
        border: 1px solid #cbd5e1;
        border-radius: 4px;
        padding: 8px 12px;
        color: #0f172a;
        width: 100%;
        box-sizing: border-box;
    `

    const batchSizes = [50, 100, 150, 200]
    batchSizes.forEach((size) => {
      const option = document.createElement("option")
      option.value = size
      option.textContent = size
      option.selected = size === settings.batchSize
      batchSizeSelect.appendChild(option)
    })

    batchSizeSelect.addEventListener("change", () => {
      const newSettings = getSettings()
      newSettings.batchSize = Number.parseInt(batchSizeSelect.value)
      saveSettings(newSettings)
    })

    batchSizeGroup.appendChild(batchSizeLabel)
    batchSizeGroup.appendChild(batchSizeSelect)

    const timelineTypeGroup = document.createElement("div")
    timelineTypeGroup.style.cssText = `
        display: flex;
        flex-direction: column;
        gap: 8px;
    `

    const timelineTypeLabel = document.createElement("label")
    timelineTypeLabel.textContent = "Timeline Type:"
    timelineTypeLabel.style.cssText = `
        font-size: 14px;
        font-weight: bold;
    `

    const timelineTypeSelect = document.createElement("select")
    timelineTypeSelect.style.cssText = `
        background-color: #f1f5f9;
        border: 1px solid #cbd5e1;
        border-radius: 4px;
        padding: 8px 12px;
        color: #0f172a;
        width: 100%;
        box-sizing: border-box;
    `

    const timelineTypes = [
      { value: "media", label: "Media" },
      { value: "timeline", label: "Post" },
      { value: "tweets", label: "Tweets" },
      { value: "with_replies", label: "Replies" },
    ]

    timelineTypes.forEach((type) => {
      const option = document.createElement("option")
      option.value = type.value
      option.textContent = type.label
      option.selected = type.value === settings.timelineType
      timelineTypeSelect.appendChild(option)
    })

    timelineTypeSelect.addEventListener("change", () => {
      const newSettings = getSettings()
      newSettings.timelineType = timelineTypeSelect.value
      saveSettings(newSettings)
    })

    timelineTypeGroup.appendChild(timelineTypeLabel)
    timelineTypeGroup.appendChild(timelineTypeSelect)

    const mediaTypeGroup = document.createElement("div")
    mediaTypeGroup.style.cssText = `
        display: flex;
        flex-direction: column;
        gap: 8px;
    `

    const mediaTypeLabel = document.createElement("label")
    mediaTypeLabel.textContent = "Media Type:"
    mediaTypeLabel.style.cssText = `
        font-size: 14px;
        font-weight: bold;
    `

    const mediaTypeSelect = document.createElement("select")
    mediaTypeSelect.style.cssText = `
        background-color: #f1f5f9;
        border: 1px solid #cbd5e1;
        border-radius: 4px;
        padding: 8px 12px;
        color: #0f172a;
        width: 100%;
        box-sizing: border-box;
    `

    const mediaTypes = [
      { value: "all", label: "All" },
      { value: "image", label: "Image" },
      { value: "video", label: "Video" },
      { value: "gif", label: "GIF" },
    ]

    mediaTypes.forEach((type) => {
      const option = document.createElement("option")
      option.value = type.value
      option.textContent = type.label
      option.selected = type.value === settings.mediaType
      mediaTypeSelect.appendChild(option)
    })

    mediaTypeSelect.addEventListener("change", () => {
      const newSettings = getSettings()
      newSettings.mediaType = mediaTypeSelect.value
      saveSettings(newSettings)

      const newMediaTypeLabel = getMediaTypeLabel(newSettings.mediaType).toLowerCase()
      fetchButton.textContent =
        newSettings.mediaType === "all"
          ? "Fetch Media"
          : `Fetch ${newMediaTypeLabel === "gif" ? "GIF" : newMediaTypeLabel.charAt(0).toUpperCase() + newMediaTypeLabel.slice(1)}`

      title.textContent = `Download ${getMediaTypeLabel(newSettings.mediaType)}: ${username}`
    })

    mediaTypeGroup.appendChild(mediaTypeLabel)
    mediaTypeGroup.appendChild(mediaTypeSelect)

    const concurrentGroup = document.createElement("div")
    concurrentGroup.style.cssText = `
        display: flex;
        flex-direction: column;
        gap: 8px;
    `

    const concurrentLabel = document.createElement("label")
    concurrentLabel.textContent = "Batch Download Items:"
    concurrentLabel.style.cssText = `
        font-size: 14px;
        font-weight: bold;
    `

    const concurrentSelect = document.createElement("select")
    concurrentSelect.style.cssText = `
        background-color: #f1f5f9;
        border: 1px solid #cbd5e1;
        border-radius: 4px;
        padding: 8px 12px;
        color: #0f172a;
        width: 100%;
        box-sizing: border-box;
    `

    const concurrentSizes = [5, 10, 20, 25, 50]
    concurrentSizes.forEach((size) => {
      const option = document.createElement("option")
      option.value = size
      option.textContent = size
      option.selected = size === settings.concurrentDownloads
      concurrentSelect.appendChild(option)
    })

    concurrentSelect.addEventListener("change", () => {
      const newSettings = getSettings()
      newSettings.concurrentDownloads = Number.parseInt(concurrentSelect.value)
      saveSettings(newSettings)
    })

    concurrentGroup.appendChild(concurrentLabel)
    concurrentGroup.appendChild(concurrentSelect)

    const cacheDurationGroup = document.createElement("div")
    cacheDurationGroup.style.cssText = `
        display: flex;
        flex-direction: column;
        gap: 8px;
    `

    const cacheDurationLabel = document.createElement("label")
    cacheDurationLabel.textContent = "Cache Duration:"
    cacheDurationLabel.style.cssText = `
        font-size: 14px;
        font-weight: bold;
    `

    const cacheDurationSelect = document.createElement("select")
    cacheDurationSelect.style.cssText = `
        background-color: #f1f5f9;
        border: 1px solid #cbd5e1;
        border-radius: 4px;
        padding: 8px 12px;
        color: #0f172a;
        width: 100%;
        box-sizing: border-box;
    `

    for (let i = 1; i <= 24; i++) {
      const option = document.createElement("option")
      option.value = i * 60
      option.textContent = `${i} Hour${i > 1 ? "s" : ""}`
      option.selected = i === 6 || settings.cacheDuration === i * 60
      cacheDurationSelect.appendChild(option)
    }

    cacheDurationSelect.addEventListener("change", () => {
      const newSettings = getSettings()
      newSettings.cacheDuration = Number.parseInt(cacheDurationSelect.value)
      saveSettings(newSettings)
    })

    cacheDurationGroup.appendChild(cacheDurationLabel)
    cacheDurationGroup.appendChild(cacheDurationSelect)

    const clearCacheButton = document.createElement("button")
    clearCacheButton.textContent = "Clear Cache"
    clearCacheButton.style.cssText = `
        background-color: #ef4444;
        color: white;
        border: none;
        border-radius: 9999px;
        padding: 8px 16px;
        font-weight: bold;
        cursor: pointer;
        margin-top: 16px;
        width: 50%;
        margin-left: auto;
        margin-right: auto;
        display: block;
        text-align: center;
        transition: background-color 0.2s;
    `
    clearCacheButton.addEventListener("mouseenter", () => {
      clearCacheButton.style.backgroundColor = "#dc2626"
    })
    clearCacheButton.addEventListener("mouseleave", () => {
      clearCacheButton.style.backgroundColor = "#ef4444"
    })

    clearCacheButton.addEventListener("click", () => {
      createConfirmDialog("Are you sure about clearing the cache?", () => {
        cacheManager.clear()

        const notification = document.createElement("div")
        notification.style.cssText = `
                position: fixed;
                bottom: 20px;
                left: 50%;
                transform: translateX(-50%);
                background-color: #0ea5e9;
                color: white;
                padding: 12px 24px;
                border-radius: 9999px;
                font-weight: bold;
                z-index: 10002;
                box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
                text-align: center;
            `
        notification.textContent = "Cache cleared successfully"
        document.body.appendChild(notification)

        setTimeout(() => {
          document.body.removeChild(notification)
        }, 3000)
      })
    })

    const githubLink = document.createElement("a")
    githubLink.href = "https://github.com/afkarxyz/Twitter-X-Media-Batch-Downloader"
    githubLink.target = "_blank"
    githubLink.style.cssText = `
        display: flex;
        align-items: center;
        justify-content: center;
        color: #64748b;
        text-decoration: none;
        margin-top: 16px;
        padding: 8px;
        border-radius: 8px;
        transition: background-color 0.2s, color 0.2s;
    `
    githubLink.innerHTML = createGithubIcon().outerHTML + "Twitter/X Media Batch Downloader"

    githubLink.addEventListener("mouseenter", () => {
      githubLink.style.backgroundColor = "#f1f5f9"
      githubLink.style.color = "#0ea5e9"
    })

    githubLink.addEventListener("mouseleave", () => {
      githubLink.style.backgroundColor = "transparent"
      githubLink.style.color = "#64748b"
    })

    settingsForm.appendChild(tokenGroup)
    settingsForm.appendChild(batchGroup)
    settingsForm.appendChild(batchSizeGroup)
    settingsForm.appendChild(timelineTypeGroup)
    settingsForm.appendChild(mediaTypeGroup)
    settingsForm.appendChild(concurrentGroup)
    settingsForm.appendChild(cacheDurationGroup)
    settingsForm.appendChild(clearCacheButton)
    settingsForm.appendChild(githubLink)

    settingsContent.appendChild(settingsForm)

    mainTab.addEventListener("click", () => {
      mainTab.style.borderBottom = "2px solid #0ea5e9"
      mainTab.style.color = "#0f172a"
      settingsTab.style.borderBottom = "none"
      settingsTab.style.color = "#64748b"
      mainContent.style.display = "block"
      settingsContent.style.display = "none"
    })

    settingsTab.addEventListener("click", () => {
      settingsTab.style.borderBottom = "2px solid #0ea5e9"
      settingsTab.style.color = "#0f172a"
      mainTab.style.borderBottom = "none"
      mainTab.style.color = "#64748b"
      settingsContent.style.display = "block"
      mainContent.style.display = "none"
    })

    modalContent.appendChild(header)
    modalContent.appendChild(tabs)
    modalContent.appendChild(mainContent)
    modalContent.appendChild(settingsContent)
    modal.appendChild(modalContent)

    const mediaData = {
      username: username,
      currentPage: 0,
      mediaItems: [],
      allMediaItems: [],
      hasMore: false,
      downloading: false,
      totalDownloaded: 0,
      totalToDownload: 0,
      totalItems: 0,
      autoBatchRunning: false,
    }

    fetchButton.addEventListener("click", async () => {
      const settings = getSettings()

      if (!settings.authToken) {
        const overlay = document.createElement("div")
        overlay.style.cssText = `
                position: fixed;
                top: 0;
                left: 0;
                width: 100%;
                height: 100%;
                background-color: rgba(0, 0, 0, 0.35);
                backdrop-filter: blur(2.5px);
                display: flex;
                justify-content: center;
                align-items: center;
                z-index: 10001;
                font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
            `

        const popup = document.createElement("div")
        popup.style.cssText = `
                background-color: #ffffff;
                color: #0f172a;
                border-radius: 16px;
                width: 300px;
                max-width: 90%;
                box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
                overflow: hidden;
            `

        const header = document.createElement("div")
        header.style.cssText = `
                padding: 16px;
                border-bottom: 1px solid #e2e8f0;
                font-weight: bold;
                font-size: 16px;
                text-align: center;
            `
        header.textContent = "Authentication Required"

        const content = document.createElement("div")
        content.style.cssText = `
                padding: 16px;
                text-align: center;
            `

        const authLink = document.createElement("a")
        authLink.href = "https://github.com/afkarxyz/Twitter-X-Media-Batch-Downloader#-how-to-obtain-auth-token"
        authLink.target = "_blank"
        authLink.textContent = "How to Obtain Auth Token"
        authLink.style.cssText = `
                color: #0ea5e9;
                text-decoration: none;
                cursor: pointer;
            `
        content.appendChild(authLink)

        const buttonContainer = document.createElement("div")
        buttonContainer.style.cssText = `
                padding: 16px;
                display: flex;
                justify-content: center;
                border-top: 1px solid #e2e8f0;
            `

        const okButton = document.createElement("button")
        okButton.style.cssText = `
                background-color: #0ea5e9;
                color: white;
                border: none;
                border-radius: 9999px;
                padding: 8px 24px;
                font-weight: bold;
                cursor: pointer;
                transition: background-color 0.2s;
            `
        okButton.textContent = "OK"
        okButton.addEventListener("mouseenter", () => {
          okButton.style.backgroundColor = "#0284c7"
        })
        okButton.addEventListener("mouseleave", () => {
          okButton.style.backgroundColor = "#0ea5e9"
        })
        okButton.onclick = () => {
          document.body.removeChild(overlay)
          settingsTab.click()
        }

        buttonContainer.appendChild(okButton)
        popup.appendChild(header)
        popup.appendChild(content)
        popup.appendChild(buttonContainer)
        overlay.appendChild(popup)

        document.body.appendChild(overlay)
        return
      }

      infoContainer.style.display = "none"
      buttonContainer.style.display = "none"
      nextBatchButton.style.display = "none"
      autoBatchButton.style.display = "none"
      stopBatchButton.style.display = "none"
      progressContainer.style.display = "none"
      fetchButton.disabled = true
      fetchButton.textContent = "Fetching..."

      try {
        const cacheKey = `${settings.timelineType}_${settings.mediaType}_${username}_${mediaData.currentPage}_${settings.batchSize}`
        let data = cacheManager.get(cacheKey)

        if (!data) {
          let url
          if (settings.batchEnabled) {
            url = `https://gallerydl.vercel.app/metadata/${settings.timelineType}/${settings.batchSize}/${mediaData.currentPage}/${settings.mediaType}/${username}/${settings.authToken}`
          } else {
            url = `https://gallerydl.vercel.app/metadata/${settings.timelineType}/${settings.mediaType}/${username}/${settings.authToken}`
          }

          data = await fetchData(url)

          cacheManager.set(cacheKey, data)
        }

        if (data.timeline && data.timeline.length > 0) {
          mediaData.mediaItems = data.timeline
          mediaData.hasMore = data.metadata.has_more
          mediaData.totalItems = data.total_urls

          if (mediaData.currentPage === 0) {
            mediaData.allMediaItems = [...data.timeline]
          } else {
            mediaData.allMediaItems = [...mediaData.allMediaItems, ...data.timeline]
          }

          const mediaTypeLabel = getMediaTypeLabel(settings.mediaType)

          if (settings.batchEnabled) {
            infoContainer.innerHTML = `
                        <div style="margin-bottom: 8px;"><strong>Account:</strong> ${data.account_info.name}</div>
                        <div style="margin-bottom: 8px;"><strong>${mediaTypeLabel} Found:</strong> ${formatNumber(data.total_urls)}</div>
                        <div style="margin-top: 8px;"><strong>Batch:</strong> ${mediaData.currentPage + 1}</div>
                        <div style="margin-top: 8px;"><strong>Total Items:</strong> ${formatNumber(mediaData.allMediaItems.length)}</div>
                    `
          } else {
            const currentPart = Math.floor(mediaData.allMediaItems.length / 500) + 1

            infoContainer.innerHTML = `
                        <div style="margin-bottom: 8px;"><strong>Account:</strong> ${data.account_info.name}</div>
                        <div style="margin-bottom: 8px;"><strong>${mediaTypeLabel} Found:</strong> ${formatNumber(data.total_urls)}</div>
                        <div style="margin-top: 8px;"><strong>Part:</strong> ${currentPart}</div>
                        <div style="margin-top: 8px;"><strong>Total Items:</strong> ${formatNumber(mediaData.allMediaItems.length)}</div>
                    `
          }

          infoContainer.style.display = "block"

          if (settings.batchEnabled) {
            buttonContainer.innerHTML = ""
            buttonContainer.appendChild(downloadCurrentButton)
            buttonContainer.appendChild(downloadAllButton)
            buttonContainer.style.display = "flex"
          } else {
            buttonContainer.innerHTML = ""
            buttonContainer.appendChild(downloadButton)
            buttonContainer.style.display = "block"
          }

          if (settings.batchEnabled && mediaData.hasMore) {
            batchButtonsContainer.style.display = "flex"
            nextBatchButton.style.display = "block"
            autoBatchButton.style.display = "block"
          }

          downloadCurrentButton.onclick = () => downloadMedia(false)
          downloadAllButton.onclick = () => downloadMedia(true)

          fetchButton.disabled = false
          const currentMediaTypeLabel = getMediaTypeLabel(settings.mediaType).toLowerCase()
          fetchButton.textContent =
            settings.mediaType === "all"
              ? "Fetch Media"
              : `Fetch ${currentMediaTypeLabel === "gif" ? "GIF" : currentMediaTypeLabel.charAt(0).toUpperCase() + currentMediaTypeLabel.slice(1)}`
        } else {
          infoContainer.innerHTML = '<div style="color: #ef4444;">No media found or invalid token</div>'
          infoContainer.style.display = "block"
          fetchButton.disabled = false
          const currentMediaTypeLabel = getMediaTypeLabel(settings.mediaType).toLowerCase()
          fetchButton.textContent =
            settings.mediaType === "all"
              ? "Fetch Media"
              : `Fetch ${currentMediaTypeLabel === "gif" ? "GIF" : currentMediaTypeLabel.charAt(0).toUpperCase() + currentMediaTypeLabel.slice(1)}`
        }
      } catch (error) {
        infoContainer.innerHTML = `<div style="color: #ef4444;">Error: ${error.message}</div>`
        infoContainer.style.display = "block"
        fetchButton.disabled = false
        const currentMediaTypeLabel = getMediaTypeLabel(settings.mediaType).toLowerCase()
        fetchButton.textContent =
          settings.mediaType === "all"
            ? "Fetch Media"
            : `Fetch ${currentMediaTypeLabel === "gif" ? "GIF" : currentMediaTypeLabel.charAt(0).toUpperCase() + currentMediaTypeLabel.slice(1)}`
      }
    })

    nextBatchButton.addEventListener("click", () => {
      mediaData.currentPage++
      fetchButton.click()
    })

    autoBatchButton.addEventListener("click", () => {
      if (mediaData.autoBatchRunning) {
        return
      }

      mediaData.autoBatchRunning = true
      autoBatchButton.style.display = "none"
      stopBatchButton.style.display = "block"
      nextBatchButton.style.display = "none"

      startAutoBatch()
    })

    stopBatchButton.addEventListener("click", () => {
      createConfirmDialog("Stop auto batch download?", () => {
        mediaData.autoBatchRunning = false
        stopBatchButton.style.display = "none"
        autoBatchButton.style.display = "block"
        if (mediaData.hasMore) {
          nextBatchButton.style.display = "block"
        }
      })
    })

    async function startAutoBatch() {
      while (mediaData.hasMore && mediaData.autoBatchRunning) {
        mediaData.currentPage++

        downloadCurrentButton.disabled = true
        downloadAllButton.disabled = true

        await new Promise((resolve) => {
          const settings = getSettings()
          const cacheKey = `${settings.timelineType}_${settings.mediaType}_${username}_${mediaData.currentPage}_${settings.batchSize}`
          const data = cacheManager.get(cacheKey)

          if (data) {
            processNextBatch(data)
            resolve()
          } else {
            let url
            if (settings.batchEnabled) {
              url = `https://gallerydl.vercel.app/metadata/${settings.timelineType}/${settings.batchSize}/${mediaData.currentPage}/${settings.mediaType}/${username}/${settings.authToken}`
            } else {
              url = `https://gallerydl.vercel.app/metadata/${settings.timelineType}/${settings.mediaType}/${username}/${settings.authToken}`
            }

            fetchData(url)
              .then((data) => {
                cacheManager.set(cacheKey, data)
                processNextBatch(data)
                resolve()
              })
              .catch((error) => {
                mediaData.autoBatchRunning = false
                stopBatchButton.style.display = "none"
                autoBatchButton.style.display = "block"

                downloadCurrentButton.disabled = false
                downloadAllButton.disabled = false

                if (mediaData.hasMore) {
                  nextBatchButton.style.display = "block"
                }

                resolve()
              })
          }
        })

        await new Promise((resolve) => setTimeout(resolve, 1000))
      }

      if (mediaData.autoBatchRunning) {
        mediaData.autoBatchRunning = false
        stopBatchButton.style.display = "none"
        autoBatchButton.style.display = "none"
      }

      downloadCurrentButton.disabled = false
      downloadAllButton.disabled = false
    }

    function processNextBatch(data) {
      if (data.timeline && data.timeline.length > 0) {
        mediaData.mediaItems = data.timeline
        mediaData.hasMore = data.metadata.has_more

        mediaData.allMediaItems = [...mediaData.allMediaItems, ...data.timeline]

        const settings = getSettings()
        const mediaTypeLabel = getMediaTypeLabel(settings.mediaType)

        infoContainer.innerHTML = `
                <div style="margin-bottom: 8px;"><strong>Account:</strong> ${data.account_info.name}</div>
                <div style="margin-bottom: 8px;"><strong>${mediaTypeLabel} Found:</strong> ${formatNumber(data.total_urls)}</div>
                <div style="margin-top: 8px;"><strong>Batch:</strong> ${mediaData.currentPage + 1}</div>
                <div style="margin-top: 8px;"><strong>Total Items:</strong> ${formatNumber(mediaData.allMediaItems.length)}</div>
            `

        if (!mediaData.hasMore) {
          nextBatchButton.style.display = "none"
          autoBatchButton.style.display = "none"
          stopBatchButton.style.display = "none"
        }
      } else {
        mediaData.hasMore = false
        nextBatchButton.style.display = "none"
        autoBatchButton.style.display = "none"
        stopBatchButton.style.display = "none"
      }
    }

    function chunkMediaItems(items) {
      const chunks = []
      for (let i = 0; i < items.length; i += 500) {
        chunks.push(items.slice(i, i + 500))
      }
      return chunks
    }

    async function downloadMedia(downloadAll) {
      if (mediaData.downloading) return

      mediaData.downloading = true

      const settings = getSettings()
      const timestamp = getCurrentTimestamp()

      let itemsToDownload
      if (downloadAll) {
        itemsToDownload = mediaData.allMediaItems
      } else {
        itemsToDownload = mediaData.mediaItems
      }

      mediaData.totalToDownload = itemsToDownload.length
      mediaData.totalDownloaded = 0

      progressText.textContent = `Downloading 0/${formatNumber(mediaData.totalToDownload)}`
      progressFill.style.width = "0%"
      progressContainer.style.display = "block"

      fetchButton.disabled = true
      if (settings.batchEnabled) {
        downloadCurrentButton.disabled = true
        downloadAllButton.disabled = true
      } else {
        downloadButton.disabled = true
      }
      nextBatchButton.disabled = true
      autoBatchButton.disabled = true
      stopBatchButton.disabled = true

      const chunks = chunkMediaItems(itemsToDownload)

      for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
        const chunk = chunks[chunkIndex]

        if (chunk.length === 1 && chunks.length === 1) {
          try {
            const item = chunk[0]
            const formattedDate = formatDate(item.date)
            const baseFilename = `${username}_${formattedDate}_${item.tweet_id}`
            const fileExtension = item.type === "photo" ? "jpg" : "mp4"
            const filename = `${baseFilename}.${fileExtension}`

            const blob = await fetchBinary(item.url)

            const downloadLink = document.createElement("a")
            downloadLink.href = URL.createObjectURL(blob)
            downloadLink.download = filename
            document.body.appendChild(downloadLink)
            downloadLink.click()
            document.body.removeChild(downloadLink)

            mediaData.totalDownloaded = 1
            progressText.textContent = `Downloading 1/1`
            progressFill.style.width = "100%"

            continue
          } catch (error) {}
        }

        const zip = new JSZip()

        const hasImages = chunk.some((item) => item.type === "photo")
        const hasVideos = chunk.some((item) => item.type === "video")
        const hasGifs = chunk.some((item) => item.type === "gif")

        let imageFolder, videoFolder, gifFolder
        if (settings.mediaType === "all") {
          if (hasImages) imageFolder = zip.folder("image")
          if (hasVideos) videoFolder = zip.folder("video")
          if (hasGifs) gifFolder = zip.folder("gif")
        }

        const filenameMap = {}

        const concurrentBatches = []
        for (let i = 0; i < chunk.length; i += settings.concurrentDownloads) {
          concurrentBatches.push(chunk.slice(i, i + settings.concurrentDownloads))
        }

        for (const batch of concurrentBatches) {
          const downloadPromises = batch.map(async (item) => {
            try {
              const formattedDate = formatDate(item.date)

              let baseFilename = `${username}_${formattedDate}_${item.tweet_id}`

              if (filenameMap[baseFilename] !== undefined) {
                filenameMap[baseFilename]++
                baseFilename = `${baseFilename}_${baseFilename}_${String(filenameMap[baseFilename]).padStart(2, "0")}`
              } else {
                filenameMap[baseFilename] = 0
              }

              const fileExtension = item.type === "photo" ? "jpg" : "mp4"

              const filename = `${baseFilename}.${fileExtension}`

              const blob = await fetchBinary(item.url)

              if (settings.mediaType === "all") {
                if (item.type === "photo") {
                  imageFolder.file(filename, blob)
                } else if (item.type === "video") {
                  videoFolder.file(filename, blob)
                } else if (item.type === "gif") {
                  gifFolder.file(filename, blob)
                }
              } else {
                zip.file(filename, blob)
              }

              return true
            } catch (error) {
              return false
            }
          })

          await Promise.all(downloadPromises)

          mediaData.totalDownloaded += batch.length
          progressText.textContent = `Downloading ${formatNumber(mediaData.totalDownloaded)}/${formatNumber(mediaData.totalToDownload)}`
          progressFill.style.width = `${(mediaData.totalDownloaded / mediaData.totalToDownload) * 100}%`
        }

        progressText.textContent = `Creating ZIP file ${chunkIndex + 1}/${chunks.length}...`

        try {
          const zipBlob = await zip.generateAsync({ type: "blob" })

          let zipFilename
          if (chunks.length === 1 && chunk.length < 500) {
            zipFilename = `${username}_${timestamp}.zip`
          } else if (settings.batchEnabled && !downloadAll) {
            zipFilename = `${username}_${timestamp}_part_${String(mediaData.currentPage + 1).padStart(2, "0")}.zip`
          } else {
            zipFilename = `${username}_${timestamp}_part_${String(chunkIndex + 1).padStart(2, "0")}.zip`
          }

          const downloadLink = document.createElement("a")
          downloadLink.href = URL.createObjectURL(zipBlob)
          downloadLink.download = zipFilename
          document.body.appendChild(downloadLink)
          downloadLink.click()
          document.body.removeChild(downloadLink)
        } catch (error) {
          progressText.textContent = `Error creating ZIP ${chunkIndex + 1}: ${error.message}`
        }
      }

      progressText.textContent = "Download complete!"
      progressFill.style.width = "100%"

      setTimeout(() => {
        fetchButton.disabled = false
        if (settings.batchEnabled) {
          downloadCurrentButton.disabled = false
          downloadAllButton.disabled = false
        } else {
          downloadButton.disabled = false
        }
        nextBatchButton.disabled = false
        autoBatchButton.disabled = false
        stopBatchButton.disabled = false

        mediaData.downloading = false
      }, 2000)
    }

    document.body.appendChild(modal)
  }

  function extractUsername() {
    const pathParts = window.location.pathname.split("/").filter((part) => part)
    if (pathParts.length > 0) {
      return pathParts[0]
    }
    return null
  }

  function insertDownloadIcon() {
    const usernameDivs = document.querySelectorAll('[data-testid="UserName"]')

    usernameDivs.forEach((usernameDiv) => {
      if (!usernameDiv.querySelector(".download-icon")) {
        const username = extractUsername()
        if (!username) return

        const verifiedButton = usernameDiv
          .querySelector('[aria-label*="verified"], [aria-label*="Verified"]')
          ?.closest("button")

        const targetElement = verifiedButton
          ? verifiedButton.parentElement
          : usernameDiv.querySelector(".css-1jxf684")?.closest("span")

        if (targetElement) {
          const downloadIcon = createDownloadIcon()

          const iconDiv = document.createElement("div")
          iconDiv.className = "download-icon css-175oi2r r-1awozwy r-xoduu5"
          iconDiv.style.cssText = `
                    display: inline-flex;
                    align-items: center;
                    margin-left: 6px;
                    margin-right: 6px;
                    gap: 6px;
                    padding: 0 3px;
                    transition: transform 0.2s, color 0.2s;
                `
          iconDiv.appendChild(downloadIcon)

          iconDiv.addEventListener("mouseenter", () => {
            iconDiv.style.transform = "scale(1.1)"
            iconDiv.style.color = "#0ea5e9"
          })

          iconDiv.addEventListener("mouseleave", () => {
            iconDiv.style.transform = "scale(1)"
            iconDiv.style.color = ""
          })

          iconDiv.addEventListener("click", (e) => {
            e.stopPropagation()
            createModal(username)
          })

          const wrapperDiv = document.createElement("div")
          wrapperDiv.style.cssText = `
                    display: inline-flex;
                    align-items: center;
                    gap: 4px;
                `
          wrapperDiv.appendChild(iconDiv)
          targetElement.parentNode.insertBefore(wrapperDiv, targetElement.nextSibling)
        }
      }
    })
  }

  insertDownloadIcon()

  function checkForUserNameElement() {
    const usernameDivs = document.querySelectorAll('[data-testid="UserName"]')
    if (usernameDivs.length > 0) {
      insertDownloadIcon()
    }
  }

  setInterval(checkForUserNameElement, 100)

  let lastUrl = location.href
  let lastUsername = extractUsername()

  function checkForChanges() {
    const currentUrl = location.href
    const currentUsername = extractUsername()

    if (currentUrl !== lastUrl || currentUsername !== lastUsername) {
      lastUrl = currentUrl
      lastUsername = currentUsername

      document.querySelectorAll(".download-icon").forEach((icon) => {
        const wrapper = icon.closest("div[style*='display: inline-flex']")
        if (wrapper) {
          wrapper.remove()
        }
      })

      setTimeout(insertDownloadIcon, 50)
    }
  }

  const observer = new MutationObserver(() => {
    checkForChanges()
    checkForUserNameElement()
  })

  observer.observe(document.body, {
    childList: true,
    subtree: true,
    attributes: true,
    characterData: true,
  })

  setInterval(checkForChanges, 300)

  const originalPushState = history.pushState
  const originalReplaceState = history.replaceState

  history.pushState = function () {
    originalPushState.apply(this, arguments)
    checkForChanges()
    insertDownloadIcon()
  }

  history.replaceState = function () {
    originalReplaceState.apply(this, arguments)
    checkForChanges()
    insertDownloadIcon()
  }

  window.addEventListener("popstate", () => {
    checkForChanges()
    insertDownloadIcon()
  })
})()

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址