Telegram Media Downloader (Batch Support) (by AFU IT) v1.2

Download images, GIFs, videos, and voice messages from private channels + batch download selected media

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

})();