Telegram 受限图片视频下载器

从禁止下载的Telegram频道中下载图片、视频及语音消息

// ==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.");
})();