diff --git a/docs/greasyfork/id-ID.md b/docs/greasyfork/id-ID.md new file mode 100644 index 0000000..9b01c1d --- /dev/null +++ b/docs/greasyfork/id-ID.md @@ -0,0 +1,40 @@ +# Pengunduh Media Telegram + +**Buka Kunci Telegram: Unduh Apa Pun yang Ingin Anda Gunakan.** + +Skrip ini membuka kunci dan memungkinkan pengunduhan gambar, GIF, dan video di aplikasi web Telegram dari obrolan, cerita, dan bahkan saluran pribadi tempat pengunduhan dinonaktifkan atau dibatasi. + +Penting: Skrip ini **GRATIS** untuk digunakan. Jika Anda melihat sesuatu yang meminta Anda untuk membayar untuk mengunduh, jangan bayar dan laporkan ke [GitHub issues](https://github.com/Neet-Nestor/Telegram-Media-Downloader/issues) atau area komentar untuk memberitahu pengembang. + +## Cara Menginstal + +Instal ekstensi userscript dan klik tombol "instal" di atas untuk menginstal skrip. + +**Penting:** Jika Anda menggunakan ekstensi Tampermonkey di browser berbasis Chrome, ikuti [petunjuk di sini](https://www.tampermonkey.net/faq.php#Q209) untuk mengaktifkan Mode Pengembang. + + +## Cara Menggunakan +Skrip ini hanya berfungsi di Telegram Webapp. Ini menambahkan tombol unduh untuk gambar, GIF, dan video. + +![Image Download](https://media2.giphy.com/media/v1.Y2lkPTc5MGI3NjExY2VjNmU2ZDM0YTFlOWY4YTMzZDZmNjVlMDE2ODQ4OGY4N2E3MDFkNSZlcD12MV9pbnRlcm5hbF9naWZzX2dpZklkJmN0PWc/lqCVcw0pCd2VA3zqoE/giphy.gif) +![GIF Download](https://media0.giphy.com/media/v1.Y2lkPTc5MGI3NjExMzYwMzM3ZTMzYmI1MzA4M2EyYmY0NTFlOTg4OWFhNjhjNDk5YTkzYiZlcD12MV9pbnRlcm5hbF9naWZzX2dpZklkJmN0PWc/wnYzW4vwpPdeuo62nQ/giphy.gif) +![Video Download](https://media1.giphy.com/media/v1.Y2lkPTc5MGI3NjExMXcxYnJxaXMxcW05YW5rZ2YzZzE0bTU4aTBwYXI1N3pmdnVzbDFrdSZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/EEPbblwmSpteAmwLls/giphy.gif) +![Story Download](https://media0.giphy.com/media/v1.Y2lkPTc5MGI3NjExZ3Z5Y2VzM2QzbW1xc3ZwNTQ2N3Q0a3lnanpxdW55c2Qzajl5NXZsaCZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/xJFjBGi8isHPR5cuHl/giphy.gif) + +Untuk video, bilah kemajuan akan ditampilkan di sudut kanan bawah setelah Anda mulai mengunduh. Untuk gambar dan audio, tidak akan ada bilah kemajuan. + +### Versi Webapp yang Didukung +Ada 2 versi berbeda dari aplikasi web Telegram: +- https://webk.telegram.org / https://web.telegram.org/k/ (**Direkomendasikan**) +- https://webz.telegram.org / https://web.telegram.org/a/ + +Skrip ini harus berfungsi di kedua versi aplikasi web, tetapi beberapa fitur hanya tersedia di versi /k/ (seperti pengunduhan pesan suara). Jika fitur tertentu tidak berfungsi, disarankan untuk beralih ke versi /k/. + +### Periksa Kemajuan Pengunduhan +Bilah kemajuan akan ditampilkan di sudut kanan bawah layar untuk video. Anda juga dapat memeriksa [konsol DevTools](https://developer.chrome.com/docs/devtools/open/) untuk log. + +## Dukung Penulis +Jika Anda menyukai skrip ini, Anda dapat mendukung saya melalui [Venmo](https://venmo.com/u/NeetNestor) atau [belikan saya kopi](https://ko-fi.com/neetnestor) :) + +## Hubungi +Jika Anda memiliki masalah menggunakan skrip ini, silakan hubungi [halaman GitHub](https://github.com/Neet-Nestor/Telegram-Media-Downloader) kami dan buat masalah. diff --git a/src/tel_download.js b/src/tel_download.js index 8971bba..c34befb 100644 --- a/src/tel_download.js +++ b/src/tel_download.js @@ -52,6 +52,194 @@ return h >>> 0; }; + const sanitizeFilename = (s) => { + try { + return String(s).replace(/[^a-z0-9\.\-_]/gi, '_'); + } catch (e) { return String(s); } + }; + + // Extract ID portion from blob URLs robustly. + const extractBlobIdFromUrl = (u) => { + try { + const s = String(u || ''); + // take last path segment and try to match UUID-like pattern + const last = s.split('/').pop(); + const m = last && last.match(/([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})/); + if (m) return m[1]; + // fallback: return last segment if reasonably short + if (last && last.length <= 64) return last; + return null; + } catch (e) { + return null; + } + }; + + // Parse filename from Content-Disposition header if present + const parseFilenameFromContentDisposition = (hdr) => { + try { + if (!hdr) return null; + // Fits common patterns: attachment; filename="name.ext" or filename=name.ext + const m = hdr.match(/filename\*?=(?:UTF-8''\s*)?"?([^";]+)"?/i); + if (m && m[1]) return m[1].trim(); + return null; + } catch (e) { return null; } + }; + + // Centralized fetch-and-save helper: fetch URL, derive filename (hint/itemMid/blobId/hash), save blob + const fetchAndSaveUrl = async (url, { filenameHint = null, itemMid = null } = {}) => { + const res = await fetch(url); + if (!res.ok) throw new Error('Fetch failed: ' + res.status); + // Try filename from headers first + const cd = res.headers.get('Content-Disposition'); + let filenameFromCd = parseFilenameFromContentDisposition(cd); + const blob = await res.blob(); + const contentType = res.headers.get('Content-Type') || blob.type || ''; + const ext = (contentType.split('/')[1] || '').split(';')[0] || ''; + + const candidates = []; + if (filenameHint) candidates.push(sanitizeFilename(filenameHint)); + if (itemMid) candidates.push(sanitizeFilename(itemMid)); + if (filenameFromCd) candidates.push(sanitizeFilename(filenameFromCd)); + const blobId = extractBlobIdFromUrl(url); + if (blobId) candidates.push(sanitizeFilename(blobId)); + + const base = candidates.find(Boolean); + if (!base) throw new Error('No filename could be determined (no hint, mid, or blob ID)'); + const finalExt = ext || 'bin'; + const finalName = base + '.' + finalExt; + + const blobUrl = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + document.body.appendChild(a); + a.href = blobUrl; + a.download = finalName; + a.click(); + document.body.removeChild(a); + window.URL.revokeObjectURL(blobUrl); + + return finalName; + }; + + // Viewer lock — ensure only one viewer is open at a time + const viewerLockQueue = []; + let viewerLocked = false; + const acquireViewerLock = async () => { + if (!viewerLocked) { + viewerLocked = true; + return; + } + return new Promise((resolve) => viewerLockQueue.push(resolve)); + }; + const releaseViewerLock = () => { + if (viewerLockQueue.length > 0) { + const next = viewerLockQueue.shift(); + next(); + } else { + viewerLocked = false; + } + }; + + // Probe the opened media viewer to find a playable stream URL. + // Returns { url, contentType, lockAcquired } if found or not, but when lockAcquired is true the caller + // must call releaseViewerLock() after it closes the viewer (so the viewer remains unique during download). + const probeViewerStream = async (item, { maxAttempts = 3, timeout = 12000 } = {}) => { + // Acquire viewer lock so only one probe/download sequence manipulates the viewer at a time + await acquireViewerLock(); + let lockAcquired = true; + + // Choose the element that most reliably opens the viewer. Some album items don't + // have an anchor — the clickable target is the media element itself (img/.album-item-media/.media-container) + let opener = item; + if (item && item.querySelector) { + opener = item.querySelector('a, .album-item-media, .media-container, .media-photo, img, .thumbnail, .canvas-thumbnail') || item; + } + const playBtn = item && (item.querySelector('.video-play, .btn-circle.video-play, .toggle') || null); + + const prevSuppress = typeof telSuppressMediaError !== 'undefined' ? telSuppressMediaError : false; + telSuppressMediaError = true; + + try { + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + // Close any existing viewer first to avoid stale slides being detected + try { + const existingClose = document.querySelector('#MediaViewer button[aria-label="Close"], #MediaViewer button[title="Close"], .media-viewer-whole .close'); + if (existingClose) { + existingClose.click(); + await new Promise((r) => setTimeout(r, 200)); + } + } catch (e) {} + + if (playBtn) { + try { playBtn.click(); logger.info('Clicked in-item play button (probe attempt ' + attempt + ')'); } catch (e) { logger.info('Play button click failed during probe: ' + (e?.message || e)); } + } + try { opener && opener.click(); } catch (e) { logger.info('Opener click failed during probe: ' + (e?.message || e)); } + + // Wait for viewer to open and stabilize + await new Promise((r) => setTimeout(r, 500)); + + const start = Date.now(); + while (Date.now() - start < timeout) { + try { + const v = document.querySelector('#MediaViewer .MediaViewerSlide--active video, .media-viewer-whole video, video.media-video, .ckin__player video'); + if (v) { + const s = v.currentSrc || v.src; + if (s) { + try { + const href = new URL(s, location.href).href; + // HEAD check to avoid HTML/service-worker pages + let contentType = null; + try { + const headRes = await fetch(href, { method: 'HEAD' }); + contentType = headRes.headers.get('Content-Type') || ''; + } catch (e) { logger.info('HEAD check failed for probe URL: ' + href + ' (' + (e?.message || e) + ')'); } + + if (!contentType || contentType.indexOf('text/html') !== 0) { + return { url: href, contentType, lockAcquired }; + } + } catch (e) { + return { url: s, contentType: null, lockAcquired }; + } + } + } + + const streamLink = document.querySelector('#MediaViewer a[href*="stream/"]')?.href || document.querySelector('.media-viewer-whole a[href*="stream/"]')?.href; + if (streamLink) { + let contentType = null; + try { + const headRes = await fetch(streamLink, { method: 'HEAD' }); + contentType = headRes.headers.get('Content-Type') || ''; + } catch (e) { logger.info('HEAD check failed for probe stream link: ' + streamLink + ' (' + (e?.message || e) + ')'); } + if (!contentType || contentType.indexOf('text/html') !== 0) return { url: streamLink, contentType, lockAcquired }; + } + } catch (e) { + logger.error('Error while polling viewer during probe: ' + (e?.message || e)); + } + await new Promise((r) => setTimeout(r, 200)); + } + + if (attempt < maxAttempts) { + const backoff = 500 * attempt; + logger.info('Viewer probe no result, retrying after ' + backoff + 'ms (attempt ' + (attempt + 1) + ')'); + await new Promise((r) => setTimeout(r, backoff)); + } + } catch (e) { + logger.error('Unexpected error in probe attempt: ' + (e?.message || e)); + } + } + } catch (e) { + // Ensure we never let an exception leak while holding the viewer lock — return with lockAcquired so caller can release it. + logger.error('Unexpected error in probeViewerStream: ' + (e?.message || e)); + return { lockAcquired }; + } finally { + telSuppressMediaError = prevSuppress; + } + + // No URL found but lock was acquired — caller should release when viewer is closed + return { lockAcquired }; + }; + + const createProgressBar = (videoId, fileName) => { const isDarkMode = document.querySelector("html").classList.contains("night") || @@ -79,12 +267,17 @@ title.innerText = fileName; const closeButton = document.createElement("div"); - closeButton.style.cursor = "pointer"; + closeButton.className = "tel-progress-close"; closeButton.style.fontSize = "1.2rem"; - closeButton.style.color = isDarkMode ? "#8a8a8a" : "white"; + closeButton.style.color = isDarkMode ? "#4a4a4a" : "#888"; + closeButton.style.cursor = "not-allowed"; + closeButton.style.opacity = "0.5"; closeButton.innerHTML = "×"; closeButton.onclick = function () { - container.removeChild(innerContainer); + // Only allow close if download is completed or aborted + if (closeButton.dataset.canClose === "true") { + container.removeChild(innerContainer); + } }; const progressBar = document.createElement("div"); @@ -123,31 +316,124 @@ const innerContainer = document.getElementById( "tel-downloader-progress-" + videoId ); + if (!innerContainer) { + logger.info(`Progress UI closed but download continues: ${progress}%`, fileName); + return; + } innerContainer.querySelector("p.filename").innerText = fileName; const progressBar = innerContainer.querySelector("div.progress"); - progressBar.querySelector("p").innerText = progress + "%"; - progressBar.querySelector("div").style.width = progress + "%"; + const pct = parseInt(progress, 10); + progressBar.querySelector("p").innerText = pct + "%"; + progressBar.querySelector("div").style.width = pct + "%"; + if (pct >= 100) { + // Treat 100% as completion and trigger completion handler + completeProgress(videoId); + } }; + const downloadCompletionResolvers = new Map(); + const completeProgress = (videoId) => { - const progressBar = document - .getElementById("tel-downloader-progress-" + videoId) - .querySelector("div.progress"); + const container = document.getElementById("tel-downloader-progress-" + videoId); + if (!container) return; + const progressBar = container.querySelector("div.progress"); progressBar.querySelector("p").innerText = "Completed"; progressBar.querySelector("div").style.backgroundColor = "#B6C649"; progressBar.querySelector("div").style.width = "100%"; + + // Enable close button + const closeBtn = container.querySelector(".tel-progress-close"); + if (closeBtn) { + closeBtn.dataset.canClose = "true"; + closeBtn.style.cursor = "pointer"; + closeBtn.style.opacity = "1"; + const isDarkMode = document.querySelector("html").classList.contains("night") || document.querySelector("html").classList.contains("theme-dark"); + closeBtn.style.color = isDarkMode ? "#8a8a8a" : "white"; + } + + const r = downloadCompletionResolvers.get(videoId); + if (r && r.resolve) { + r.resolve(); + downloadCompletionResolvers.delete(videoId); + } + + // Auto-close completed progress after a short delay + setTimeout(() => { + try { + const c = document.getElementById("tel-downloader-progress-" + videoId); + if (c && c.parentNode) c.parentNode.removeChild(c); + } catch (e) {} + }, 5000); }; - const AbortProgress = (videoId) => { - const progressBar = document - .getElementById("tel-downloader-progress-" + videoId) - .querySelector("div.progress"); + const AbortProgress = (videoId, err) => { + const container = document.getElementById("tel-downloader-progress-" + videoId); + if (!container) return; + const progressBar = container.querySelector("div.progress"); progressBar.querySelector("p").innerText = "Aborted"; progressBar.querySelector("div").style.backgroundColor = "#D16666"; progressBar.querySelector("div").style.width = "100%"; + + // Enable close button + const closeBtn = container.querySelector(".tel-progress-close"); + if (closeBtn) { + closeBtn.dataset.canClose = "true"; + closeBtn.style.cursor = "pointer"; + closeBtn.style.opacity = "1"; + const isDarkMode = document.querySelector("html").classList.contains("night") || document.querySelector("html").classList.contains("theme-dark"); + closeBtn.style.color = isDarkMode ? "#8a8a8a" : "white"; + } + + const r = downloadCompletionResolvers.get(videoId); + if (r && r.reject) { + r.reject(err || new Error('Aborted')); + downloadCompletionResolvers.delete(videoId); + } }; - const tel_download_video = (url) => { + // Download queue (max parallel downloads) and worker wrapper + const MAX_CONCURRENT_DOWNLOADS = 5; + let activeDownloadCount = 0; + const downloadQueue = []; + + const processDownloadQueue = () => { + if (activeDownloadCount >= MAX_CONCURRENT_DOWNLOADS || downloadQueue.length === 0) return; + const job = downloadQueue.shift(); + activeDownloadCount++; + // Notify job that it has started before running worker + try { + if (job.startResolve && typeof job.startResolve === 'function') { + try { job.startResolve(); } catch (e) {} + } + } catch (e) {} + + (async () => { + try { + const res = await tel_download_video_worker(job.url, job.filenameHint); + job.resolve(res); + } catch (e) { + job.reject(e); + } finally { + activeDownloadCount--; + processDownloadQueue(); + } + })(); + }; + + const tel_download_video = (url, filenameHint) => { + let startResolve; + const started = new Promise((r) => { startResolve = r; }); + const completion = new Promise((resolve, reject) => { + downloadQueue.push({ url, filenameHint, resolve, reject, startResolve }); + processDownloadQueue(); + }); + // Attach started promise so callers can await download start + completion.started = started; + return completion; + }; + + // Worker that actually performs the download; kept separate so tel_download_video can be a queued wrapper + const tel_download_video_worker = async (url, filenameHint) => { let _blobs = []; let _next_offset = 0; let _total_size = null; @@ -157,20 +443,54 @@ (Math.random() + 1).toString(36).substring(2, 10) + "_" + Date.now().toString(); - let fileName = hashCode(url).toString(36) + "." + _file_extension; - // Some video src is in format: - // 'stream/{"dcId":5,"location":{...},"size":...,"mimeType":"video/mp4","fileName":"xxxx.MP4"}' + // Filename determination: + // Priority: filenameHint (if provided) -> metadata.fileName (stream/ JSON) -> blob-id (if blob URL) -> hash(url) + let baseName = null; + let fileName = null; + if (filenameHint) baseName = sanitizeFilename(filenameHint); + + // Promise that resolves when download completes (or rejects on abort) + const completionPromise = new Promise((resolve, reject) => { + downloadCompletionResolvers.set(videoId, { resolve, reject }); + }); + + // Try to extract embedded filename from URL metadata if present try { - const metadata = JSON.parse( - decodeURIComponent(url.split("/")[url.split("/").length - 1]) - ); - if (metadata.fileName) { + const metaRaw = decodeURIComponent(url.split("/")[url.split("/").length - 1]); + const metadata = JSON.parse(metaRaw); + if (metadata && metadata.fileName) { fileName = metadata.fileName; } } catch (e) { - // Invalid JSON string, pass extracting fileName + // ignore invalid metadata + } + + if (!baseName && !fileName) { + // Try to pull filename from any encoded JSON metadata inside the URL + try { + const decoded = decodeURIComponent(url || ""); + const m = decoded.match(/['\"]fileName['\"]\s*:\s*['\"]([^'\"]+)['\"]/i); + if (m && m[1]) { + fileName = m[1]; + } + } catch (e) {} + + // Fallback to blob id extracted from URL path + if (!fileName) { + const blobId = extractBlobIdFromUrl(url); + if (blobId) baseName = sanitizeFilename(blobId); + } } + + // Final fallback: derive a stable name from URL hash so download never fails + if (!fileName && !baseName) { + baseName = sanitizeFilename('download_' + hashCode(String(url || ''))); + logger.info('No filename hint, metadata, or blob id; using fallback name: ' + baseName); + } + + if (!fileName) fileName = baseName + "." + _file_extension; + logger.info(`URL: ${url}`, fileName); const fetchNextPart = (_writable) => { @@ -216,16 +536,6 @@ _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 - ); - logger.info( - `Progress: ${((_next_offset * 100) / _total_size).toFixed(0)}%`, - fileName - ); updateProgress( videoId, fileName, @@ -255,7 +565,6 @@ } else { save(); } - completeProgress(videoId); } }) .catch((reason) => { @@ -282,6 +591,8 @@ window.URL.revokeObjectURL(blobUrl); logger.info("Download triggered", fileName); + + completeProgress(videoId); }; const supportsFileSystemAccess = @@ -307,24 +618,32 @@ }) .catch((err) => { console.error(err.name, err.message); + AbortProgress(videoId, err); }); }) .catch((err) => { if (err.name !== "AbortError") { console.error(err.name, err.message); + AbortProgress(videoId, err); } }); } else { fetchNextPart(null); createProgressBar(videoId); } + + return completionPromise; }; const tel_download_audio = (url) => { let _blobs = []; let _next_offset = 0; let _total_size = null; - const fileName = hashCode(url).toString(36) + ".ogg"; + const blobId = extractBlobIdFromUrl(url); + if (!blobId) { + throw new Error('No filename could be determined for audio (no blob ID)'); + } + const fileName = sanitizeFilename(blobId) + ".ogg"; const fetchNextPart = (_writable) => { fetch(url, { @@ -465,22 +784,69 @@ } }; - const tel_download_image = (imageUrl) => { - const fileName = - (Math.random() + 1).toString(36).substring(2, 10) + ".jpeg"; // assume jpeg + const tel_download_image = async (imageUrl, filenameHint) => { + // Try to preserve a sensible extension and filename. Use filenameHint first, then blob id, then a random name. + let ext = 'jpeg'; + try { + const m = String(imageUrl).match(/\.([a-z0-9]{2,5})(?:[?#]|$)/i); + if (m && m[1]) ext = m[1]; + else if (imageUrl && imageUrl.startsWith('blob:')) { + // Try to fetch a small chunk to determine type + try { + const r = await fetch(imageUrl); + const b = await r.blob(); + if (b && b.type) ext = (b.type.split('/')[1] || ext).split(';')[0]; + // Save blob directly so we don't rely on a href blob with unknown name + const deduced = filenameHint ? sanitizeFilename(filenameHint) : (extractBlobIdFromUrl(imageUrl) || hashCode(imageUrl).toString(36)); + const fileName = deduced + '.' + ext; + const blobUrl = window.URL.createObjectURL(b); + const a = document.createElement('a'); + document.body.appendChild(a); + a.href = blobUrl; + a.download = fileName; + a.click(); + document.body.removeChild(a); + window.URL.revokeObjectURL(blobUrl); + logger.info('Download triggered', fileName); + return Promise.resolve(); + } catch (e) { + // fall back to simple anchor method + } + } + } catch (e) {} - const a = document.createElement("a"); + const blobId = extractBlobIdFromUrl(imageUrl); + if (!filenameHint && !blobId) { + throw new Error('No filename could be determined for image (no hint or blob ID)'); + } + const fileName = filenameHint ? sanitizeFilename(filenameHint) + '.' + ext : sanitizeFilename(blobId) + '.' + ext; + + const a = document.createElement('a'); document.body.appendChild(a); a.href = imageUrl; a.download = fileName; a.click(); document.body.removeChild(a); - logger.info("Download triggered", fileName); + logger.info('Download triggered', fileName); + return Promise.resolve(); }; logger.info("Initialized"); + // Global handler to optionally suppress noisy MediaError unhandled rejections + let telSuppressMediaError = false; + window.addEventListener('unhandledrejection', (ev) => { + try { + const reason = ev && ev.reason; + const msg = (reason && reason.message) || String(reason || ''); + if (telSuppressMediaError && msg && msg.includes('Failed to init decoder')) { + ev.preventDefault && ev.preventDefault(); + logger.info('Suppressed MediaError: ' + msg); + } + } catch (e) {} + }); + // For webz /a/ webapp setInterval(() => { // Stories @@ -506,13 +872,15 @@ video?.currentSrc || video?.querySelector("source")?.src; if (videoSrc) { - tel_download_video(videoSrc); + const dataMid = storiesContainer.getAttribute('data-mid') || storiesContainer.closest('[data-mid]')?.getAttribute('data-mid'); + tel_download_video(videoSrc, dataMid); } else { // 2. Story with image const images = storiesContainer.querySelectorAll("img.PVZ8TOWS"); if (images.length > 0) { const imageSrc = images[images.length - 1]?.src; - if (imageSrc) tel_download_image(imageSrc); + const dataMid = storiesContainer.getAttribute('data-mid') || storiesContainer.closest('[data-mid]')?.getAttribute('data-mid'); + if (imageSrc) tel_download_image(imageSrc, dataMid); } } }; @@ -560,7 +928,8 @@ downloadButton.setAttribute("data-tel-download-url", videoUrl); downloadButton.appendChild(downloadIcon); downloadButton.onclick = () => { - tel_download_video(videoPlayer.querySelector("video").currentSrc); + const dataMid = mediaContainer.getAttribute('data-mid') || mediaContainer.closest('[data-mid]')?.getAttribute('data-mid'); + tel_download_video(videoPlayer.querySelector("video").currentSrc, dataMid); }; // Add download button to video controls @@ -589,7 +958,8 @@ ) { // Update existing button telDownloadButton.onclick = () => { - tel_download_video(videoPlayer.querySelector("video").currentSrc); + const dataMid = mediaContainer.getAttribute('data-mid') || mediaContainer.closest('[data-mid]')?.getAttribute('data-mid'); + tel_download_video(videoPlayer.querySelector("video").currentSrc, dataMid); }; telDownloadButton.setAttribute("data-tel-download-url", videoUrl); } @@ -603,7 +973,8 @@ downloadButton.setAttribute("data-tel-download-url", img.src); downloadButton.appendChild(downloadIcon); downloadButton.onclick = () => { - tel_download_image(img.src); + const dataMid = mediaContainer.getAttribute('data-mid') || mediaContainer.closest('[data-mid]')?.getAttribute('data-mid'); + tel_download_image(img.src, dataMid); }; // Add/Update/Remove download button to topbar @@ -622,7 +993,8 @@ ) { // Update existing button telDownloadButton.onclick = () => { - tel_download_image(img.src); + const dataMid = mediaContainer.getAttribute('data-mid') || mediaContainer.closest('[data-mid]')?.getAttribute('data-mid'); + tel_download_image(img.src, dataMid); }; telDownloadButton.setAttribute("data-tel-download-url", img.src); } @@ -700,12 +1072,14 @@ video?.currentSrc || video?.querySelector("source")?.src; if (videoSrc) { - tel_download_video(videoSrc); + const dataMid = storiesContainer.getAttribute('data-mid') || storiesContainer.closest('[data-mid]')?.getAttribute('data-mid'); + tel_download_video(videoSrc, dataMid); } else { // 2. Story with image const imageSrc = storiesContainer.querySelector("img.media-photo")?.src; - if (imageSrc) tel_download_image(imageSrc); + const dataMid = storiesContainer.getAttribute('data-mid') || storiesContainer.closest('[data-mid]')?.getAttribute('data-mid'); + if (imageSrc) tel_download_image(imageSrc, dataMid); } }; return downloadButton; @@ -740,6 +1114,8 @@ // Query hidden buttons and unhide them const hiddenButtons = mediaButtons.querySelectorAll("button.btn-icon.hide"); let onDownload = null; + let qualityDownloadBtn = mediaButtons.querySelector(".quality-download-options-button-menu"); + for (const btn of hiddenButtons) { btn.classList.remove("hide"); if (btn.textContent === FORWARD_ICON) { @@ -754,6 +1130,14 @@ logger.info("onDownload", onDownload); } } + + // Prioritize official Telegram quality download button if available + if (!onDownload && qualityDownloadBtn) { + onDownload = () => { + logger.info("Using official Telegram quality download button"); + qualityDownloadBtn.click(); + }; + } if (mediaAspecter.querySelector(".ckin__player")) { // 1. Video player detected - Video and it has finished initial loading @@ -777,8 +1161,38 @@ if (onDownload) { downloadButton.onclick = onDownload; } else { - downloadButton.onclick = () => { - tel_download_video(mediaAspecter.querySelector("video").src); + downloadButton.onclick = async () => { + const dataMid = mediaContainer.getAttribute('data-mid') || mediaContainer.closest('[data-mid]')?.getAttribute('data-mid'); + let videoUrl = mediaAspecter.querySelector("video").src; + + // Validate URL before attempting download + let isValidUrl = false; + try { + const headRes = await fetch(videoUrl, { method: 'HEAD' }); + const contentType = headRes.headers.get('Content-Type') || ''; + isValidUrl = contentType.indexOf('video/') === 0; + } catch (e) { + logger.info('HEAD check failed for video src: ' + (e?.message || e)); + } + + // If URL is invalid, try to probe the viewer for a working stream URL + if (!isValidUrl) { + logger.info('Direct video src returned non-video or failed, probing viewer for stream...'); + try { + const probeRes = await probeViewerStream(mediaAspecter, { maxAttempts: 3, timeout: 12000 }); + if (probeRes && probeRes.url) { + videoUrl = probeRes.url; + logger.info('Found valid stream URL via probe: ' + videoUrl); + tel_download_video(videoUrl, dataMid); + if (probeRes.lockAcquired) releaseViewerLock(); + return; + } + } catch (e) { + logger.error('Viewer probe failed: ' + (e?.message || e)); + } + } + + tel_download_video(videoUrl, dataMid); }; } brControls.prepend(downloadButton); @@ -799,8 +1213,38 @@ if (onDownload) { downloadButton.onclick = onDownload; } else { - downloadButton.onclick = () => { - tel_download_video(mediaAspecter.querySelector("video").src); + downloadButton.onclick = async () => { + const dataMid = mediaContainer.getAttribute('data-mid') || mediaContainer.closest('[data-mid]')?.getAttribute('data-mid'); + let videoUrl = mediaAspecter.querySelector("video").src; + + // Validate URL before attempting download + let isValidUrl = false; + try { + const headRes = await fetch(videoUrl, { method: 'HEAD' }); + const contentType = headRes.headers.get('Content-Type') || ''; + isValidUrl = contentType.indexOf('video/') === 0; + } catch (e) { + logger.info('HEAD check failed for video src: ' + (e?.message || e)); + } + + // If URL is invalid, try to probe the viewer for a working stream URL + if (!isValidUrl) { + logger.info('Direct video src returned non-video or failed, probing viewer for stream...'); + try { + const probeRes = await probeViewerStream(mediaAspecter, { maxAttempts: 3, timeout: 12000 }); + if (probeRes && probeRes.url) { + videoUrl = probeRes.url; + logger.info('Found valid stream URL via probe: ' + videoUrl); + tel_download_video(videoUrl, dataMid); + if (probeRes.lockAcquired) releaseViewerLock(); + return; + } + } catch (e) { + logger.error('Viewer probe failed: ' + (e?.message || e)); + } + } + + tel_download_video(videoUrl, dataMid); }; } mediaButtons.prepend(downloadButton); @@ -823,7 +1267,8 @@ downloadButton.onclick = onDownload; } else { downloadButton.onclick = () => { - tel_download_image(mediaAspecter.querySelector("img.thumbnail").src); + const dataMid = mediaContainer.getAttribute('data-mid') || mediaContainer.closest('[data-mid]')?.getAttribute('data-mid'); + tel_download_image(mediaAspecter.querySelector("img.thumbnail").src, dataMid); }; } mediaButtons.prepend(downloadButton); @@ -846,5 +1291,846 @@ body.appendChild(container); })(); + // Persistent album state helpers (per-album keys) + const ALBUM_STORAGE_KEY_BASE = 'tel_album_states_v1'; + + // Migrate legacy aggregated storage key to per-album keys + const migrateAlbumStates = () => { + try { + const raw = localStorage.getItem(ALBUM_STORAGE_KEY_BASE); + if (!raw) return; // nothing to migrate + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object') { + Object.keys(parsed).forEach((albumMid) => { + try { + localStorage.setItem( + `${ALBUM_STORAGE_KEY_BASE}_${albumMid}`, + JSON.stringify(parsed[albumMid]) + ); + } catch (e) { + logger.error('Failed to migrate album state for ' + albumMid + ': ' + (e?.message || e)); + } + }); + // Remove legacy aggregated key after migrating + localStorage.removeItem(ALBUM_STORAGE_KEY_BASE); + logger.info('Migrated album states to per-album keys'); + } + } catch (e) { + logger.error('Migration failed: ' + (e?.message || e)); + } + }; + + const getAlbumState = (albumMid) => { + try { + const key = `${ALBUM_STORAGE_KEY_BASE}_${albumMid}`; + const raw = localStorage.getItem(key); + return raw ? JSON.parse(raw) : { status: null, items: {} }; + } catch (e) { + logger.error('Failed to get album state: ' + (e?.message || e)); + return { status: null, items: {} }; + } + }; + + const setAlbumState = (albumMid, state) => { + try { + const key = `${ALBUM_STORAGE_KEY_BASE}_${albumMid}`; + localStorage.setItem(key, JSON.stringify(state)); + } catch (e) { + logger.error('Failed to set album state: ' + (e?.message || e)); + } + }; + + const createBadgeForAlbum = (album, initStatus = null) => { + try { + if (getComputedStyle(album).position === 'static') { + album.style.position = 'relative'; + } + const existing = album.querySelector('.tel-album-scanned-badge'); + + // Get albumMid: for /k/ it's data-mid on the album, for /a/ it's data-album-main-id on .bottom-marker + let albumMid = album.getAttribute && album.getAttribute('data-mid'); + if (!albumMid) { + const marker = album.querySelector && album.querySelector('.bottom-marker'); + albumMid = marker && marker.getAttribute && marker.getAttribute('data-album-main-id'); + } + + let state = albumMid ? getAlbumState(albumMid) : { status: null, items: {} }; + if (initStatus && !state.status) { + state.status = initStatus; + if (albumMid) setAlbumState(albumMid, state); + } + + let badge = existing; + let badgeWrap = existing ? existing.parentNode : null; + + if (existing) { + logger.info(`Found existing badge for albumMid: ${albumMid}`); + const label = state.status === 'downloaded' ? 'Downloaded' : (state.status === 'partial' ? 'Partial downloaded' : 'Download'); + existing.innerText = label; + + // Remove existing listeners by cloning the element + if (!existing.dataset.listenerAttached) { + const newBadge = existing.cloneNode(true); + existing.parentNode.replaceChild(newBadge, existing); + badge = newBadge; + } + + // Ensure it's inside a badge wrapper for proper layout + try { + let wrap = badge.parentNode; + if (!wrap || !wrap.classList || !wrap.classList.contains('tel-album-badge-wrap')) { + wrap = document.createElement('div'); + wrap.className = 'tel-album-badge-wrap'; + wrap.style.position = 'absolute'; + wrap.style.top = '8px'; + wrap.style.right = '8px'; + wrap.style.zIndex = 9999; + wrap.style.display = 'flex'; + wrap.style.alignItems = 'center'; + wrap.style.gap = '8px'; + try { badge.remove(); } catch (e) {} + wrap.appendChild(badge); + album.appendChild(wrap); + badgeWrap = wrap; + } else { + badgeWrap = wrap; + } + } catch (e) {} + } else { + // Create new badge + badge = document.createElement('button'); + badge.className = 'tel-album-scanned-badge'; + badge.title = 'Download album'; + // badge will be placed inside a wrapper for correct layout + badge.style.padding = '4px 8px'; + badge.style.borderRadius = '12px'; + badge.style.background = '#6093B5'; + badge.style.color = 'white'; + badge.style.border = 'none'; + badge.style.cursor = 'pointer'; + badge.style.display = 'inline-flex'; + badge.style.alignItems = 'center'; + badge.style.justifyContent = 'center'; + badge.style.whiteSpace = 'nowrap'; + badge.style.boxSizing = 'border-box'; + badge.innerText = state.status === 'downloaded' ? 'Downloaded' : (state.status === 'partial' ? 'Partial downloaded' : 'Download'); + + logger.info(`Creating new badge with text: ${badge.innerText}, albumMid: ${albumMid}`); + + // create wrapper and append badge into it + badgeWrap = document.createElement('div'); + badgeWrap.className = 'tel-album-badge-wrap'; + badgeWrap.style.position = 'absolute'; + badgeWrap.style.top = '8px'; + badgeWrap.style.right = '8px'; + badgeWrap.style.zIndex = 9999; + badgeWrap.style.display = 'flex'; + badgeWrap.style.alignItems = 'center'; + badgeWrap.style.gap = '8px'; + badgeWrap.appendChild(badge); + album.appendChild(badgeWrap); + } + + const updateBadgeText = (s) => { + try { + badge.innerText = s; + } catch (e) {} + try { + if (typeof ensureRedownloadButton === 'function') ensureRedownloadButton(); + } catch (e) {} + }; + + // Ensure redownload button exists when album is partial or downloaded + const ensureRedownloadButton = () => { + try { + const existingR = badge.parentNode && badge.parentNode.querySelector('.tel-album-redownload'); + if (existingR) existingR.remove(); + + if (state.status === 'downloaded' || state.status === 'partial') { + const red = document.createElement('button'); + red.className = 'tel-album-redownload'; + red.title = 'Redownload album'; + red.innerText = 'Redownload'; + + // Copy computed styles from the main badge so the buttons match exactly + try { + const cs = window.getComputedStyle(badge); + red.style.padding = cs.padding || '4px 8px'; + red.style.borderRadius = cs.borderRadius || '12px'; + red.style.fontSize = cs.fontSize || '0.9rem'; + red.style.lineHeight = cs.lineHeight || '1'; + // Only set explicit height if computed height is pixels (avoid % or auto which can stretch) + if (/^\d+px$/.test(cs.height)) red.style.height = cs.height; + red.style.display = 'inline-flex'; + red.style.alignItems = 'center'; + red.style.justifyContent = 'center'; + red.style.whiteSpace = 'nowrap'; + red.style.boxSizing = 'border-box'; + red.style.marginLeft = '8px'; + // color & background (red variant) + red.style.background = '#D16666'; + red.style.color = cs.color || 'white'; + red.style.border = 'none'; + red.style.cursor = 'pointer'; + } catch (e) { + // Fallback styles + red.style.marginLeft = '8px'; + red.style.padding = badge.style.padding || '4px 8px'; + red.style.borderRadius = badge.style.borderRadius || '12px'; + red.style.background = '#D16666'; + red.style.color = 'white'; + red.style.border = 'none'; + red.style.cursor = 'pointer'; + red.style.display = 'inline-block'; + } + + red.addEventListener('click', async (ev) => { + ev.stopPropagation(); + red.disabled = true; + badge.disabled = true; + try { + await forceRedownloadAlbum(album, albumMid); + } catch (e) { + logger.error('Redownload album failed: ' + (e?.message || e)); + } finally { + red.disabled = false; + badge.disabled = false; + ensureRedownloadButton(); + } + }); + + // append red to same wrapper as the main badge for correct sizing and alignment + try { + const wrap = badge.parentNode && badge.parentNode.classList && badge.parentNode.classList.contains('tel-album-badge-wrap') ? badge.parentNode : null; + if (wrap) wrap.appendChild(red); else badge.after(red); + } catch (e) { + try { badge.after(red); } catch (e) {} + } + } + } catch (e) { + logger.error('ensureRedownloadButton error: ' + (e?.message || e)); + } + }; + + badge.addEventListener('click', async (ev) => { + ev.stopPropagation(); + logger.info('Badge clicked! Starting album download...'); + badge.disabled = true; + try { + // Gather album items; handle both /a/ and /k/ structures + let albumItems = Array.from(album.querySelectorAll('.album-item.grouped-item')); + logger.info(`Found ${albumItems.length} .album-item.grouped-item items`); + if (albumItems.length === 0) { + // Try /a/ structure: .album-item-select-wrapper > .media-inner + albumItems = Array.from(album.querySelectorAll('.album-item-select-wrapper .media-inner')); + logger.info(`Found ${albumItems.length} .media-inner items for /a/`); + } + if (albumItems.length === 0) { + const attach = album.querySelector('.attachment, .album-item, .album-item-media, .media-container'); + if (attach) albumItems = [attach]; + logger.info(`Fallback: Found ${albumItems.length} attachment items`); + } + + const total = albumItems.length; + let downloaded = 0; + logger.info(`Total items to process: ${total}`); + + // load latest state + state = albumMid ? getAlbumState(albumMid) : state; + for (const item of albumItems) { + // For /a/, get itemMid from data-message-id of the media-inner element itself + // For /k/, get it from data-mid + let itemMid = item.getAttribute && item.getAttribute('data-mid'); + if (!itemMid && item.id && item.id.startsWith('album-media-message-')) { + itemMid = item.id.replace('album-media-message-', ''); + } + // Fall back to album's mid if not found + if (!itemMid) { + itemMid = album.getAttribute && album.getAttribute('data-mid'); + } + + logger.info(`Processing item with mid: ${itemMid}`); + + if (itemMid && state.items && state.items[itemMid]) { + logger.info('Album scan: Skipping already downloaded item: ' + itemMid); + downloaded++; + badge.innerText = 'Downloading... ' + downloaded + '/' + total; + continue; + } + + // Detect video by presence of .video-time or