From 044f55d497b775cc47c846c4f05e86c14ed8d663 Mon Sep 17 00:00:00 2001 From: Didar Shayarov Date: Sun, 23 Feb 2025 23:43:13 +0300 Subject: [PATCH 1/2] feat: New audio downloading mechanism --- src/tel_download.js | 455 ++++++++++++++++++++++++++++---------------- 1 file changed, 290 insertions(+), 165 deletions(-) diff --git a/src/tel_download.js b/src/tel_download.js index cc0852f..7ed82bb 100644 --- a/src/tel_download.js +++ b/src/tel_download.js @@ -4,7 +4,7 @@ // @name:zh-CN Telegram 受限图片视频下载器 // @name:zh-TW Telegram 受限圖片影片下載器 // @name:ru Telegram: загрузчик медиафайлов -// @version 1.206 +// @version 1.205 // @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 @@ -52,6 +52,217 @@ return h >>> 0; }; + let observer; + + const initObserver = () => { + if (!observer) { + console.log('init observer') + observer = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + if (entry.name.includes("/progressive/document")) { + handleInterceptedRequest(entry); + } + } + }); + observer.observe({ type: "resource", buffered: true }); + } + } + + const interceptRequests = (messageId, duration, fileName) => { + registerMessageForIntercept(messageId, duration, fileName) + } + + const handleInterceptedRequest = (entry) => { + const url = entry.name; + for (const [messageId, audioElement] of audioDownloadsMap.entries()) { + if (!audioElement.urls.has(url)) { + tel_download_audio(url, messageId); + break; + } + } + }; + + const registerMessageForIntercept = (messageId, duration, fileName) => { + if (!audioDownloadsMap.has(messageId)) { + initAudioDownload(messageId, duration, fileName) + } + initObserver(); + }; + + const unregisterMessage = (messageId) => { + document.querySelector(`#${messageId} .download-button`).remove() + audioDownloadsMap.delete(messageId); + }; + + const audioDownloadsMap = new Map(); + + const initAudioDownload = (messageId, duration, fileName) => { + const audioDownloadObject = { + urls: new Set(), + blobs: new Map(), + progressBar: null, + duration, + fileName + } + audioDownloadsMap.set(messageId, audioDownloadObject); + } + + const downloadMenuItemElement = () => { + const menuItem = document.createElement("div"); + menuItem.setAttribute("role", "menuitem"); + menuItem.setAttribute("tabindex", "0"); + menuItem.className = "MenuItem compact"; + menuItem.innerHTML = ` + + Download + `; + return menuItem; + } + + const downloadButtonElement = (stopDownload = false) => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'Button download-button tiny primary round'; + btn.setAttribute('aria-label', `${stopDownload ? 'Cancel d' : 'D'}ownload`); + btn.setAttribute('title', `${stopDownload ? 'Cancel d' : 'D'}ownload`); + btn.innerHTML = ``; + return btn + } + + const downloadSpinnerElement = () => { + const spinner = document.createElement('div'); + spinner.className = 'media-loading opacity-transition fast open shown'; // not-open closing + spinner.innerHTML = ` +
+ +
+ `; + return spinner + } + + const formatProgress = (downloaded, total) => { + const mbDownloaded = (downloaded / 1024 / 1024).toFixed(1); + const mbTotal = (total / 1024 / 1024).toFixed(1); + return `${mbDownloaded} MB / ${mbTotal} MB`; + }; + + const addAudioDownloadButton = () => { + + const audioElements = document.querySelectorAll('.Audio'); + + audioElements.forEach((audioElement) => { + + const messageElement = audioElement.closest('.Message.message-list-item'); + if (!messageElement) return; + + const messageId = messageElement.getAttribute('id'); + + const existingDownloadButton = audioElement.querySelector('.download-button'); + + if (!existingDownloadButton) { + const playButton = audioElement.querySelector('button.Button.toggle-play'); + if (!playButton) { + return; + } + const button = downloadButtonElement(); + + button.onclick = () => { + + const duration = audioElement.querySelector('.duration').textContent; + const fileName = audioElement.querySelector('.title').textContent; + + imitateClick(playButton); + + setTimeout(() => interceptRequests(messageId, duration, fileName), 2000); + }; + + audioElement.querySelector('.toogle-play-wrapper') + .after(button); + + } else if (audioDownloadsMap.has(messageId)) { /* downloading */ + + let downloadBtn = audioElement.querySelector('.download-button'); + if (!audioElement.querySelector('.icon-close')) { + downloadBtn.replaceWith(downloadButtonElement(true)); + } + + const spinner = downloadSpinnerElement() + + if (!audioElement.querySelector('.media-loading')) { + downloadBtn.insertAdjacentElement('beforebegin', spinner); + } + + // update progress bar + const metaDiv = audioElement.querySelector('.meta') + metaDiv.innerHTML = audioDownloadsMap.get(messageId).progressBar; + + // прекратить загрузку + downloadBtn.onclick = () => { + const span = document.createElement('span'); + span.className = 'duration' + span.dir = 'auto'; + span.innerHTML = audioDownloadsMap.get(messageId).duration; + + audioDownloadsMap.delete(messageId); + + audioElement.querySelector('.media-loading').remove(); + + metaDiv.replaceChildren(span); + + unregisterMessage(messageId); + } + } + }); + } + + const addContextMenuDownloadItem = () => { + + const contextMenu = document.querySelector('.MessageContextMenu_items') + + if (!contextMenu) { + return; + } + + const playButton = contextMenu.closest('.Message').querySelector('button.Button.toggle-play') + if (!playButton) { + return; /* Skip if there's no audio */ + } + + const downloadButton = Array.from(contextMenu.querySelectorAll('.MenuItem.compact')) + .find(item => item.textContent.includes('Download')); + + if (downloadButton) { + return; /* Skip if there's already a download button */ + } + + const menuItem = downloadMenuItemElement(); + contextMenu.appendChild(menuItem); + + const messageElement = contextMenu.closest('.Message.message-list-item'); + if (!messageElement) return; + + const messageId = messageElement.getAttribute('id'); + + menuItem.onclick = function () { + + contextMenu.parentNode.removeChild(contextMenu); + const duration = messageElement.querySelector('.duration').textContent + const fileName = messageElement.querySelector('.title').textContent + + imitateClick(playButton) + initAudioDownload(messageId, duration, fileName) + + setTimeout(() => interceptRequests(messageId, duration, fileName), 2000); + }; + } + + const imitateClick = (buttonElement) => { + buttonElement.dispatchEvent(new MouseEvent('mousemove', {bubbles: true, clientX: 100, clientY: 100})); + buttonElement.dispatchEvent(new MouseEvent('mousedown', {bubbles: true, clientX: 100, clientY: 100})); + buttonElement.dispatchEvent(new MouseEvent('mouseup', {bubbles: true, clientX: 100, clientY: 100})); + buttonElement.dispatchEvent(new MouseEvent('click', {bubbles: true, clientX: 100, clientY: 100})); + } + const createProgressBar = (videoId, fileName) => { const isDarkMode = document.querySelector("html").classList.contains("night") || @@ -320,105 +531,88 @@ } }; - const tel_download_audio = (url) => { - let _blobs = []; + const tel_download_audio = (url, messageId) => { + + if (audioDownloadsMap.has(messageId) && audioDownloadsMap.get(messageId).urls.has(url)) { + return; + } + + const downloadData = audioDownloadsMap.get(messageId) let _next_offset = 0; let _total_size = null; - const fileName = hashCode(url).toString(36) + ".ogg"; + const fileName = audioDownloadsMap.get(messageId).fileName - const fetchNextPart = (_writable) => { - fetch(url, { - method: "GET", - headers: { - Range: `bytes=${_next_offset}-`, - }, - }) - .then((res) => { - if (res.status !== 206 && res.status !== 200) { - logger.error( - "Non 200/206 response was received: " + res.status, - fileName - ); - return; - } + downloadData.urls.add(url) - const mime = res.headers.get("Content-Type").split(";")[0]; - if (!mime.startsWith("audio/")) { - logger.error( - "Get non audio response with MIME type " + mime, - fileName - ); - throw "Get non audio response with MIME type " + mime; - } + if (!downloadData.blobs.get(messageId)) { + downloadData.blobs.set(url, []) + } - try { - const match = res.headers - .get("Content-Range") - .match(contentRangeRegex); + const fetchNextPart = async () => { + try { + const response = await fetch(url, { + method: "GET", + headers: { + Range: `bytes=${_next_offset}-`, + }, + }); - const startOffset = parseInt(match[1]); - const endOffset = parseInt(match[2]); - const totalSize = parseInt(match[3]); + if (response.status !== 206 && response.status !== 200) { + logger.error( + "Non 200/206 response was received: " + res.status, + fileName + ); + throw new Error(`Non 200/206 response was received: ${response.status}`); + } - if (startOffset !== _next_offset) { - logger.error("Gap detected between responses."); - logger.info("Last offset: " + _next_offset); - logger.info("New start offset " + match[1]); - throw "Gap detected between responses."; - } - if (_total_size && totalSize !== _total_size) { - logger.error("Total size differs"); - throw "Total size differs"; - } + const mime = response.headers.get("Content-Type").split(";")[0]; + if (!mime.startsWith("audio/")) { + logger.error( + "Get non audio response with MIME type " + mime, + fileName + ); + throw new Error(`Get non audio response with MIME type ${mime}`); + } - _next_offset = endOffset + 1; - _total_size = totalSize; - } finally { - logger.info( - `Get response: ${res.headers.get( - "Content-Length" - )} bytes data from ${res.headers.get("Content-Range")}` - ); - return res.blob(); - } - }) - .then((resBlob) => { - if (_writable !== null) { - _writable.write(resBlob).then(() => {}); - } else { - _blobs.push(resBlob); - } - }) - .then(() => { - if (_next_offset < _total_size) { - fetchNextPart(_writable); - } else { - if (_writable !== null) { - _writable.close().then(() => { - logger.info("Download finished", fileName); - }); - } else { - save(); - } - } - }) - .catch((reason) => { - logger.error(reason, fileName); - }); - }; + const match = response.headers.get("Content-Range").match(contentRangeRegex); + const startOffset = parseInt(match[1]); + const endOffset = parseInt(match[2]); + const totalSize = parseInt(match[3]); - const save = () => { - logger.info( - "Finish downloading blobs. Concatenating blobs and downloading...", - fileName - ); + if (startOffset !== _next_offset) { + logger.error("Gap detected between responses."); + logger.info("Last offset: " + _next_offset); + logger.info("New start offset " + match[1]); + throw new Error("Gap detected between responses."); + } + if (_total_size && totalSize !== _total_size) { + logger.error("Total size differs"); + throw new Error("Total size differs"); + } - let blob = new Blob(_blobs, { type: "audio/ogg" }); - const blobUrl = window.URL.createObjectURL(blob); + _next_offset = endOffset + 1; + _total_size = totalSize; - logger.info("Final blob size in bytes: " + blob.size, fileName); + const blob = await response.blob(); + downloadData.blobs.get(url).push(blob); - blob = 0; + downloadData.progressBar = formatProgress(_next_offset, _total_size); + + if (_next_offset < _total_size) { + fetchNextPart(); + } else { + save(messageId); + } + } catch (error) { + logger.error(error.message, fileName); + unregisterMessage(messageId) + } + }; + + const save = () => { + const chunks = audioDownloadsMap.get(messageId).blobs.get(url); + const fullBlob = new Blob(chunks, {type: "audio/ogg"}); + const blobUrl = window.URL.createObjectURL(fullBlob); const a = document.createElement("a"); document.body.appendChild(a); @@ -428,42 +622,14 @@ document.body.removeChild(a); window.URL.revokeObjectURL(blobUrl); + unregisterMessage(messageId); + logger.info("Download triggered", fileName); }; - const supportsFileSystemAccess = - "showSaveFilePicker" in unsafeWindow && - (() => { - try { - return unsafeWindow.self === unsafeWindow.top; - } catch { - return false; - } - })(); - if (supportsFileSystemAccess) { - unsafeWindow - .showSaveFilePicker({ - suggestedName: fileName, - }) - .then((handle) => { - handle - .createWritable() - .then((writable) => { - fetchNextPart(writable); - }) - .catch((err) => { - console.error(err.name, err.message); - }); - }) - .catch((err) => { - if (err.name !== "AbortError") { - console.error(err.name, err.message); - } - }); - } else { - fetchNextPart(null); - } - }; + fetchNextPart(); + } + const tel_download_image = (imageUrl) => { const fileName = @@ -637,50 +803,9 @@ // For webk /k/ webapp setInterval(() => { - /* Voice Message or Circle Video */ - const pinnedAudio = document.body.querySelector(".pinned-audio"); - let dataMid; - let downloadButtonPinnedAudio = - document.body.querySelector("._tel_download_button_pinned_container") || - document.createElement("button"); - if (pinnedAudio) { - dataMid = pinnedAudio.getAttribute("data-mid"); - downloadButtonPinnedAudio.className = - "btn-icon tgico-download _tel_download_button_pinned_container"; - downloadButtonPinnedAudio.innerHTML = `${DOWNLOAD_ICON}`; - } - const audioElements = document.body.querySelectorAll("audio-element"); - audioElements.forEach((audioElement) => { - const bubble = audioElement.closest(".bubble"); - if ( - !bubble || - bubble.querySelector("._tel_download_button_pinned_container") - ) { - return; /* Skip if there's already a download button */ - } - if ( - dataMid && - downloadButtonPinnedAudio.getAttribute("data-mid") !== dataMid && - audioElement.getAttribute("data-mid") === dataMid - ) { - downloadButtonPinnedAudio.onclick = (e) => { - e.stopPropagation(); - if (isAudio) { - tel_download_audio(link); - } else { - tel_download_video(link); - } - }; - downloadButtonPinnedAudio.setAttribute("data-mid", dataMid); - const link = audioElement.audio && audioElement.audio.getAttribute("src"); - const isAudio = audioElement.audio && audioElement.audio instanceof HTMLAudioElement - if (link) { - pinnedAudio - .querySelector(".pinned-container-wrapper-utils") - .appendChild(downloadButtonPinnedAudio); - } - } - }); + /* Voice Message */ + addContextMenuDownloadItem() + addAudioDownloadButton() // Stories const storiesContainer = document.getElementById("stories-viewer"); From ecd2ff705b600136da0a58b8a0af1db0174a809c Mon Sep 17 00:00:00 2001 From: Didar Shayarov Date: Sun, 23 Feb 2025 23:50:28 +0300 Subject: [PATCH 2/2] fix: minor --- src/tel_download.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/tel_download.js b/src/tel_download.js index 7ed82bb..4c389cb 100644 --- a/src/tel_download.js +++ b/src/tel_download.js @@ -4,7 +4,7 @@ // @name:zh-CN Telegram 受限图片视频下载器 // @name:zh-TW Telegram 受限圖片影片下載器 // @name:ru Telegram: загрузчик медиафайлов -// @version 1.205 +// @version 1.206 // @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 @@ -196,7 +196,6 @@ const metaDiv = audioElement.querySelector('.meta') metaDiv.innerHTML = audioDownloadsMap.get(messageId).progressBar; - // прекратить загрузку downloadBtn.onclick = () => { const span = document.createElement('span'); span.className = 'duration'