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.207
// @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
// ==/UserScript==


(function () {
  const logger = {
    info: (message, fileName = null) => {
      console.log(
        `[Tel Download] ${fileName ? `${fileName}: ` : ""}${message}`
      );
    },
    error: (message, fileName = null) => {
      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;

  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;
  };

  /**
   * Создает элемент прогресс-бара для отображения статуса загрузки.
   * @param {string} videoId Уникальный ID для прогресс-бара.
   * @param {string} fileName Имя файла для отображения.
   */
  const createProgressBar = (videoId, fileName) => {
    const isDarkMode =
      document.querySelector("html").classList.contains("night") ||
      document.querySelector("html").classList.contains("theme-dark");
    let container = document.getElementById("tel-downloader-progress-bar-container");

    // Создаем контейнер, если он еще не существует
    if (!container) {
      container = document.createElement("div");
      container.id = "tel-downloader-progress-bar-container";
      container.style.position = "fixed";
      container.style.bottom = "0";
      container.style.right = "0";
      container.style.zIndex = location.pathname.startsWith("/k/") ? "4" : "1600";
      document.body.appendChild(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 = "5px"; // Добавим немного скругления

    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;
    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.style.marginLeft = "10px"; // Отступ от названия файла
    closeButton.innerHTML = "&times;";
    closeButton.onclick = function () {
      innerContainer.remove(); // Удаляем весь innerContainer
    };

    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";
    progressBar.style.marginTop = "0.4rem"; // Отступ от названия

    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.innerText = "0%"; // Начальное значение

    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.1s linear"; // Плавный переход для прогресса

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

  /**
   * Обновляет прогресс загрузки для существующего прогресс-бара.
   * @param {string} videoId ID прогресс-бара.
   * @param {string} fileName Обновленное имя файла (если изменилось).
   * @param {number} progress Прогресс в процентах.
   */
  const updateProgress = (videoId, fileName, progress) => {
    const innerContainer = document.getElementById(
      "tel-downloader-progress-" + videoId
    );
    if (!innerContainer) {
      logger.error(`Progress bar with ID ${videoId} not found for update.`, fileName);
      return;
    }
    innerContainer.querySelector("p.filename").innerText = fileName;
    const progressBar = innerContainer.querySelector("div.progress");
    progressBar.querySelector("p").innerText = progress + "%";
    progressBar.querySelector("div").style.width = progress + "%";
  };

  /**
   * Завершает прогресс загрузки, отмечая его как выполненный.
   * @param {string} videoId ID прогресс-бара.
   */
  const completeProgress = (videoId) => {
    const innerContainer = document.getElementById(
      "tel-downloader-progress-" + videoId
    );
    if (!innerContainer) {
      logger.error(`Progress bar with ID ${videoId} not found for completion.`);
      return;
    }
    const progressBar = innerContainer.querySelector("div.progress");
    progressBar.querySelector("p").innerText = "Completed";
    progressBar.querySelector("div").style.backgroundColor = "#B6C649";
    progressBar.querySelector("div").style.width = "100%";
    setTimeout(() => innerContainer.remove(), 2000); // Удалить через 2 секунды после завершения
  };

  /**
   * Отмечает прогресс загрузки как прерванный.
   * @param {string} videoId ID прогресс-бара.
   */
  const AbortProgress = (videoId) => {
    const innerContainer = document.getElementById(
      "tel-downloader-progress-" + videoId
    );
    if (!innerContainer) {
      logger.error(`Progress bar with ID ${videoId} not found for abort.`);
      return;
    }
    const progressBar = innerContainer.querySelector("div.progress");
    progressBar.querySelector("p").innerText = "Aborted";
    progressBar.querySelector("div").style.backgroundColor = "#D16666";
    progressBar.querySelector("div").style.width = "100%";
    setTimeout(() => innerContainer.remove(), 2000); // Удалить через 2 секунды после прерывания
  };

  /**
   * Загружает видео по частям, используя Range-запросы.
   * Поддерживает File System Access API.
   * @param {string} url URL видео для загрузки.
   */
  const tel_download_video = async (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;

    // Некоторые video src имеют формат:
    // 'stream/{"dcId":5,"location":{...},"size":...,"mimeType":"video/mp4","fileName":"xxxx.MP4"}'
    try {
      const metadataPart = url.split("/").pop(); // Получаем последнюю часть URL
      if (metadataPart) {
        const decodedMetadata = decodeURIComponent(metadataPart);
        const metadata = JSON.parse(decodedMetadata);
        if (metadata.fileName) {
          fileName = metadata.fileName;
          // Обновляем расширение файла, если оно есть в метаданных
          const parts = fileName.split('.');
          if (parts.length > 1) {
            _file_extension = parts.pop();
          }
        }
      }
    } catch (e) {
      // Invalid JSON string, pass extracting fileName
      logger.info(`Could not parse metadata from URL part: ${url.split('/').pop()}`, fileName);
    }
    logger.info(`Starting download for URL: ${url}`, fileName);

    createProgressBar(videoId, fileName);

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

    if (supportsFileSystemAccess) {
      try {
        const handle = await unsafeWindow.showSaveFilePicker({
          suggestedName: fileName,
        });
        _writable = await handle.createWritable();
        logger.info('File System Access API writable created.', fileName);
      } catch (err) {
        if (err.name !== "AbortError") {
          logger.error(`Error with File System Access API: ${err.name} - ${err.message}`, fileName);
        } else {
          logger.info('File save operation aborted by user.', fileName);
        }
        AbortProgress(videoId);
        return; // Прекращаем выполнение, если пользователь отменил сохранение
      }
    }

    try {
      while (_writable ? true : _next_offset < (_total_size || Infinity)) { // Продолжаем, пока не загрузим все или пока writable не закрыт
        const headers = {
          "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/117.0",
        };
        if (_total_size) { // Добавляем Range только если известен общий размер
          headers.Range = `bytes=${_next_offset}-`;
        }


        const res = await fetch(url, { method: "GET", headers });

        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("video/")) {
          throw new Error("Get non video response with MIME type " + mime);
        }
        const currentFileExtension = mime.split("/")[1];
        if (_file_extension !== currentFileExtension) {
          _file_extension = currentFileExtension;
          fileName = fileName.substring(0, fileName.indexOf(".") + 1) + _file_extension;
          logger.info(`File extension updated to: ${_file_extension}`, fileName);
        }


        const contentRangeHeader = res.headers.get("Content-Range");
        if (contentRangeHeader) {
          const match = contentRangeHeader.match(contentRangeRegex);
          if (!match) {
            throw new Error(`Invalid Content-Range header: ${contentRangeHeader}`);
          }

          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 "Gap detected between responses.";
          }
          if (_total_size && totalSize !== _total_size) {
            logger.error("Total size differs", fileName);
            throw "Total size differs";
          }

          _next_offset = endOffset + 1;
          _total_size = totalSize;
        } else if (res.status === 200) {
          // Если 200 OK без Content-Range, значит это полный файл
          _total_size = parseInt(res.headers.get("Content-Length"));
          _next_offset = _total_size;
          logger.info(`Received full file (200 OK), total size: ${_total_size} bytes`, fileName);
        } else {
          throw new Error("Missing Content-Range header for 206 Partial Content response.");
        }


        const resBlob = await res.blob();
        if (_writable) {
          await _writable.write(resBlob);
        } else {
          _blobs.push(resBlob);
        }

        const progressPercent = _total_size ? ((_next_offset * 100) / _total_size).toFixed(0) : 0;
        logger.info(`Progress: ${progressPercent}%`, fileName);
        updateProgress(videoId, fileName, progressPercent);

        if (_next_offset >= _total_size) {
          break; // Загрузка завершена
        }
      }

      if (_writable) {
        await _writable.close();
        logger.info("Download finished (File System Access API)", fileName);
      } else {
        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", fileName);
      }
      completeProgress(videoId);
    } catch (reason) {
      logger.error(`Download failed: ${reason}`, fileName);
      AbortProgress(videoId);
      if (_writable) {
        try {
          await _writable.close();
        } catch (closeErr) {
          logger.error(`Error closing writable after download failure: ${closeErr.message}`, fileName);
        }
      }
    }
  };

  /**
   * Загружает аудиофайл по частям, используя Range-запросы.
   * Поддерживает File System Access API.
   * @param {string} url URL аудиофайла для загрузки.
   */
  const tel_download_audio = async (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;

    logger.info(`Starting download for URL: ${url}`, fileName);
    createProgressBar(audioId, fileName); // Используем audioId для прогресс-бара

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

    if (supportsFileSystemAccess) {
      try {
        const handle = await unsafeWindow.showSaveFilePicker({
          suggestedName: fileName,
        });
        _writable = await handle.createWritable();
        logger.info('File System Access API writable created for audio.', fileName);
      } catch (err) {
        if (err.name !== "AbortError") {
          logger.error(`Error with File System Access API for audio: ${err.name} - ${err.message}`, fileName);
        } else {
          logger.info('Audio file save operation aborted by user.', fileName);
        }
        AbortProgress(audioId);
        return;
      }
    }

    try {
      while (_writable ? true : _next_offset < (_total_size || Infinity)) {
        const headers = {};
        if (_total_size) {
          headers.Range = `bytes=${_next_offset}-`;
        }

        const res = await fetch(url, { method: "GET", headers });

        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("Get non audio response with MIME type " + mime);
        }
        const currentFileExtension = mime.split("/")[1];
        if (_file_extension !== currentFileExtension) {
          _file_extension = currentFileExtension;
          fileName = fileName.substring(0, fileName.indexOf(".") + 1) + _file_extension;
          logger.info(`Audio file extension updated to: ${_file_extension}`, fileName);
        }


        const contentRangeHeader = res.headers.get("Content-Range");
        if (contentRangeHeader) {
          const match = contentRangeHeader.match(contentRangeRegex);
          if (!match) {
            throw new Error(`Invalid Content-Range header: ${contentRangeHeader}`);
          }
          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}`, fileName);
            throw "Gap detected between responses.";
          }
          if (_total_size && totalSize !== _total_size) {
            logger.error("Total audio size differs", fileName);
            throw "Total size differs";
          }

          _next_offset = endOffset + 1;
          _total_size = totalSize;
        } else if (res.status === 200) {
          _total_size = parseInt(res.headers.get("Content-Length"));
          _next_offset = _total_size;
          logger.info(`Received full audio file (200 OK), total size: ${_total_size} bytes`, fileName);
        } else {
          throw new Error("Missing Content-Range header for 206 Partial Content response for audio.");
        }


        const resBlob = await res.blob();
        if (_writable) {
          await _writable.write(resBlob);
        } else {
          _blobs.push(resBlob);
        }

        const progressPercent = _total_size ? ((_next_offset * 100) / _total_size).toFixed(0) : 0;
        logger.info(`Audio Progress: ${progressPercent}%`, fileName);
        updateProgress(audioId, fileName, progressPercent);

        if (_next_offset >= _total_size) {
          break;
        }
      }

      if (_writable) {
        await _writable.close();
        logger.info("Audio download finished (File System Access API)", fileName);
      } else {
        const blob = new Blob(_blobs, { type: `audio/${_file_extension}` });
        const blobUrl = window.URL.createObjectURL(blob);

        logger.info("Final audio 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("Audio download triggered", fileName);
      }
      completeProgress(audioId);
    } catch (reason) {
      logger.error(`Audio download failed: ${reason}`, fileName);
      AbortProgress(audioId);
      if (_writable) {
        try {
          await _writable.close();
        } catch (closeErr) {
          logger.error(`Error closing writable after audio download failure: ${closeErr.message}`, fileName);
        }
      }
    }
  };

  /**
   * Загружает изображение.
   * @param {string} imageUrl URL изображения для загрузки.
   */
  const tel_download_image = (imageUrl) => {
    // Попытка определить расширение из URL, иначе по умолчанию .jpeg
    let fileName = (Math.random() + 1).toString(36).substring(2, 10);
    const urlParts = imageUrl.split('.');
    if (urlParts.length > 1) {
      const potentialExtension = urlParts.pop().split('?')[0].split('#')[0];
      if (potentialExtension.length <= 4 && /^[a-zA-Z0-9]+$/.test(potentialExtension)) {
        fileName += `.${potentialExtension}`;
      } else {
        fileName += ".jpeg";
      }
    } else {
      fileName += ".jpeg";
    }

    const a = document.createElement("a");
    document.body.appendChild(a);
    a.href = imageUrl;
    a.download = fileName;
    a.click();
    document.body.removeChild(a);

    logger.info("Image download triggered", fileName);
  };

  logger.info("Initialized script.");

  // For webz /a/ webapp (Telegram Web Z)
  setInterval(() => {
    // Stories
    const storiesContainer = document.getElementById("StoryViewer");
    if (storiesContainer) {
      // logger.info("Found storiesContainer");
      const createDownloadButtonForStory = () => { // Переименовал для ясности
        const downloadIcon = document.createElement("i");
        downloadIcon.className = "icon icon-download";
        const downloadButton = document.createElement("button");
        downloadButton.className =
          "Button TkphaPyQ tiny translucent-white round tel-download";
        downloadButton.appendChild(downloadIcon);
        downloadButton.setAttribute("type", "button");
        downloadButton.setAttribute("title", "Download");
        downloadButton.setAttribute("aria-label", "Download");
        downloadButton.onclick = () => {
          // 1. Story with video
          const video = storiesContainer.querySelector("video");
          const videoSrc =
            video?.src ||
            video?.currentSrc ||
            video?.querySelector("source")?.src;
          if (videoSrc) {
            tel_download_video(videoSrc);
          } else {
            // 2. Story with image
            // Используем querySelectorAll и берем последний, так как могут быть превью
            const images = storiesContainer.querySelectorAll("img.PVZ8TOWS");
            if (images.length > 0) {
              const imageSrc = images[images.length - 1]?.src;
              if (imageSrc) tel_download_image(imageSrc);
            }
          }
        };
        return downloadButton;
      };

      const storyHeader =
        storiesContainer.querySelector(".GrsJNw3y") ||
        storiesContainer.querySelector(".DropdownMenu")?.parentNode; // Использование optional chaining
      if (storyHeader && !storyHeader.querySelector(".tel-download")) {
        // logger.info("Adding download button to story header.");
        // Проверяем, существует ли уже кнопка загрузки от Telegram
        const existingDownloadButton = storyHeader.querySelector('button[title="Download"], button[aria-label="Download"]');
        if (!existingDownloadButton) {
          storyHeader.insertBefore(
            createDownloadButtonForStory(),
            storyHeader.querySelector("button") // Вставляем перед первой кнопкой
          );
        }
      }
    }

    // All media opened are located in .media-viewer-movers > .media-viewer-aspecter
    const mediaContainer = document.querySelector(
      "#MediaViewer .MediaViewerSlide--active"
    );
    const mediaViewerActions = document.querySelector(
      "#MediaViewer .MediaViewerActions"
    );

    if (!mediaContainer || !mediaViewerActions) return;

    // logger.info("Found active media viewer.");

    // Создаем кнопку загрузки, которая будет использоваться
    const createMediaDownloadButton = (url, type) => {
      const downloadIcon = document.createElement("i");
      downloadIcon.className = "icon icon-download";
      const downloadButton = document.createElement("button");
      downloadButton.className =
        "Button smaller translucent-white round 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);
      downloadButton.onclick = () => {
        if (type === 'video') {
          tel_download_video(url);
        } else if (type === 'image') {
          tel_download_image(url);
        }
      };
      return downloadButton;
    };

    const videoPlayer = mediaContainer.querySelector(
      ".MediaViewerContent > .VideoPlayer"
    );
    const img = mediaContainer.querySelector(".MediaViewerContent > div > img");

    let targetUrl = null;
    let mediaType = null;

    if (videoPlayer) {
      const videoElement = videoPlayer.querySelector("video");
      targetUrl = videoElement?.currentSrc || videoElement?.src;
      mediaType = 'video';
    } else if (img && img.src) {
      targetUrl = img.src;
      mediaType = 'image';
    }

    if (targetUrl) {
      const existingOurButton = mediaViewerActions.querySelector("button.tel-download");
      const existingOfficialButton = mediaViewerActions.querySelector('button[title="Download"]');

      // Если есть официальная кнопка, не добавляем свою, а используем ее
      if (existingOfficialButton) {
        // Проверяем, если наша кнопка уже есть, но официальная появилась, удаляем свою
        if (existingOurButton) {
          existingOurButton.remove();
          // logger.info("Removed custom download button, official one is present.");
        }
        // Если официальная кнопка есть, мы не управляем ее кликом.
        // Предполагается, что она работает корректно.
        return;
      }

      // Если официальной кнопки нет, добавляем или обновляем нашу
      if (existingOurButton) {
        if (existingOurButton.getAttribute("data-tel-download-url") !== targetUrl) {
          // Обновляем URL и функцию клика, если URL изменился (т.е. переключили медиа)
          existingOurButton.setAttribute("data-tel-download-url", targetUrl);
          existingOurButton.onclick = () => {
            if (mediaType === 'video') {
              tel_download_video(targetUrl);
            } else if (mediaType === 'image') {
              tel_download_image(targetUrl);
            }
          };
          // logger.info("Updated custom download button for new media.");
        }
      } else {
        // Если нет ни нашей, ни официальной кнопки, создаем и добавляем новую
        const newDownloadButton = createMediaDownloadButton(targetUrl, mediaType);
        mediaViewerActions.prepend(newDownloadButton);
        // logger.info("Added custom download button.");
      }

      // Добавление кнопки в контролы видеоплеера (только для видео)
      if (videoPlayer) {
        const controls = videoPlayer.querySelector(".VideoPlayerControls");
        if (controls) {
          const buttons = controls.querySelector(".buttons");
          if (buttons && !buttons.querySelector("button.tel-download")) {
            const spacer = buttons.querySelector(".spacer");
            if (spacer) {
              const newDownloadButton = createMediaDownloadButton(targetUrl, mediaType);
              spacer.after(newDownloadButton);
              // logger.info("Added custom download button to video controls.");
            }
          }
        }
      }
    } else {
      // Если медиа нет, убедимся, что наша кнопка удалена
      const existingOurButton = mediaViewerActions.querySelector("button.tel-download");
      if (existingOurButton) {
        existingOurButton.remove();
        // logger.info("Removed custom download button as no media found.");
      }
    }
  }, REFRESH_DELAY);

  // For webk /k/ webapp (Telegram Web K)
  setInterval(() => {
    /* Voice Message or Circle Video */
    const audioElements = document.body.querySelectorAll("audio-element");

    audioElements.forEach((audioElement) => {
      const bubble = audioElement.closest(".bubble");
      // Пропускаем, если нет родительского элемента .bubble или кнопка уже существует
      if (!bubble || bubble.querySelector("._tel_download_button_pinned_container")) {
        return;
      }

      const link = audioElement.audio?.src;
      if (!link) {
        return;
      }

      const isAudio = audioElement.audio instanceof HTMLAudioElement;

      // Создаем кнопку загрузки
      const downloadButton = document.createElement("button");
      downloadButton.className = "btn-icon tgico-download _tel_download_button_pinned_container";
      downloadButton.innerHTML = `<span class="tgico button-icon">${DOWNLOAD_ICON}</span>`;
      downloadButton.setAttribute("type", "button");
      downloadButton.setAttribute("title", "Download");
      downloadButton.setAttribute("aria-label", "Download");
      downloadButton.onclick = (e) => {
        e.stopPropagation(); // Предотвратить всплытие события, чтобы не закрывать медиаплеер
        if (isAudio) {
          tel_download_audio(link);
        } else {
          tel_download_video(link);
        }
      };

      // Ищем место для вставки кнопки
      const utilsContainer = bubble.querySelector(".message-utils-container") || bubble.querySelector(".bubble-tools-wrapper");
      if (utilsContainer && !utilsContainer.querySelector("._tel_download_button_pinned_container")) {
        utilsContainer.appendChild(downloadButton);
        // logger.info("Added download button for voice message/circle video.", link);
      }
    });

    // Stories (webk)
    const storiesContainer = document.getElementById("stories-viewer");
    if (storiesContainer) {
      const createDownloadButtonForStoryK = () => {
        const downloadButton = document.createElement("button");
        downloadButton.className = "btn-icon rp tel-download";
        downloadButton.innerHTML = `<span class="tgico">${DOWNLOAD_ICON}</span><div class="c-ripple"></div>`;
        downloadButton.setAttribute("type", "button");
        downloadButton.setAttribute("title", "Download");
        downloadButton.setAttribute("aria-label", "Download");
        downloadButton.onclick = () => {
          const video = storiesContainer.querySelector("video.media-video");
          const videoSrc =
            video?.src ||
            video?.currentSrc ||
            video?.querySelector("source")?.src;
          if (videoSrc) {
            tel_download_video(videoSrc);
          } else {
            const imageSrc =
              storiesContainer.querySelector("img.media-photo")?.src;
            if (imageSrc) tel_download_image(imageSrc);
          }
        };
        return downloadButton;
      };

      const storyHeader = storiesContainer.querySelector(
        "[class^='_ViewerStoryHeaderRight']"
      );
      if (storyHeader && !storyHeader.querySelector(".tel-download")) {
        storyHeader.prepend(createDownloadButtonForStoryK());
        // logger.info("Added download button to story header (webk).");
      }

      const storyFooter = storiesContainer.querySelector(
        "[class^='_ViewerStoryFooterRight']"
      );
      if (storyFooter && !storyFooter.querySelector(".tel-download")) {
        storyFooter.prepend(createDownloadButtonForStoryK());
        // logger.info("Added download button to story footer (webk).");
      }
    }

    // All media opened are located in .media-viewer-movers > .media-viewer-aspecter (webk)
    const mediaContainer = document.querySelector(".media-viewer-whole");
    if (!mediaContainer) return;

    const mediaAspecter = mediaContainer.querySelector(
      ".media-viewer-movers .media-viewer-aspecter"
    );
    const mediaButtons = mediaContainer.querySelector(
      ".media-viewer-topbar .media-viewer-buttons"
    );
    if (!mediaAspecter || !mediaButtons) return;

    // logger.info("Found active media viewer (webk).");

    // Query hidden buttons and unhide them
    const hiddenButtons = mediaButtons.querySelectorAll("button.btn-icon.hide");
    let onOfficialDownloadClick = null;
    for (const btn of hiddenButtons) {
      btn.classList.remove("hide");
      if (btn.textContent === FORWARD_ICON) { // Хотя FORWARD_ICON не используется для кнопки загрузки, оставил для справки
        btn.classList.add("tgico-forward");
      }
      if (btn.textContent === DOWNLOAD_ICON) {
        btn.classList.add("tgico-download");
        onOfficialDownloadClick = () => {
          btn.click();
        };
        // logger.info("Found and unhidden official download button.");
      }
    }

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

    const existingCustomButton = mediaButtons.querySelector("button.tel-download");
    const existingOfficialDownloadButton = mediaButtons.querySelector("button.btn-icon.tgico-download");

    // Если есть официальная кнопка загрузки, то наша кнопка не нужна
    if (existingOfficialDownloadButton) {
      if (existingCustomButton) {
        existingCustomButton.remove();
        // logger.info("Removed custom download button as official one is present (webk).");
      }
      return;
    }

    // Если нет официальной кнопки, добавляем свою
    let targetUrl = null;
    let mediaType = null;

    if (videoElement && (videoElement.src || videoElement.currentSrc)) {
      targetUrl = videoElement.src || videoElement.currentSrc;
      mediaType = 'video';
    } else if (imageElement && imageElement.src) {
      targetUrl = imageElement.src;
      mediaType = 'image';
    }

    if (targetUrl) {
      if (existingCustomButton) {
        // Если наша кнопка уже есть, обновляем ее, если URL изменился
        if (existingCustomButton.getAttribute("data-tel-download-url") !== targetUrl) {
          existingCustomButton.setAttribute("data-tel-download-url", targetUrl);
          existingCustomButton.onclick = () => {
            if (mediaType === 'video') {
              tel_download_video(targetUrl);
            } else if (mediaType === 'image') {
              tel_download_image(targetUrl);
            }
          };
          // logger.info("Updated custom download button for new media (webk).");
        }
      } else {
        // Создаем новую кнопку, если ее нет
        const downloadButton = document.createElement("button");
        downloadButton.className = "btn-icon tgico-download tel-download";
        downloadButton.innerHTML = `<span class="tgico button-icon">${DOWNLOAD_ICON}</span>`;
        downloadButton.setAttribute("type", "button");
        downloadButton.setAttribute("title", "Download");
        downloadButton.setAttribute("aria-label", "Download");
        downloadButton.setAttribute("data-tel-download-url", targetUrl); // Сохраняем URL
        if (onOfficialDownloadClick) {
          downloadButton.onclick = onOfficialDownloadClick; // Используем официальный клик, если найден
        } else {
          downloadButton.onclick = () => {
            if (mediaType === 'video') {
              tel_download_video(targetUrl);
            } else if (mediaType === 'image') {
              tel_download_image(targetUrl);
            }
          };
        }
        mediaButtons.prepend(downloadButton);
        // logger.info("Added custom download button (webk).");
      }

      // Добавление кнопки в контролы видеоплеера (только для видео)
      if (videoElement && mediaAspecter.querySelector(".ckin__player")) { // Проверяем, что это активный видеоплеер
        const controls = mediaAspecter.querySelector(
          ".default__controls.ckin__controls"
        );
        if (controls && !controls.querySelector(".tel-download")) {
          const brControls = controls.querySelector(
            ".bottom-controls .right-controls"
          );
          if (brControls) {
            const downloadButton = document.createElement("button");
            downloadButton.className =
              "btn-icon default__button tgico-download tel-download";
            downloadButton.innerHTML = `<span class="tgico">${DOWNLOAD_ICON}</span>`;
            downloadButton.setAttribute("type", "button");
            downloadButton.setAttribute("title", "Download");
            downloadButton.setAttribute("aria-label", "Download");
            downloadButton.setAttribute("data-tel-download-url", targetUrl); // Сохраняем URL
            if (onOfficialDownloadClick) {
              downloadButton.onclick = onOfficialDownloadClick;
            } else {
              downloadButton.onclick = () => {
                tel_download_video(targetUrl);
              };
            }
            brControls.prepend(downloadButton);
            // logger.info("Added custom download button to video controls (webk).");
          }
        }
      }
    } else {
      // Если медиа нет, убедимся, что наша кнопка удалена
      if (existingCustomButton) {
        existingCustomButton.remove();
        // logger.info("Removed custom download button as no media found (webk).");
      }
    }
  }, REFRESH_DELAY);

  logger.info("Script setup completed.");
})();