Telegram 受限圖片影片下載器

從禁止下載的 Telegram 頻道中下載圖片、影片及語音訊息

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Telegram Media Downloader
// @name:en      Telegram Media Downloader
// @name:zh-CN   Telegram 受限图片视频下载器
// @name:zh-TW   Telegram 受限圖片影片下載器
// @name:ru      Telegram: загрузчик медиафайлов
// @version      1.208 // Увеличена версия из-за изменений
// @namespace    https://github.com/Neet-Nestor/Telegram-Media-Downloader
// @description  Download images, GIFs, videos, and voice messages on the Telegram webapp from private channels that disable downloading and restrict saving content
// @description:en  Download images, GIFs, videos, and voice messages on the Telegram webapp from private channels that disable downloading and restrict saving content
// @description:ru Загружайте изображения, GIF-файлы, видео и голосовые сообщения в веб-приложении Telegram из частных каналов, которые отключили загрузку и ограничили сохранение контента
// @description:zh-CN 从禁止下载的Telegram频道中下载图片、视频及语音消息
// @description:zh-TW 從禁止下載的 Telegram 頻道中下載圖片、影片及語音訊息
// @author       Nestor Qin
// @license      GNU GPLv3
// @website      https://github.com/Neet-Nestor/Telegram-Media-Downloader
// @match        https://web.telegram.org/*
// @match        https://webk.telegram.org/*
// @match        https://webz.telegram.org/*
// @icon         https://img.icons8.com/color/452/telegram-app--v5.png
// @grant        unsafeWindow // Добавлено явно для использования unsafeWindow
// ==/UserScript==

