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