// ==UserScript==
// @name Telegram Media Downloader (Batch Support) (by AFU IT) v1.2
// @name:en Telegram Media Downloader (Batch Support) (by AFU IT) v1.2
// @version 1.2
// @description Download images, GIFs, videos, and voice messages from private channels + batch download selected media
// @author AFU IT
// @license GNU GPLv3
// @telegram https://t.me/afuituserscript
// @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 none
// @namespace https://github.com/Neet-Nestor/Telegram-Media-Downloader
// ==/UserScript==
(function() {
'use strict';
// Enhanced Logger
const logger = {
info: (message, fileName = null) => {
console.log(`[TG-Silent] ${fileName ? `${fileName}: ` : ""}${message}`);
},
error: (message, fileName = null) => {
console.error(`[TG-Silent] ${fileName ? `${fileName}: ` : ""}${message}`);
},
warn: (message, fileName = null) => {
console.warn(`[TG-Silent] ${fileName ? `${fileName}: ` : ""}${message}`);
}
};
const hashCode = (s) => {
var h = 0, l = s.length, i = 0;
if (l > 0) {
while (i < l) {
h = ((h << 5) - h + s.charCodeAt(i++)) | 0;
}
}
return h >>> 0;
};
// Progress tracking
let batchProgress = {
current: 0,
total: 0,
container: null
};
// Create batch progress bar
const createBatchProgress = () => {
if (document.getElementById('tg-batch-progress')) return;
const progressContainer = document.createElement('div');
progressContainer.id = 'tg-batch-progress';
progressContainer.style.cssText = `
position: fixed;
bottom: 100px;
right: 20px;
width: 280px;
background: rgba(0,0,0,0.9);
color: white;
padding: 12px 16px;
border-radius: 12px;
z-index: 999998;
display: none;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
`;
const progressText = document.createElement('div');
progressText.id = 'tg-batch-progress-text';
progressText.style.cssText = `
margin-bottom: 8px;
font-size: 13px;
font-weight: 500;
`;
const progressBarBg = document.createElement('div');
progressBarBg.style.cssText = `
width: 100%;
height: 4px;
background: rgba(255,255,255,0.2);
border-radius: 2px;
overflow: hidden;
`;
const progressBarFill = document.createElement('div');
progressBarFill.id = 'tg-batch-progress-fill';
progressBarFill.style.cssText = `
height: 100%;
background: #8774e1;
width: 0%;
transition: width 0.3s ease;
border-radius: 2px;
`;
progressBarBg.appendChild(progressBarFill);
progressContainer.appendChild(progressText);
progressContainer.appendChild(progressBarBg);
document.body.appendChild(progressContainer);
batchProgress.container = progressContainer;
};
// Update batch progress
const updateBatchProgress = (current, total, text) => {
const progressText = document.getElementById('tg-batch-progress-text');
const progressFill = document.getElementById('tg-batch-progress-fill');
const container = batchProgress.container;
if (progressText && progressFill && container) {
progressText.textContent = text || `Processing ${current}/${total}...`;
const percent = total > 0 ? (current / total) * 100 : 0;
progressFill.style.width = `${percent}%`;
container.style.display = 'block';
if (current >= total && total > 0) {
setTimeout(() => {
container.style.display = 'none';
}, 3000);
}
}
};
// Silent download functions
const tel_download_image = (imageUrl) => {
const fileName = (Math.random() + 1).toString(36).substring(2, 10) + ".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);
};
const tel_download_video = (url) => {
return new Promise((resolve, reject) => {
fetch(url)
.then(response => response.blob())
.then(blob => {
const fileName = (Math.random() + 1).toString(36).substring(2, 10) + ".mp4";
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("Video download triggered", fileName);
resolve();
})
.catch(error => {
logger.error("Video download failed", error);
reject(error);
});
});
};
// Prevent media viewer from opening
const preventMediaViewerOpen = () => {
document.addEventListener('click', (e) => {
const target = e.target;
if (window.isDownloadingBatch &&
(target.closest('.album-item') || target.closest('.media-container'))) {
const albumItem = target.closest('.album-item');
if (albumItem && albumItem.querySelector('.video-time')) {
logger.info('Preventing video popup during batch download');
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
return false;
}
}
}, true);
};
// Function to construct video URL from data-mid
const constructVideoUrl = (dataMid, peerId) => {
const patterns = [
`stream/${encodeURIComponent(JSON.stringify({
dcId: 5,
location: {
_: "inputDocumentFileLocation",
id: dataMid,
access_hash: "0",
file_reference: []
},
mimeType: "video/mp4",
fileName: `video_${dataMid}.mp4`
}))}`,
`stream/${dataMid}`,
`video/${dataMid}`,
`media/${dataMid}`
];
return patterns[0];
};
// Function to get video URL without opening media viewer
const getVideoUrlSilently = async (albumItem, dataMid) => {
logger.info(`Getting video URL silently for data-mid: ${dataMid}`);
const existingVideo = document.querySelector(`video[src*="${dataMid}"], video[data-mid="${dataMid}"]`);
if (existingVideo && (existingVideo.src || existingVideo.currentSrc)) {
const videoUrl = existingVideo.src || existingVideo.currentSrc;
logger.info(`Found existing video URL: ${videoUrl}`);
return videoUrl;
}
const peerId = albumItem.getAttribute('data-peer-id');
const constructedUrl = constructVideoUrl(dataMid, peerId);
logger.info(`Constructed video URL: ${constructedUrl}`);
try {
const response = await fetch(constructedUrl, { method: 'HEAD' });
if (response.ok) {
logger.info('Constructed URL is valid');
return constructedUrl;
}
} catch (error) {
logger.warn('Constructed URL test failed, will try alternative method');
}
return new Promise((resolve) => {
logger.info('Trying silent click method...');
window.isDownloadingBatch = true;
const mediaViewers = document.querySelectorAll('.media-viewer-whole, .media-viewer');
mediaViewers.forEach(viewer => {
viewer.style.display = 'none';
viewer.style.visibility = 'hidden';
viewer.style.pointerEvents = 'none';
});
const clickEvent = new MouseEvent('click', {
bubbles: false,
cancelable: true,
view: window
});
albumItem.dispatchEvent(clickEvent);
setTimeout(() => {
const video = document.querySelector('video');
if (video && (video.src || video.currentSrc)) {
const videoUrl = video.src || video.currentSrc;
logger.info(`Found video URL via silent click: ${videoUrl}`);
const mediaViewer = document.querySelector('.media-viewer-whole');
if (mediaViewer) {
mediaViewer.style.display = 'none';
mediaViewer.style.visibility = 'hidden';
mediaViewer.style.opacity = '0';
mediaViewer.style.pointerEvents = 'none';
const escapeEvent = new KeyboardEvent('keydown', {
key: 'Escape',
code: 'Escape',
keyCode: 27,
which: 27,
bubbles: true
});
document.dispatchEvent(escapeEvent);
}
window.isDownloadingBatch = false;
resolve(videoUrl);
} else {
logger.warn('Could not get video URL, using fallback');
window.isDownloadingBatch = false;
resolve(constructedUrl);
}
}, 100);
});
};
// Get count of selected messages (not individual media items)
const getSelectedMessageCount = () => {
const selectedBubbles = document.querySelectorAll('.bubble.is-selected');
return selectedBubbles.length;
};
// Get all media URLs from selected bubbles
const getSelectedMediaUrls = async () => {
const mediaUrls = [];
const selectedBubbles = document.querySelectorAll('.bubble.is-selected');
let processedCount = 0;
const totalBubbles = selectedBubbles.length;
window.isDownloadingBatch = true;
for (const bubble of selectedBubbles) {
logger.info('Processing bubble:', bubble.className);
const albumItems = bubble.querySelectorAll('.album-item.is-selected');
if (albumItems.length > 0) {
logger.info(`Found album with ${albumItems.length} items`);
for (let index = 0; index < albumItems.length; index++) {
const albumItem = albumItems[index];
const dataMid = albumItem.getAttribute('data-mid');
updateBatchProgress(processedCount, totalBubbles * 2, `Analyzing album item ${index + 1}...`);
const videoTime = albumItem.querySelector('.video-time');
const playButton = albumItem.querySelector('.btn-circle.video-play');
const isVideo = videoTime && playButton;
const mediaPhoto = albumItem.querySelector('.media-photo');
if (isVideo) {
logger.info(`Album item ${index + 1} is a VIDEO (duration: "${videoTime.textContent}")`);
const videoUrl = await getVideoUrlSilently(albumItem, dataMid);
if (videoUrl) {
mediaUrls.push({
type: 'video',
url: videoUrl,
dataMid: dataMid
});
}
} else if (mediaPhoto && mediaPhoto.src && !mediaPhoto.src.includes('data:')) {
logger.info(`Album item ${index + 1} is an IMAGE`);
mediaUrls.push({
type: 'image',
url: mediaPhoto.src,
dataMid: dataMid
});
}
await new Promise(resolve => setTimeout(resolve, 50));
}
} else {
updateBatchProgress(processedCount, totalBubbles, `Processing single media...`);
const videos = bubble.querySelectorAll('.media-video, video');
let hasVideo = false;
videos.forEach(video => {
const videoSrc = video.src || video.currentSrc;
if (videoSrc && !videoSrc.includes('data:')) {
mediaUrls.push({
type: 'video',
url: videoSrc
});
hasVideo = true;
logger.info('Found single video:', videoSrc);
}
});
if (!hasVideo) {
const images = bubble.querySelectorAll('.media-photo');
images.forEach(img => {
const isVideoThumbnail = img.closest('.media-video') ||
img.closest('video') ||
bubble.querySelector('.video-time') ||
bubble.querySelector('.btn-circle.video-play');
if (!isVideoThumbnail && img.src && !img.src.includes('data:')) {
mediaUrls.push({
type: 'image',
url: img.src
});
logger.info('Found single image:', img.src);
}
});
}
}
processedCount++;
}
window.isDownloadingBatch = false;
logger.info(`Total media found: ${mediaUrls.length}`);
return mediaUrls;
};
// Show Telegram-style stay on page warning
const showStayOnPageWarning = () => {
const existingWarning = document.getElementById('tg-stay-warning');
if (existingWarning) return;
// Check if dark mode is enabled
const isDarkMode = document.querySelector("html").classList.contains("night") ||
document.querySelector("html").classList.contains("theme-dark") ||
document.body.classList.contains("night") ||
document.body.classList.contains("theme-dark");
const warning = document.createElement('div');
warning.id = 'tg-stay-warning';
warning.style.cssText = `
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
background: ${isDarkMode ? 'var(--color-background-secondary, #212121)' : 'var(--color-background-secondary, #ffffff)'};
color: ${isDarkMode ? 'var(--color-text, #ffffff)' : 'var(--color-text, #000000)'};
padding: 16px 20px;
border-radius: 12px;
z-index: 999999;
font-family: var(--font-family, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif);
font-size: 14px;
font-weight: 400;
box-shadow: 0 4px 16px rgba(0, 0, 0, ${isDarkMode ? '0.4' : '0.15'});
border: 1px solid ${isDarkMode ? 'var(--color-borders, #3e3e3e)' : 'var(--color-borders, #e4e4e4)'};
max-width: 320px;
animation: slideDown 0.3s ease;
`;
warning.innerHTML = `
<div style="display: flex; align-items: flex-start; gap: 12px;">
<div style="
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--color-primary, #8774e1);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-top: 1px;
">
<span style="color: white; font-size: 12px; font-weight: bold;">!</span>
</div>
<div style="flex: 1;">
<div style="font-weight: 500; margin-bottom: 4px;">Downloading Media</div>
<div style="opacity: 0.7; font-size: 13px; line-height: 1.4;">Please stay on this page while the download is in progress.</div>
</div>
<button onclick="this.closest('#tg-stay-warning').remove()" style="
background: none;
border: none;
color: ${isDarkMode ? 'var(--color-text-secondary, #aaaaaa)' : 'var(--color-text-secondary, #707579)'};
cursor: pointer;
font-size: 18px;
padding: 0;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background-color 0.15s ease;
flex-shrink: 0;
" onmouseover="this.style.backgroundColor='${isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)'}'" onmouseout="this.style.backgroundColor='transparent'">×</button>
</div>
`;
const style = document.createElement('style');
style.textContent = `
@keyframes slideDown {
from {
transform: translateX(-50%) translateY(-10px);
opacity: 0;
scale: 0.95;
}
to {
transform: translateX(-50%) translateY(0);
opacity: 1;
scale: 1;
}
}
`;
document.head.appendChild(style);
document.body.appendChild(warning);
setTimeout(() => {
if (warning.parentNode) {
warning.style.animation = 'slideDown 0.3s ease reverse';
setTimeout(() => warning.remove(), 300);
}
}, 8000);
};
// Silent batch download
const silentBatchDownload = async () => {
logger.info('Starting silent batch download...');
showStayOnPageWarning();
const nativeSuccess = await tryNativeDownload();
if (!nativeSuccess) {
updateBatchProgress(0, 1, 'Analyzing selected media...');
const mediaUrls = await getSelectedMediaUrls();
if (mediaUrls.length === 0) {
logger.warn('No media URLs found in selected messages');
return;
}
logger.info(`Downloading ${mediaUrls.length} media items silently...`);
for (let i = 0; i < mediaUrls.length; i++) {
const media = mediaUrls[i];
try {
updateBatchProgress(i, mediaUrls.length, `Downloading ${media.type} ${i + 1}/${mediaUrls.length}...`);
if (media.type === 'image') {
tel_download_image(media.url);
} else if (media.type === 'video') {
await tel_download_video(media.url);
}
await new Promise(resolve => setTimeout(resolve, 500));
} catch (error) {
logger.error(`Failed to download ${media.type}: ${error.message}`);
}
}
updateBatchProgress(mediaUrls.length, mediaUrls.length, `Completed: ${mediaUrls.length} files downloaded`);
logger.info('Silent batch download completed');
}
};
// Try native Telegram download
const tryNativeDownload = () => {
return new Promise((resolve) => {
const firstSelected = document.querySelector('.bubble.is-selected');
if (!firstSelected) {
resolve(false);
return;
}
const rightClickEvent = new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
view: window,
button: 2,
buttons: 2,
clientX: 100,
clientY: 100
});
firstSelected.dispatchEvent(rightClickEvent);
setTimeout(() => {
const contextMenu = document.querySelector('#bubble-contextmenu');
if (contextMenu) {
contextMenu.style.display = 'none';
contextMenu.style.visibility = 'hidden';
contextMenu.style.opacity = '0';
contextMenu.style.pointerEvents = 'none';
const menuItems = contextMenu.querySelectorAll('.btn-menu-item');
let downloadFound = false;
menuItems.forEach(item => {
const textElement = item.querySelector('.btn-menu-item-text');
if (textElement && textElement.textContent.trim() === 'Download selected') {
logger.info('Using native download...');
item.click();
downloadFound = true;
}
});
setTimeout(() => {
if (contextMenu) {
contextMenu.classList.remove('active', 'was-open');
contextMenu.style.display = 'none';
}
}, 50);
resolve(downloadFound);
} else {
resolve(false);
}
}, 50);
});
};
// Create download button
const createBatchDownloadButton = () => {
const existingBtn = document.getElementById('tg-batch-download-btn');
if (existingBtn) {
// Use message count instead of individual media count
const count = getSelectedMessageCount();
const countSpan = existingBtn.querySelector('.media-count');
if (countSpan) {
countSpan.textContent = count > 0 ? count : '';
countSpan.style.display = count > 0 ? 'flex' : 'none';
}
return;
}
const downloadBtn = document.createElement('button');
downloadBtn.id = 'tg-batch-download-btn';
downloadBtn.title = 'Download Selected Files Silently';
downloadBtn.innerHTML = `
<svg class="download-icon" width="28" height="28" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 20h14v-2H5v2zM12 4v12l-4-4h3V4h2v8h3l-4 4z" fill="white" stroke="white" stroke-width="0.5"/>
</svg>
<svg class="loading-icon" width="28" height="28" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style="display: none;">
<circle cx="12" cy="12" r="10" stroke="white" stroke-width="2" fill="none" stroke-linecap="round" stroke-dasharray="31.416" stroke-dashoffset="31.416">
<animate attributeName="stroke-dasharray" dur="2s" values="0 31.416;15.708 15.708;0 31.416" repeatCount="indefinite"/>
<animate attributeName="stroke-dashoffset" dur="2s" values="0;-15.708;-31.416" repeatCount="indefinite"/>
</circle>
</svg>
<span class="media-count" style="
position: absolute;
top: -6px;
right: -6px;
background: #ff4757;
color: white;
border-radius: 11px;
width: 22px;
height: 22px;
font-size: 12px;
font-weight: bold;
display: none;
align-items: center;
justify-content: center;
box-shadow: 0 2px 6px rgba(0,0,0,0.3);
border: 2px solid white;
"></span>
`;
Object.assign(downloadBtn.style, {
position: 'fixed',
bottom: '20px',
right: '20px',
zIndex: '999999',
background: '#8774e1',
border: 'none',
borderRadius: '50%',
color: 'white',
cursor: 'pointer',
padding: '13px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '54px',
height: '54px',
boxShadow: '0 4px 16px rgba(135, 116, 225, 0.4)',
transition: 'all 0.2s ease',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'
});
downloadBtn.addEventListener('mouseenter', () => {
if (!downloadBtn.disabled) {
downloadBtn.style.background = '#7c6ce0';
downloadBtn.style.transform = 'scale(1.05)';
}
});
downloadBtn.addEventListener('mouseleave', () => {
if (!downloadBtn.disabled) {
downloadBtn.style.background = '#8774e1';
downloadBtn.style.transform = 'scale(1)';
}
});
downloadBtn.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
const count = getSelectedMessageCount();
if (count === 0) {
alert('Please select some messages first');
return;
}
downloadBtn.disabled = true;
downloadBtn.style.cursor = 'wait';
downloadBtn.querySelector('.download-icon').style.display = 'none';
downloadBtn.querySelector('.loading-icon').style.display = 'block';
downloadBtn.title = 'Downloading... Please stay on this page';
logger.info(`Silent batch download started for ${count} selected messages...`);
try {
await silentBatchDownload();
} catch (error) {
logger.error('Batch download failed:', error);
}
downloadBtn.disabled = false;
downloadBtn.style.cursor = 'pointer';
downloadBtn.querySelector('.download-icon').style.display = 'block';
downloadBtn.querySelector('.loading-icon').style.display = 'none';
downloadBtn.title = 'Download Selected Files Silently';
});
document.body.appendChild(downloadBtn);
logger.info('Silent batch download button created');
};
// Monitor selection changes
const monitorSelection = () => {
const observer = new MutationObserver(() => {
setTimeout(createBatchDownloadButton, 100);
});
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['class']
});
};
// Initialize
const init = () => {
logger.info('Initializing silent Telegram downloader...');
createBatchProgress();
createBatchDownloadButton();
monitorSelection();
preventMediaViewerOpen();
setInterval(createBatchDownloadButton, 2000);
logger.info('Silent downloader ready!');
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
setTimeout(init, 1000);
}
logger.info("Silent Telegram Media Downloader initialized.");
})();