(function () {
  'use strict'; // Всегда полезно для чистого кода

  const logger = {
    info: (message, fileName = '') => { // fileName по умолчанию пустая строка
      console.log(
        `[Tel Download] ${fileName ? `${fileName}: ` : ""}${message}`
      );
    },
    error: (message, fileName = '') => { // fileName по умолчанию пустая строка
      console.error(
        `[Tel Download] ${fileName ? `${fileName}: ` : ""}${message}`
      );
    },
  };

  // Unicode values for icons (used in /k/ app)
  // https://github.com/morethanwords/tweb/blob/master/src/icons.ts
  const DOWNLOAD_ICON = "\uE95B";
  const FORWARD_ICON = "\uE976";
  const contentRangeRegex = /^bytes (\d+)-(\d+)\/(\d+)$/;
  const REFRESH_DELAY = 500; // Интервал для setInterval

  const hashCode = (s) => {
    let h = 0,
      l = s.length,
      i = 0;
    if (l > 0) {
      while (i < l) {
        h = ((h << 5) - h + s.charCodeAt(i++)) | 0;
      }
    }
    return h >>> 0;
  };

  // --- Функции для управления прогресс-баром ---
  const createProgressBar = (videoId, fileName) => {
    const isDarkMode =
      document.documentElement.classList.contains("night") ||
      document.documentElement.classList.contains("theme-dark");
    let container = document.getElementById("tel-downloader-progress-bar-container");
    if (!container) { // Создаем контейнер, если его нет
        setupProgressBar();
        container = document.getElementById("tel-downloader-progress-bar-container");
    }

    const innerContainer = document.createElement("div");
    innerContainer.id = "tel-downloader-progress-" + videoId;
    innerContainer.style.width = "20rem";
    innerContainer.style.marginTop = "0.4rem";
    innerContainer.style.padding = "0.6rem";
    innerContainer.style.backgroundColor = isDarkMode
      ? "rgba(0,0,0,0.3)"
      : "rgba(0,0,0,0.6)";
    innerContainer.style.borderRadius = "0.5rem"; // Добавим немного скругления

    const flexContainer = document.createElement("div");
    flexContainer.style.display = "flex";
    flexContainer.style.justifyContent = "space-between";
    flexContainer.style.alignItems = "center"; // Выравнивание по центру

    const title = document.createElement("p");
    title.className = "filename";
    title.style.margin = "0 0 0.5rem 0"; // Отступ снизу
    title.style.color = "white";
    title.style.whiteSpace = "nowrap"; // Предотвратить перенос
    title.style.overflow = "hidden"; // Скрыть переполнение
    title.style.textOverflow = "ellipsis"; // Добавить многоточие
    title.innerText = fileName;

    const closeButton = document.createElement("div");
    closeButton.style.cursor = "pointer";
    closeButton.style.fontSize = "1.2rem";
    closeButton.style.color = isDarkMode ? "#8a8a8a" : "white";
    closeButton.innerHTML = "&times;";
    closeButton.onclick = function () {
      innerContainer.remove(); // Используем .remove() вместо parent.removeChild
    };

    const progressBar = document.createElement("div");
    progressBar.className = "progress";
    progressBar.style.backgroundColor = "#e2e2e2";
    progressBar.style.position = "relative";
    progressBar.style.width = "100%";
    progressBar.style.height = "1.6rem";
    progressBar.style.borderRadius = "2rem";
    progressBar.style.overflow = "hidden";

    const counter = document.createElement("p");
    counter.style.position = "absolute";
    counter.style.zIndex = 5;
    counter.style.left = "50%";
    counter.style.top = "50%";
    counter.style.transform = "translate(-50%, -50%)";
    counter.style.margin = 0;
    counter.style.color = "black";
    counter.style.fontWeight = "bold"; // Сделать текст прогресса жирным

    const progress = document.createElement("div");
    progress.style.position = "absolute";
    progress.style.height = "100%";
    progress.style.width = "0%";
    progress.style.backgroundColor = "#6093B5";
    progress.style.transition = "width 0.2s ease-out"; // Плавное изменение прогресса

    progressBar.appendChild(counter);
    progressBar.appendChild(progress);
    flexContainer.appendChild(title);
    flexContainer.appendChild(closeButton);
    innerContainer.appendChild(flexContainer);
    innerContainer.appendChild(progressBar);
    container.appendChild(innerContainer);

    updateProgress(videoId, fileName, 0); // Инициализируем прогресс на 0%
  };

  const updateProgress = (videoId, fileName, progress) => {
    const innerContainer = document.getElementById(
      "tel-downloader-progress-" + videoId
    );
    if (!innerContainer) return; // Проверка на существование элемента

    innerContainer.querySelector("p.filename").innerText = fileName;
    const progressBar = innerContainer.querySelector("div.progress");
    if (!progressBar) return; // Проверка на существование элемента

    progressBar.querySelector("p").innerText = `${progress}%`;
    progressBar.querySelector("div").style.width = `${progress}%`;
  };

  const completeProgress = (videoId) => {
    const innerContainer = document.getElementById("tel-downloader-progress-" + videoId);
    if (!innerContainer) return;

    const progressBar = innerContainer.querySelector("div.progress");
    if (!progressBar) return;

    progressBar.querySelector("p").innerText = "Completed";
    progressBar.querySelector("div").style.backgroundColor = "#B6C649";
    progressBar.querySelector("div").style.width = "100%";
    setTimeout(() => {
        innerContainer.remove(); // Удалить после завершения через 3 секунды
    }, 3000);
  };

  const abortProgress = (videoId) => { // Переименована для соответствия стилю
    const innerContainer = document.getElementById("tel-downloader-progress-" + videoId);
    if (!innerContainer) return;

    const progressBar = innerContainer.querySelector("div.progress");
    if (!progressBar) return;

    progressBar.querySelector("p").innerText = "Aborted";
    progressBar.querySelector("div").style.backgroundColor = "#D16666";
    progressBar.querySelector("div").style.width = "100%";
    setTimeout(() => {
        innerContainer.remove(); // Удалить после ошибки через 3 секунды
    }, 3000);
  };
  // --- Конец функций для управления прогресс-баром ---

  // --- Функции загрузки ---
  const tel_download_video = (url) => {
    let _blobs = [];
    let _next_offset = 0;
    let _total_size = null;
    let _file_extension = "mp4";

    const videoId =
      (Math.random() + 1).toString(36).substring(2, 10) +
      "_" +
      Date.now().toString();
    let fileName = `${hashCode(url).toString(36)}.${_file_extension}`;

    // Improved filename extraction
    try {
      const urlObj = new URL(url);
      const lastSegment = urlObj.pathname.split('/').pop();
      if (lastSegment) {
          try {
              const decodedLastSegment = decodeURIComponent(lastSegment);
              const metadata = JSON.parse(decodedLastSegment);
              if (metadata.fileName) {
                  fileName = metadata.fileName;
                  _file_extension = fileName.split('.').pop() || _file_extension;
              }
          } catch (e) {
              // Not a JSON string, try to extract extension from last segment
              const parts = lastSegment.split('.');
              if (parts.length > 1) {
                  const potentialExt = parts.pop();
                  if (potentialExt.length <= 4 && /^[a-zA-Z0-9]+$/.test(potentialExt)) { // Basic check for valid extension
                      _file_extension = potentialExt;
                      fileName = `${hashCode(url).toString(36)}.${_file_extension}`; // Re-form filename with new extension
                  }
              }
          }
      }
    } catch (e) {
      logger.error(`Error parsing URL or metadata: ${e.message}`, url);
    }
    logger.info(`URL: ${url}`, fileName);

    createProgressBar(videoId, fileName); // Создаем прогресс-бар здесь

    const fetchNextPart = (writable) => { // writable вместо _writable
      fetch(url, {
        method: "GET",
        headers: {
          Range: `bytes=${_next_offset}-`,
          "User-Agent":
            "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/117.0",
        },
      })
        .then((res) => {
          if (![200, 206].includes(res.status)) {
            throw new Error(`Non 200/206 response was received: ${res.status}`);
          }
          const mime = res.headers.get("Content-Type")?.split(";")?.[0]; // Optional chaining
          if (!mime || !mime.startsWith("video/")) {
            throw new Error(`Received non-video response with MIME type ${mime || 'N/A'}`);
          }
          _file_extension = mime.split("/")[1];
          // Обновляем расширение файла в имени, если оно изменилось
          if (!fileName.endsWith(`.${_file_extension}`)) {
            const nameWithoutExt = fileName.includes('.') ? fileName.substring(0, fileName.lastIndexOf('.')) : fileName;
            fileName = `${nameWithoutExt}.${_file_extension}`;
          }

          const contentRangeHeader = res.headers.get("Content-Range");
          if (!contentRangeHeader) {
              // If Content-Range is missing, assume it's a full download (status 200)
              if (res.status === 200) {
                  _total_size = parseInt(res.headers.get("Content-Length"));
                  _next_offset = _total_size; // Mark as complete
                  logger.info(`Full download detected, total size: ${_total_size}`, fileName);
                  return res.blob();
              } else {
                  throw new Error("Content-Range header missing for partial content.");
              }
          }

          const match = contentRangeHeader.match(contentRangeRegex);
          if (!match) {
              throw new Error("Invalid Content-Range header format.");
          }

          const startOffset = parseInt(match[1]);
          const endOffset = parseInt(match[2]);
          const totalSize = parseInt(match[3]);

          if (startOffset !== _next_offset) {
            logger.error(`Gap detected between responses. Last offset: ${_next_offset}, New start offset: ${startOffset}`, fileName);
            throw new Error("Gap detected between responses.");
          }
          if (_total_size && totalSize !== _total_size) { // self-comparison is always false for non-NaN
            logger.error("Total size differs", fileName); // Probably meant totalSize !== _total_size
            throw new Error("Total size differs");
          }

          _next_offset = endOffset + 1;
          _total_size = totalSize;

          logger.info(
            `Get response: ${res.headers.get("Content-Length")} bytes data from ${res.headers.get("Content-Range")}`,
            fileName
          );
          const progress = ((_next_offset * 100) / _total_size).toFixed(0);
          logger.info(`Progress: ${progress}%`, fileName);
          updateProgress(videoId, fileName, progress);
          return res.blob();
        })
        .then((resBlob) => {
          if (writable) { // Проверка на null
            return writable.write(resBlob);
          } else {
            _blobs.push(resBlob);
            return Promise.resolve(); // Возвращаем Promise для цепочки
          }
        })
        .then(() => {
          if (_total_size === null) {
              throw new Error("_total_size is NULL after first fetch (should not happen for valid content-range)");
          }

          if (_next_offset < _total_size) {
            fetchNextPart(writable);
          } else {
            if (writable) {
              writable.close().then(() => {
                logger.info("Download finished (FileSystemAccess API)", fileName);
                completeProgress(videoId);
              }).catch(err => {
                  logger.error(`Error closing writable: ${err.message}`, fileName);
                  abortProgress(videoId);
              });
            } else {
              saveVideoBlob(); // Переименована функция сохранения
            }
          }
        })
        .catch((reason) => {
          logger.error(`Download failed: ${reason.message || reason}`, fileName);
          abortProgress(videoId);
        });
    };

    const saveVideoBlob = () => { // Переименована для ясности
      logger.info("Finish downloading blobs. Concatenating blobs and downloading...", fileName);

      const blob = new Blob(_blobs, { type: `video/${_file_extension}` }); // Использование определенного расширения
      const blobUrl = window.URL.createObjectURL(blob);

      logger.info(`Final blob size: ${blob.size} bytes`, fileName);

      const a = document.createElement("a");
      document.body.appendChild(a);
      a.href = blobUrl;
      a.download = fileName;
      a.click();
      document.body.removeChild(a);
      window.URL.revokeObjectURL(blobUrl);

      logger.info("Download triggered (Blob URL)", fileName);
      completeProgress(videoId); // Завершаем прогресс-бар
    };

    const supportsFileSystemAccess =
      "showSaveFilePicker" in unsafeWindow &&
      (() => {
        try {
          return unsafeWindow.self === unsafeWindow.top;
        } catch {
          return false;
        }
      })();

    if (supportsFileSystemAccess) {
      unsafeWindow
        .showSaveFilePicker({
          suggestedName: fileName,
          types: [{
            description: 'Video Files',
            accept: {
              'video/*': ['.mp4', '.webm', '.ogg', '.mov']
            }
          }]
        })
        .then((handle) => {
          handle
            .createWritable()
            .then((writable) => {
              fetchNextPart(writable);
            })
            .catch((err) => {
              logger.error(`Error creating writable: ${err.name} - ${err.message}`, fileName);
              abortProgress(videoId);
            });
        })
        .catch((err) => {
          if (err.name !== "AbortError") {
            logger.error(`Error showing save file picker: ${err.name} - ${err.message}`, fileName);
            abortProgress(videoId);
          } else {
            logger.info("File save dialog aborted by user.", fileName);
            abortProgress(videoId); // Отмечаем как отмененное
          }
        });
    } else {
      fetchNextPart(null);
    }
  };

  const tel_download_audio = (url) => {
    let _blobs = [];
    let _next_offset = 0;
    let _total_size = null;
    let _file_extension = "ogg"; // Предполагаемое расширение
    const audioId =
      (Math.random() + 1).toString(36).substring(2, 10) +
      "_" +
      Date.now().toString();
    let fileName = `${hashCode(url).toString(36)}.${_file_extension}`;

    // Можно добавить логику для извлечения имени файла из URL, если оно есть
    try {
        const urlObj = new URL(url);
        const lastSegment = urlObj.pathname.split('/').pop();
        if (lastSegment) {
            const parts = lastSegment.split('.');
            if (parts.length > 1) {
                const potentialExt = parts.pop();
                if (potentialExt.length <= 4 && /^[a-zA-Z0-9]+$/.test(potentialExt)) {
                    _file_extension = potentialExt;
                    fileName = `${hashCode(url).toString(36)}.${_file_extension}`;
                }
            }
        }
    } catch (e) {
        logger.error(`Error parsing audio URL: ${e.message}`, url);
    }

    createProgressBar(audioId, fileName); // Создаем прогресс-бар для аудио

    const fetchNextPart = (writable) => {
      fetch(url, {
        method: "GET",
        headers: {
          Range: `bytes=${_next_offset}-`,
        },
      })
        .then((res) => {
          if (![200, 206].includes(res.status)) {
            throw new Error(`Non 200/206 response was received: ${res.status}`);
          }

          const mime = res.headers.get("Content-Type")?.split(";")?.[0];
          if (!mime || !mime.startsWith("audio/")) {
            throw new Error(`Received non-audio response with MIME type ${mime || 'N/A'}`);
          }
          _file_extension = mime.split("/")[1];
          if (!fileName.endsWith(`.${_file_extension}`)) {
            const nameWithoutExt = fileName.includes('.') ? fileName.substring(0, fileName.lastIndexOf('.')) : fileName;
            fileName = `${nameWithoutExt}.${_file_extension}`;
          }

          const contentRangeHeader = res.headers.get("Content-Range");
          if (!contentRangeHeader) {
              if (res.status === 200) {
                  _total_size = parseInt(res.headers.get("Content-Length"));
                  _next_offset = _total_size;
                  logger.info(`Full download detected for audio, total size: ${_total_size}`, fileName);
                  return res.blob();
              } else {
                  throw new Error("Content-Range header missing for partial audio content.");
              }
          }

          const match = contentRangeHeader.match(contentRangeRegex);
          if (!match) {
              throw new Error("Invalid Content-Range header format for audio.");
          }

          const startOffset = parseInt(match[1]);
          const endOffset = parseInt(match[2]);
          const totalSize = parseInt(match[3]);

          if (startOffset !== _next_offset) {
            logger.error(`Gap detected between audio responses. Last offset: ${_next_offset}, New start offset: ${startOffset}`);
            throw new Error("Gap detected between audio responses.");
          }
          if (_total_size && totalSize !== _total_size) {
            logger.error("Total audio size differs");
            throw new Error("Total audio size differs");
          }

          _next_offset = endOffset + 1;
          _total_size = totalSize;

          logger.info(
            `Get audio response: ${res.headers.get("Content-Length")} bytes data from ${res.headers.get("Content-Range")}`,
            fileName
          );
          const progress = ((_next_offset * 100) / _total_size).toFixed(0);
          updateProgress(audioId, fileName, progress);
          return res.blob();
        })
        .then((resBlob) => {
          if (writable) {
            return writable.write(resBlob);
          } else {
            _blobs.push(resBlob);
            return Promise.resolve();
          }
        })
        .then(() => {
          if (_total_size === null) {
            throw new Error("_total_size is NULL for audio after first fetch");
          }

          if (_next_offset < _total_size) {
            fetchNextPart(writable);
          } else {
            if (writable) {
              writable.close().then(() => {
                logger.info("Audio download finished (FileSystemAccess API)", fileName);
                completeProgress(audioId);
              }).catch(err => {
                  logger.error(`Error closing audio writable: ${err.message}`, fileName);
                  abortProgress(audioId);
              });
            } else {
              saveAudioBlob();
            }
          }
        })
        .catch((reason) => {
          logger.error(`Audio download failed: ${reason.message || reason}`, fileName);
          abortProgress(audioId);
        });
    };

    const saveAudioBlob = () => {
      logger.info(
        "Finish downloading audio blobs. Concatenating blobs and downloading...",
        fileName
      );

      const blob = new Blob(_blobs, { type: `audio/${_file_extension}` }); // Использование определенного расширения
      const blobUrl = window.URL.createObjectURL(blob);

      logger.info(`Final audio blob size in bytes: ${blob.size}`, fileName);

      // Нет необходимости в blob = 0; GC позаботится об этом
      // blob = 0;

      const a = document.createElement("a");
      document.body.appendChild(a);
      a.href = blobUrl;
      a.download = fileName;
      a.click();
      document.body.removeChild(a);
      window.URL.revokeObjectURL(blobUrl);

      logger.info("Audio download triggered", fileName);
      completeProgress(audioId);
    };

    const supportsFileSystemAccess =
      "showSaveFilePicker" in unsafeWindow &&
      (() => {
        try {
          return unsafeWindow.self === unsafeWindow.top;
        } catch {
          return false;
        }
      })();

    if (supportsFileSystemAccess) {
      unsafeWindow
        .showSaveFilePicker({
          suggestedName: fileName,
          types: [{
            description: 'Audio Files',
            accept: {
              'audio/*': ['.ogg', '.mp3', '.wav', '.flac']
            }
          }]
        })
        .then((handle) => {
          handle
            .createWritable()
            .then((writable) => {
              fetchNextPart(writable);
            })
            .catch((err) => {
              logger.error(`Error creating audio writable: ${err.name} - ${err.message}`, fileName);
              abortProgress(audioId);
            });
        })
        .catch((err) => {
          if (err.name !== "AbortError") {
            logger.error(`Error showing audio save file picker: ${err.name} - ${err.message}`, fileName);
            abortProgress(audioId);
          } else {
            logger.info("Audio file save dialog aborted by user.", fileName);
            abortProgress(audioId);
          }
        });
    } else {
      fetchNextPart(null);
    }
  };

  const tel_download_image = (imageUrl) => {
    // Попытаемся определить расширение из URL, если нет - по умолчанию 'jpeg'
    let fileName = (Math.random() + 1).toString(36).substring(2, 10);
    try {
        const urlObj = new URL(imageUrl);
        const pathSegments = urlObj.pathname.split('/');
        const lastSegment = pathSegments[pathSegments.length - 1];
        if (lastSegment && lastSegment.includes('.')) {
            const parts = lastSegment.split('.');
            const potentialExt = parts[parts.length - 1];
            if (potentialExt.length <= 5 && /^[a-zA-Z0-9]+$/.test(potentialExt)) { // Простая проверка на валидность
                fileName += `.${potentialExt}`;
            } else {
                fileName += ".jpeg"; // Fallback
            }
        } else {
            fileName += ".jpeg"; // Fallback
        }
    } catch (e) {
        logger.error(`Error processing image URL for filename: ${e.message}`, imageUrl);
        fileName += ".jpeg"; // Fallback в случае ошибки URL
    }

    const imageId = (Math.random() + 1).toString(36).substring(2, 10) + "_" + Date.now().toString();
    createProgressBar(imageId, fileName); // Создаем прогресс-бар для изображений

    fetch(imageUrl)
        .then(response => {
            if (!response.ok) {
                throw new Error(`HTTP error! status: ${response.status}`);
            }
            const contentType = response.headers.get("Content-Type");
            if (contentType && !fileName.includes('.')) { // Если расширение не определено из URL
                const mimeTypeParts = contentType.split('/');
                if (mimeTypeParts.length > 1) {
                    const ext = mimeTypeParts[1];
                    if (ext === "jpeg") fileName += ".jpg"; // Общепринятое расширение
                    else fileName += `.${ext}`;
                }
            }
            return response.blob();
        })
        .then(blob => {
            const blobUrl = window.URL.createObjectURL(blob);
            const a = document.createElement("a");
            document.body.appendChild(a);
            a.href = blobUrl;
            a.download = fileName;
            a.click();
            document.body.removeChild(a);
            window.URL.revokeObjectURL(blobUrl);
            logger.info("Image download triggered", fileName);
            completeProgress(imageId);
        })
        .catch(error => {
            logger.error(`Image download failed: ${error.message}`, fileName);
            abortProgress(imageId);
        });
  };
  // --- Конец функций загрузки ---


  logger.info("Initialized script.");

  // --- Функции для добавления кнопок ---
  const addDownloadButton = (container, type, url, prepend = false) => {
    // Проверяем, существует ли уже кнопка с этим URL, чтобы избежать дубликатов
    if (container.querySelector(`.tel-download[data-tel-download-url="${url}"]`)) {
      return;
    }

    const downloadIcon = document.createElement("i");
    // Используем класс icon, если он есть, иначе просто span
    if (container.closest('#MediaViewer') || container.closest('#StoryViewer')) { // Для webz
        downloadIcon.className = "icon icon-download";
    } else { // Для webk
        downloadIcon.className = "tgico button-icon";
        downloadIcon.innerHTML = DOWNLOAD_ICON;
    }

    const downloadButton = document.createElement("button");
    downloadButton.className = "tel-download"; // Общий класс для наших кнопок
    downloadButton.setAttribute("type", "button");
    downloadButton.setAttribute("title", "Download");
    downloadButton.setAttribute("aria-label", "Download");
    downloadButton.setAttribute("data-tel-download-url", url); // Для отслеживания дубликатов
    downloadButton.appendChild(downloadIcon);

    // Добавляем специфичные классы для стилизации в webz/webk
    if (container.closest('#MediaViewer') || container.closest('#StoryViewer')) { // webz
        downloadButton.classList.add("Button", "smaller", "translucent-white", "round");
    } else { // webk
        downloadButton.classList.add("btn-icon");
        if (type === 'audio') { // Для аудио кнопок в webk, используем другой стиль
            downloadButton.classList.add("_tel_download_button_pinned_container");
        } else {
            downloadButton.classList.add("tgico-download");
        }
        downloadButton.innerHTML += '<div class="c-ripple"></div>'; // Для эффекта нажатия в webk
    }

    downloadButton.onclick = (e) => {
      e.stopPropagation(); // Предотвращаем всплытие события, которое может закрыть медиапросмотр
      if (type === 'video') {
        tel_download_video(url);
      } else if (type === 'audio') {
        tel_download_audio(url);
      } else if (type === 'image') {
        tel_download_image(url);
      }
    };

    if (prepend) {
      container.prepend(downloadButton);
    } else {
      container.appendChild(downloadButton);
    }
    return downloadButton;
  };

  // Использование MutationObserver для более эффективного добавления кнопок
  // и предотвращения дублирования setInterval
  const setupObservers = () => {

    // Observer для webz /a/ app
    const webzObserver = new MutationObserver((mutations) => {
      mutations.forEach((mutation) => {
        if (mutation.type === 'childList' || mutation.type === 'attributes') {
          // Истории (webz)
          const storiesContainer = document.getElementById("StoryViewer");
          if (storiesContainer) {
            const storyHeader = storiesContainer.querySelector(".GrsJNw3y") || storiesContainer.querySelector(".DropdownMenu")?.parentNode;
            if (storyHeader) {
                const video = storiesContainer.querySelector("video");
                const videoSrc = video?.src || video?.currentSrc || video?.querySelector("source")?.src;
                const images = storiesContainer.querySelectorAll("img.PVZ8TOWS");
                const imageSrc = images.length > 0 ? images[images.length - 1]?.src : null;

                if (videoSrc) {
                    addDownloadButton(storyHeader, 'video', videoSrc, true);
                } else if (imageSrc) {
                    addDownloadButton(storyHeader, 'image', imageSrc, true);
                }
            }
          }

          // Медиапросмотр (webz)
          const mediaContainer = document.querySelector("#MediaViewer .MediaViewerSlide--active");
          const mediaViewerActions = document.querySelector("#MediaViewer .MediaViewerActions");
          if (mediaContainer && mediaViewerActions) {
            const videoPlayer = mediaContainer.querySelector(".MediaViewerContent > .VideoPlayer");
            const img = mediaContainer.querySelector(".MediaViewerContent > div > img");

            if (videoPlayer) {
              const videoUrl = videoPlayer.querySelector("video")?.currentSrc;
              if (videoUrl) {
                // Кнопка в контролах видео
                const controls = videoPlayer.querySelector(".VideoPlayerControls");
                if (controls) {
                  const buttons = controls.querySelector(".buttons");
                  if (buttons) {
                    addDownloadButton(buttons, 'video', videoUrl);
                  }
                }
                // Кнопка в верхней панели MediaViewerActions
                addDownloadButton(mediaViewerActions, 'video', videoUrl, true);
              }
            } else if (img && img.src) {
              addDownloadButton(mediaViewerActions, 'image', img.src, true);
            }
          }
        }
      });
    });

    webzObserver.observe(document.body, { childList: true, subtree: true, attributes: true });


    // Observer для webk /k/ app
    const webkObserver = new MutationObserver((mutations) => {
      mutations.forEach((mutation) => {
        if (mutation.type === 'childList' || mutation.type === 'attributes') {
            // Аудиосообщения и кружочки (webk)
            document.querySelectorAll("audio-element").forEach((audioElement) => {
                const bubble = audioElement.closest(".bubble");
                const link = audioElement.audio?.src;
                if (bubble && link) { // Проверяем bubble и link
                    const isAudio = audioElement.audio instanceof HTMLAudioElement;
                    const container = bubble.querySelector(".message-body-wrapper .bubble-content"); // Или другой подходящий контейнер
                    if (container) {
                        // Для голосовых сообщений и кружочков, добавляем кнопку рядом
                        // Проверяем, что кнопка еще не добавлена в этот конкретный пузырь
                        if (!container.querySelector('.tel-download[data-tel-download-url]')) {
                            addDownloadButton(container, isAudio ? 'audio' : 'video', link);
                        }
                    }
                }
            });

            // Истории (webk)
            const storiesContainer = document.getElementById("stories-viewer");
            if (storiesContainer) {
                const storyHeader = storiesContainer.querySelector("[class^='_ViewerStoryHeaderRight']");
                const storyFooter = storiesContainer.querySelector("[class^='_ViewerStoryFooterRight']");

                const video = storiesContainer.querySelector("video.media-video");
                const videoSrc = video?.src || video?.currentSrc || video?.querySelector("source")?.src;
                const imageSrc = storiesContainer.querySelector("img.media-photo")?.src;

                if (storyHeader) {
                    if (videoSrc) addDownloadButton(storyHeader, 'video', videoSrc, true);
                    else if (imageSrc) addDownloadButton(storyHeader, 'image', imageSrc, true);
                }
                if (storyFooter) {
                    if (videoSrc) addDownloadButton(storyFooter, 'video', videoSrc, true);
                    else if (imageSrc) addDownloadButton(storyFooter, 'image', imageSrc, true);
                }
            }

            // Медиапросмотр (webk)
            const mediaContainer = document.querySelector(".media-viewer-whole");
            if (mediaContainer) {
                const mediaAspecter = mediaContainer.querySelector(".media-viewer-movers .media-viewer-aspecter");
                const mediaButtons = mediaContainer.querySelector(".media-viewer-topbar .media-viewer-buttons");

                if (mediaAspecter && mediaButtons) {
                    // Раскрываем скрытые официальные кнопки
                    const hiddenButtons = mediaButtons.querySelectorAll("button.btn-icon.hide");
                    for (const btn of hiddenButtons) {
                        btn.classList.remove("hide");
                        if (btn.textContent === FORWARD_ICON) {
                            btn.classList.add("tgico-forward");
                        }
                        if (btn.textContent === DOWNLOAD_ICON) {
                            btn.classList.add("tgico-download");
                        }
                    }

                    const videoElement = mediaAspecter.querySelector("video");
                    const imgElement = mediaAspecter.querySelector("img.thumbnail");

                    if (videoElement && videoElement.src) {
                        addDownloadButton(mediaButtons, 'video', videoElement.src, true);
                        // Дополнительная кнопка в контролах видеоплеера
                        const controls = mediaAspecter.querySelector(".default__controls.ckin__controls");
                        if (controls) {
                            const brControls = controls.querySelector(".bottom-controls .right-controls");
                            if (brControls) {
                                addDownloadButton(brControls, 'video', videoElement.src, true);
                            }
                        }
                    } else if (imgElement && imgElement.src) {
                        addDownloadButton(mediaButtons, 'image', imgElement.src, true);
                    }
                }
            }
        }
      });
    });

    webkObserver.observe(document.body, { childList: true, subtree: true, attributes: true });

  }; // End of setupObservers

  // Прогресс-бар контейнер
  function setupProgressBar() {
    const body = document.querySelector("body");
    let container = document.getElementById("tel-downloader-progress-bar-container");
    if (container) return; // Уже существует

    container = document.createElement("div");
    container.id = "tel-downloader-progress-bar-container";
    container.style.position = "fixed";
    container.style.bottom = "1rem"; // Отступ от низа
    container.style.right = "1rem"; // Отступ от права
    container.style.display = "flex";
    container.style.flexDirection = "column";
    container.style.gap = "0.5rem"; // Расстояние между прогресс-барами

    if (location.pathname.startsWith("/k/")) {
      container.style.zIndex = 4;
    } else {
      container.style.zIndex = 1600;
    }
    body.appendChild(container);
    logger.info("Progress bar container created.");
  }


  // Запускаем наблюдателей после инициализации скрипта
  // Вместо setInterval для добавления кнопок, используем MutationObserver
  // Однако, так как Telegram динамически загружает контент,
  // мы можем оставить setInterval для периодической проверки,
  // или использовать observer более специфично.
  // Для простоты, пока оставим setInterval, но с учетом, что addDownloadButton
  // предотвратит дубликаты. В более сложной реализации MutationObserver будет лучше.

  // Используем setInterval для периодического вызова функции,
  // которая будет запускать логику добавления кнопок.
  // Это менее эффективно, чем MutationObserver, но проще в реализации для динамически
  // появляющихся элементов в Telegram Web. Функция addDownloadButton предотвратит дублирование.

  // NOTE: Original script used setInterval, I will keep that pattern,
  // but wrap the logic into a single function to avoid redundancy.
  setInterval(() => {
    // webz /a/ app
    const storiesContainerWebz = document.getElementById("StoryViewer");
    if (storiesContainerWebz) {
        const storyHeader = storiesContainerWebz.querySelector(".GrsJNw3y") || storiesContainerWebz.querySelector(".DropdownMenu")?.parentNode;
        if (storyHeader) {
            const video = storiesContainerWebz.querySelector("video");
            const videoSrc = video?.src || video?.currentSrc || video?.querySelector("source")?.src;
            const images = storiesContainerWebz.querySelectorAll("img.PVZ8TOWS");
            const imageSrc = images.length > 0 ? images[images.length - 1]?.src : null;

            if (videoSrc) {
                addDownloadButton(storyHeader, 'video', videoSrc, true);
            } else if (imageSrc) {
                addDownloadButton(storyHeader, 'image', imageSrc, true);
            }
        }
    }

    const mediaContainerWebz = document.querySelector("#MediaViewer .MediaViewerSlide--active");
    const mediaViewerActionsWebz = document.querySelector("#MediaViewer .MediaViewerActions");
    if (mediaContainerWebz && mediaViewerActionsWebz) {
        const videoPlayer = mediaContainerWebz.querySelector(".MediaViewerContent > .VideoPlayer");
        const img = mediaContainerWebz.querySelector(".MediaViewerContent > div > img");

        if (videoPlayer) {
            const videoUrl = videoPlayer.querySelector("video")?.currentSrc;
            if (videoUrl) {
                const controls = videoPlayer.querySelector(".VideoPlayerControls");
                if (controls) {
                    const buttons = controls.querySelector(".buttons");
                    if (buttons) addDownloadButton(buttons, 'video', videoUrl);
                }
                addDownloadButton(mediaViewerActionsWebz, 'video', videoUrl, true);
            }
        } else if (img && img.src) {
            addDownloadButton(mediaViewerActionsWebz, 'image', img.src, true);
        }
    }

    // webk /k/ app
    document.querySelectorAll("audio-element").forEach((audioElement) => {
        const bubble = audioElement.closest(".bubble");
        const link = audioElement.audio?.src;
        if (bubble && link) {
            const isAudio = audioElement.audio instanceof HTMLAudioElement;
            const container = bubble.querySelector(".message-body-wrapper .bubble-content");
            if (container) {
                // Это добавит кнопку для каждого аудиоэлемента, который еще не имеет кнопки
                addDownloadButton(container, isAudio ? 'audio' : 'video', link);
            }
        }
    });

    const storiesContainerWebk = document.getElementById("stories-viewer");
    if (storiesContainerWebk) {
        const storyHeader = storiesContainerWebk.querySelector("[class^='_ViewerStoryHeaderRight']");
        const storyFooter = storiesContainerWebk.querySelector("[class^='_ViewerStoryFooterRight']");

        const video = storiesContainerWebk.querySelector("video.media-video");
        const videoSrc = video?.src || video?.currentSrc || video?.querySelector("source")?.src;
        const imageSrc = storiesContainerWebk.querySelector("img.media-photo")?.src;

        if (storyHeader) {
            if (videoSrc) addDownloadButton(storyHeader, 'video', videoSrc, true);
            else if (imageSrc) addDownloadButton(storyHeader, 'image', imageSrc, true);
        }
        if (storyFooter) {
            if (videoSrc) addDownloadButton(storyFooter, 'video', videoSrc, true);
            else if (imageSrc) addDownloadButton(storyFooter, 'image', imageSrc, true);
        }
    }

    const mediaContainerWebk = document.querySelector(".media-viewer-whole");
    if (mediaContainerWebk) {
        const mediaAspecter = mediaContainerWebk.querySelector(".media-viewer-movers .media-viewer-aspecter");
        const mediaButtons = mediaContainerWebk.querySelector(".media-viewer-topbar .media-viewer-buttons");

        if (mediaAspecter && mediaButtons) {
            // Раскрываем скрытые официальные кнопки
            const hiddenButtons = mediaButtons.querySelectorAll("button.btn-icon.hide");
            for (const btn of hiddenButtons) {
                btn.classList.remove("hide");
                if (btn.textContent === FORWARD_ICON) {
                    btn.classList.add("tgico-forward");
                }
                if (btn.textContent === DOWNLOAD_ICON) {
                    btn.classList.add("tgico-download");
                    // Если есть официальная кнопка, мы можем просто нажать ее
                    // Но для единообразия и прогресс-бара, все равно используем наш метод
                    // btn.onclick = () => btn.click(); // Или можно оставить это
                }
            }

            const videoElement = mediaAspecter.querySelector("video");
            const imgElement = mediaAspecter.querySelector("img.thumbnail");

            if (videoElement && videoElement.src) {
                addDownloadButton(mediaButtons, 'video', videoElement.src, true);
                const controls = mediaAspecter.querySelector(".default__controls.ckin__controls");
                if (controls) {
                    const brControls = controls.querySelector(".bottom-controls .right-controls");
                    if (brControls) addDownloadButton(brControls, 'video', videoElement.src, true);
                }
            } else if (imgElement && imgElement.src) {
                addDownloadButton(mediaButtons, 'image', imgElement.src, true);
            }
        }
    }
  }, REFRESH_DELAY);


  setupProgressBar(); // Убедимся, что контейнер прогресс-бара создан при запуске

  logger.info("Completed script setup and started monitoring.");
})();