Telegram Media Downloader (Improved)

Download images, GIFs, videos, and voice messages on the Telegram webapp from private channels that disable downloading and restrict saving content

目前為 2025-01-31 提交的版本,檢視 最新版本

  1. // ==UserScript==
  2. // @name Telegram Media Downloader (Improved)
  3. // @name:zh-CN Telegram图片视频下载器 (Improved)
  4. // @version 1.3
  5. // @namespace Original: https://github.com/Neet-Nestor/Telegram-Media-Downloader
  6. // @description Download images, GIFs, videos, and voice messages on the Telegram webapp from private channels that disable downloading and restrict saving content
  7. // @description:zh-cn 从禁止下载的Telegram频道中下载图片、视频及语音消息
  8. // @author Original: Nestor Qin, Update: Finomosec
  9. // @license GNU GPLv3
  10. // @website https://github.com/Neet-Nestor/Telegram-Media-Downloader
  11. // @match https://web.telegram.org/*
  12. // @match https://webk.telegram.org/*
  13. // @match https://webz.telegram.org/*
  14. // @icon https://img.icons8.com/color/452/telegram-app--v5.png
  15. // ==/UserScript==
  16.  
  17. window.onerror = function myErrorHandler(errorMsg, url, lineNumber) {
  18. console.error("UNCAUGHT ERROR: " + errorMsg);
  19. return false;
  20. };
  21.  
  22. (function () {
  23. const logger = {
  24. info: (message, fileName = null) => {
  25. // console.log(`[Tel Download] ${fileName ? `${fileName}: ` : ""}${message}`);
  26. },
  27. error: (message, fileName = null) => {
  28. console.error(
  29. `[Tel Download] ${fileName ? `${fileName}: ` : ""}${message}`
  30. );
  31. },
  32. };
  33. // Unicode values for icons (used in /k/ app)
  34. const DOWNLOAD_ICON = "\uE94E";
  35. const FORWARD_ICON = "\uE960";
  36. const contentRangeRegex = /^bytes (\d+)-(\d+)\/(\d+)$/;
  37. const REFRESH_DELAY = 500;
  38. const hashCode = (s) => {
  39. var h = 0,
  40. l = s.length,
  41. i = 0;
  42. if (l > 0) {
  43. while (i < l) {
  44. h = ((h << 5) - h + s.charCodeAt(i++)) | 0;
  45. }
  46. }
  47. return h >>> 0;
  48. };
  49.  
  50. const isDarkMode =
  51. document.querySelector("html").classList.contains("night") ||
  52. document.querySelector("html").classList.contains("theme-dark");
  53.  
  54. const setQueued = (videoId, queued) => {
  55. const progressBar = document.getElementById("tel-downloader-progress-" + videoId);
  56. progressBar.classList.toggle("queued", queued);
  57. if (queued) {
  58. progressBar.querySelector(".progress div").style.backgroundColor = "lightgray";
  59. progressBar.querySelector(".progress div").style.width = "100%";
  60. } else {
  61. progressBar.querySelector(".progress div").style.width = "0%";
  62. progressBar.querySelector(".progress div").style.backgroundColor = "#6093B5";
  63. }
  64. };
  65.  
  66. const createProgressBar = (videoId, fileName) => {
  67. const container = document.getElementById("tel-downloader-progress-bar-container");
  68. let innerContainer = document.getElementById("tel-downloader-progress-" + videoId)
  69. if (!innerContainer) {
  70. innerContainer = document.createElement("div");
  71. innerContainer.id = "tel-downloader-progress-" + videoId;
  72. innerContainer.classList.add("tel-downloader-progress");
  73. innerContainer.style.width = "20rem";
  74. innerContainer.style.marginTop = "0.4rem";
  75. innerContainer.style.padding = "0.6rem";
  76. innerContainer.style.backgroundColor = isDarkMode ? "rgba(0,0,0,0.3)" : "rgba(0,0,0,0.6)";
  77. innerContainer.setAttribute("videoId", videoId);
  78.  
  79. const flexContainer = document.createElement("div");
  80. flexContainer.style.display = "flex";
  81. flexContainer.style.justifyContent = "space-between";
  82.  
  83. const title = document.createElement("p");
  84. title.className = "filename";
  85. title.style.margin = 0;
  86. title.style.color = "white";
  87. title.innerText = fileName;
  88.  
  89. const closeButton = document.createElement("div");
  90. closeButton.style.cursor = "pointer";
  91. closeButton.style.fontSize = "1.2rem";
  92. closeButton.style.color = isDarkMode ? "#8a8a8a" : "white";
  93. closeButton.style.position = "absolute";
  94. closeButton.style.right = "4px";
  95. closeButton.innerHTML = "&times;";
  96. closeButton.onclick = function () {
  97. container.removeChild(innerContainer);
  98. checkResumeNextDownload();
  99. };
  100.  
  101. const progressBar = document.createElement("div");
  102. progressBar.className = "progress";
  103. progressBar.style.backgroundColor = "#e2e2e2";
  104. progressBar.style.position = "relative";
  105. progressBar.style.width = "100%";
  106. progressBar.style.height = "1.6rem";
  107. progressBar.style.borderRadius = "2rem";
  108. progressBar.style.overflow = "hidden";
  109.  
  110. const counter = document.createElement("p");
  111. counter.style.position = "absolute";
  112. counter.style.zIndex = 5;
  113. counter.style.left = "50%";
  114. counter.style.top = "50%";
  115. counter.style.transform = "translate(-50%, -50%)";
  116. counter.style.margin = 0;
  117. counter.style.color = "black";
  118. const progress = document.createElement("div");
  119. progress.style.position = "absolute";
  120. progress.style.height = "100%";
  121. progress.style.width = "0%";
  122. progress.style.backgroundColor = "#6093B5";
  123.  
  124. progressBar.appendChild(counter);
  125. progressBar.appendChild(progress);
  126. flexContainer.appendChild(title);
  127. flexContainer.appendChild(closeButton);
  128. innerContainer.appendChild(flexContainer);
  129. innerContainer.appendChild(progressBar);
  130. container.appendChild(innerContainer);
  131. }
  132.  
  133. const activeDownloads = Array.from(container.querySelectorAll(".tel-downloader-progress:not(.queued):not(.aborted)"));
  134. const queued = activeDownloads.length > 2;
  135. setQueued(videoId, queued);
  136. return innerContainer;
  137. };
  138.  
  139. const checkResumeNextDownload = () => {
  140. const container = document.getElementById("tel-downloader-progress-bar-container");
  141. const activeDownloads = Array.from(container.querySelectorAll(".tel-downloader-progress:not(.queued):not(.aborted):not(.completed)"));
  142. if (activeDownloads.length < 2) {
  143. const nextDownload = container.querySelector(".tel-downloader-progress.queued");
  144. if (nextDownload) {
  145. const videoId = nextDownload.getAttribute("videoId");
  146. if (typeof nextDownload.resume === "function") {
  147. setQueued(videoId, false);
  148. nextDownload.resume();
  149. delete nextDownload.resume;
  150. }
  151. }
  152. }
  153. };
  154.  
  155. const updateProgress = (videoId, fileName, progress, url) => {
  156. const innerContainer = document.getElementById(
  157. "tel-downloader-progress-" + videoId
  158. );
  159. innerContainer.querySelector("p.filename").innerText = fileName;
  160. const progressBar = innerContainer.querySelector("div.progress");
  161. progressBar.querySelector("p").innerText = progress + "%";
  162. progressBar.querySelector("div").style.width = progress + "%";
  163. progressBar.setAttribute("tel-video-url", url);
  164. };
  165.  
  166. const completeProgress = (videoId) => {
  167. const progressBar = document.getElementById("tel-downloader-progress-" + videoId);
  168. progressBar.classList.add("completed");
  169. progressBar.querySelector(".progress p").innerText = "Completed";
  170. progressBar.querySelector(".progress div").style.backgroundColor = "#B6C649";
  171. progressBar.querySelector(".progress div").style.width = "100%";
  172. window.setTimeout(() => {
  173. const innerContainer = document.getElementById("tel-downloader-progress-" + videoId);
  174. if (innerContainer) {
  175. innerContainer.parentElement.removeChild(innerContainer);
  176. }
  177. }, 10000);
  178. checkResumeNextDownload();
  179. };
  180.  
  181. const retryDownload = (videoId) => {
  182. const progressBar = document
  183. .getElementById("tel-downloader-progress-" + videoId)
  184. .querySelector("div.progress");
  185. if (!progressBar || !progressBar.classList.contains("aborted")) {
  186. return;
  187. }
  188. progressBar.classList.remove("aborted");
  189. progressBar.querySelector("div").style.backgroundColor = "#6093B5";
  190. const videoUrl = progressBar.getAttribute("tel-video-url");
  191. console.log("Retrying download: " + videoUrl);
  192. tel_download_video(videoUrl, videoId);
  193. };
  194.  
  195. const AbortProgress = (videoId) => {
  196. const progressBar = document
  197. .getElementById("tel-downloader-progress-" + videoId)
  198. .querySelector("div.progress");
  199. progressBar.classList.add("aborted");
  200. progressBar.querySelector("p").innerText = "Aborted";
  201. progressBar.querySelector("div").style.backgroundColor = "#D16666";
  202. progressBar.querySelector("div").style.width = "100%";
  203. const retryLink = document.createElement("a");
  204. retryLink.innerText = "retry";
  205. retryLink.style.marginLeft = "5px";
  206. retryLink.href = "javascript:void(0);";
  207. retryLink.onclick = () => { retryDownload(videoId); };
  208. progressBar.querySelector("p").appendChild(retryLink);
  209. window.setTimeout(() => retryDownload(videoId), 30000);
  210. checkResumeNextDownload();
  211. };
  212.  
  213. const tel_download_video = (url, videoId) => {
  214. let _blobs = [];
  215. let _next_offset = 0;
  216. let _total_size = null;
  217. let _file_extension = "mp4";
  218.  
  219. videoId = videoId || (Math.random() + 1).toString(36).substring(2, 10) + "_" + Date.now().toString();
  220. let fileName = hashCode(url).toString(36) + "." + _file_extension;
  221.  
  222. // Some video src is in format:
  223. // 'stream/{"dcId":5,"location":{...},"size":...,"mimeType":"video/mp4","fileName":"xxxx.MP4"}'
  224. try {
  225. const metadata = JSON.parse(
  226. decodeURIComponent(url.split("/")[url.split("/").length - 1])
  227. );
  228. if (metadata.fileName) {
  229. fileName = metadata.fileName;
  230. }
  231. } catch (e) {
  232. // Invalid JSON string, pass extracting fileName
  233. }
  234. logger.info(`URL: ${url}`, fileName);
  235.  
  236. const fetchNextPart = (_writable) => {
  237. fetch(url, {
  238. method: "GET",
  239. headers: {
  240. Range: `bytes=${_next_offset}-`,
  241. },
  242. "User-Agent":
  243. "User-Agent Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/117.0",
  244. })
  245. .then((res) => {
  246. if (![200, 206].includes(res.status)) {
  247. throw new Error("Non 200/206 response was received: " + res.status);
  248. }
  249. const mime = res.headers.get("Content-Type").split(";")[0];
  250. if (!mime.startsWith("video/")) {
  251. throw new Error("Get non video response with MIME type " + mime);
  252. }
  253. _file_extension = mime.split("/")[1];
  254. fileName =
  255. fileName.substring(0, fileName.indexOf(".") + 1) + _file_extension;
  256.  
  257. const match = res.headers
  258. .get("Content-Range")
  259. .match(contentRangeRegex);
  260.  
  261. const startOffset = parseInt(match[1]);
  262. const endOffset = parseInt(match[2]);
  263. const totalSize = parseInt(match[3]);
  264.  
  265. if (startOffset !== _next_offset) {
  266. logger.error("Gap detected between responses.", fileName);
  267. logger.info("Last offset: " + _next_offset, fileName);
  268. logger.info("New start offset " + match[1], fileName);
  269. throw "Gap detected between responses.";
  270. }
  271. if (_total_size && totalSize !== _total_size) {
  272. logger.error("Total size differs", fileName);
  273. throw "Total size differs";
  274. }
  275.  
  276. _next_offset = endOffset + 1;
  277. _total_size = totalSize;
  278.  
  279. logger.info(
  280. `Get response: ${res.headers.get(
  281. "Content-Length"
  282. )} bytes data from ${res.headers.get("Content-Range")}`,
  283. fileName
  284. );
  285. logger.info(
  286. `Progress: ${((_next_offset * 100) / _total_size).toFixed(0)}%`,
  287. fileName
  288. );
  289. updateProgress(
  290. videoId,
  291. fileName,
  292. ((_next_offset * 100) / _total_size).toFixed(0),
  293. url
  294. );
  295. return res.blob();
  296. })
  297. .then((resBlob) => {
  298. if (_writable !== null) {
  299. _writable.write(resBlob).then(() => {});
  300. } else {
  301. _blobs.push(resBlob);
  302. }
  303. })
  304. .then(() => {
  305. if (!_total_size) {
  306. throw new Error("_total_size is NULL");
  307. }
  308.  
  309. if (_next_offset < _total_size) {
  310. const progressBar = document.getElementById("tel-downloader-progress-" + videoId)
  311. if (progressBar.classList.contains("queued")) {
  312. progressBar.resume = () => fetchNextPart(_writable);
  313. } else {
  314. fetchNextPart(_writable);
  315. }
  316. } else {
  317. if (_writable !== null) {
  318. _writable.close().then(() => {
  319. logger.info("Download finished", fileName);
  320. });
  321. } else {
  322. save();
  323. }
  324. completeProgress(videoId);
  325. }
  326. })
  327. .catch((reason) => {
  328. logger.error("Download failed: ", reason, fileName);
  329. AbortProgress(videoId);
  330. });
  331. };
  332.  
  333. const save = () => {
  334. logger.info("Finish downloading blobs", fileName);
  335. logger.info("Concatenating blobs and downloading...", fileName);
  336.  
  337. const blob = new Blob(_blobs, { type: "video/mp4" });
  338. const blobUrl = window.URL.createObjectURL(blob);
  339.  
  340. logger.info("Final blob size: " + blob.size + " bytes", fileName);
  341.  
  342. const a = document.createElement("a");
  343. document.body.appendChild(a);
  344. a.href = blobUrl;
  345. a.download = fileName;
  346. a.click();
  347. document.body.removeChild(a);
  348. window.URL.revokeObjectURL(blobUrl);
  349.  
  350. logger.info("Download triggered", fileName);
  351. };
  352.  
  353. const supportsFileSystemAccess =
  354. "showSaveFilePicker" in unsafeWindow &&
  355. (() => {
  356. try {
  357. return unsafeWindow.self === unsafeWindow.top;
  358. } catch {
  359. return false;
  360. }
  361. })();
  362. // console.log("supportsFileSystemAccess:", supportsFileSystemAccess);
  363. if (supportsFileSystemAccess) {
  364. unsafeWindow
  365. .showSaveFilePicker({
  366. suggestedName: fileName,
  367. })
  368. .then((handle) => {
  369. handle
  370. .createWritable()
  371. .then((writable) => {
  372. const progressBar = createProgressBar(videoId);
  373. // if (progressBar.getAttribute("queued") === "true") {
  374. // progressBar.resume = () => fetchNextPart(writable);
  375. // } else {
  376. fetchNextPart(writable);
  377. // }
  378. })
  379. .catch((err) => {
  380. console.error(err.name, err.message);
  381. });
  382. })
  383. .catch((err) => {
  384. if (err.name !== "AbortError") {
  385. console.error(err.name, err.message);
  386. }
  387. });
  388. } else {
  389. const progressBar = createProgressBar(videoId);
  390. // if (progressBar.getAttribute("queued") === "true") {
  391. // progressBar.resume = () => fetchNextPart(null);
  392. // } else {
  393. fetchNextPart(null);
  394. // }
  395. }
  396. };
  397.  
  398. const tel_download_audio = (url) => {
  399. let _blobs = [];
  400. let _next_offset = 0;
  401. let _total_size = null;
  402. const fileName = hashCode(url).toString(36) + ".ogg";
  403.  
  404. const fetchNextPart = (_writable) => {
  405. fetch(url, {
  406. method: "GET",
  407. headers: {
  408. Range: `bytes=${_next_offset}-`,
  409. },
  410. })
  411. .then((res) => {
  412. if (res.status !== 206 && res.status !== 200) {
  413. logger.error(
  414. "Non 200/206 response was received: " + res.status,
  415. fileName
  416. );
  417. return;
  418. }
  419.  
  420. const mime = res.headers.get("Content-Type").split(";")[0];
  421. if (!mime.startsWith("audio/")) {
  422. logger.error(
  423. "Get non audio response with MIME type " + mime,
  424. fileName
  425. );
  426. throw "Get non audio response with MIME type " + mime;
  427. }
  428.  
  429. try {
  430. const match = res.headers
  431. .get("Content-Range")
  432. .match(contentRangeRegex);
  433.  
  434. const startOffset = parseInt(match[1]);
  435. const endOffset = parseInt(match[2]);
  436. const totalSize = parseInt(match[3]);
  437.  
  438. if (startOffset !== _next_offset) {
  439. logger.error("Gap detected between responses.");
  440. logger.info("Last offset: " + _next_offset);
  441. logger.info("New start offset " + match[1]);
  442. throw "Gap detected between responses.";
  443. }
  444. if (_total_size && totalSize !== _total_size) {
  445. logger.error("Total size differs");
  446. throw "Total size differs";
  447. }
  448.  
  449. _next_offset = endOffset + 1;
  450. _total_size = totalSize;
  451. } finally {
  452. logger.info(
  453. `Get response: ${res.headers.get(
  454. "Content-Length"
  455. )} bytes data from ${res.headers.get("Content-Range")}`
  456. );
  457. return res.blob();
  458. }
  459. })
  460. .then((resBlob) => {
  461. if (_writable !== null) {
  462. _writable.write(resBlob).then(() => {});
  463. } else {
  464. _blobs.push(resBlob);
  465. }
  466. })
  467. .then(() => {
  468. if (_next_offset < _total_size) {
  469. fetchNextPart(_writable);
  470. } else {
  471. if (_writable !== null) {
  472. _writable.close().then(() => {
  473. logger.info("Download finished", fileName);
  474. });
  475. } else {
  476. save();
  477. }
  478. }
  479. })
  480. .catch((reason) => {
  481. logger.error(reason, fileName);
  482. });
  483. };
  484.  
  485. const save = () => {
  486. logger.info(
  487. "Finish downloading blobs. Concatenating blobs and downloading...",
  488. fileName
  489. );
  490.  
  491. let blob = new Blob(_blobs, { type: "audio/ogg" });
  492. const blobUrl = window.URL.createObjectURL(blob);
  493.  
  494. logger.info("Final blob size in bytes: " + blob.size, fileName);
  495.  
  496. blob = 0;
  497.  
  498. const a = document.createElement("a");
  499. document.body.appendChild(a);
  500. a.href = blobUrl;
  501. a.download = fileName;
  502. a.click();
  503. document.body.removeChild(a);
  504. window.URL.revokeObjectURL(blobUrl);
  505.  
  506. logger.info("Download triggered", fileName);
  507. };
  508.  
  509. const supportsFileSystemAccess =
  510. "showSaveFilePicker" in unsafeWindow &&
  511. (() => {
  512. try {
  513. return unsafeWindow.self === unsafeWindow.top;
  514. } catch {
  515. return false;
  516. }
  517. })();
  518. if (supportsFileSystemAccess) {
  519. unsafeWindow
  520. .showSaveFilePicker({
  521. suggestedName: fileName,
  522. })
  523. .then((handle) => {
  524. handle
  525. .createWritable()
  526. .then((writable) => {
  527. fetchNextPart(writable);
  528. })
  529. .catch((err) => {
  530. console.error(err.name, err.message);
  531. });
  532. })
  533. .catch((err) => {
  534. if (err.name !== "AbortError") {
  535. console.error(err.name, err.message);
  536. }
  537. });
  538. } else {
  539. fetchNextPart(null);
  540. }
  541. };
  542.  
  543. const tel_download_image = (imageUrl) => {
  544. const fileName =
  545. (Math.random() + 1).toString(36).substring(2, 10) + ".jpeg"; // assume jpeg
  546.  
  547. const a = document.createElement("a");
  548. document.body.appendChild(a);
  549. a.href = imageUrl;
  550. a.download = fileName;
  551. a.click();
  552. document.body.removeChild(a);
  553.  
  554. logger.info("Download triggered", fileName);
  555. };
  556.  
  557. logger.info("Initialized");
  558.  
  559. // For webz /a/ webapp
  560. setInterval(() => {
  561. // Stories
  562. const storiesContainer = document.getElementById("StoryViewer");
  563. if (storiesContainer) {
  564. console.log("storiesContainer");
  565. const createDownloadButton = () => {
  566. console.log("createDownloadButton");
  567. const downloadIcon = document.createElement("i");
  568. downloadIcon.className = "icon icon-download";
  569. const downloadButton = document.createElement("button");
  570. downloadButton.className =
  571. "Button TkphaPyQ tiny translucent-white round tel-download";
  572. downloadButton.appendChild(downloadIcon);
  573. downloadButton.setAttribute("type", "button");
  574. downloadButton.setAttribute("title", "Download");
  575. downloadButton.setAttribute("aria-label", "Download");
  576. downloadButton.onclick = () => {
  577. // 1. Story with video
  578. const video = storiesContainer.querySelector("video");
  579. const videoSrc =
  580. video?.src ||
  581. video?.currentSrc ||
  582. video?.querySelector("source")?.src;
  583. if (videoSrc) {
  584. tel_download_video(videoSrc);
  585. } else {
  586. // 2. Story with image
  587. const images = storiesContainer.querySelectorAll("img.PVZ8TOWS");
  588. if (images.length > 0) {
  589. const imageSrc = images[images.length - 1]?.src;
  590. if (imageSrc) tel_download_image(imageSrc);
  591. }
  592. }
  593. };
  594. return downloadButton;
  595. };
  596.  
  597. const storyHeader =
  598. storiesContainer.querySelector(".GrsJNw3y") ||
  599. storiesContainer.querySelector(".DropdownMenu").parentNode;
  600. if (storyHeader && !storyHeader.querySelector(".tel-download")) {
  601. console.log("storyHeader");
  602. storyHeader.insertBefore(
  603. createDownloadButton(),
  604. storyHeader.querySelector("button")
  605. );
  606. }
  607. }
  608.  
  609. // All media opened are located in .media-viewer-movers > .media-viewer-aspecter
  610. const mediaContainer = document.querySelector(
  611. "#MediaViewer .MediaViewerSlide--active"
  612. );
  613. const mediaViewerActions = document.querySelector(
  614. "#MediaViewer .MediaViewerActions"
  615. );
  616. if (!mediaContainer || !mediaViewerActions) return;
  617.  
  618. // Videos in channels
  619. const videoPlayer = mediaContainer.querySelector(
  620. ".MediaViewerContent > .VideoPlayer"
  621. );
  622. const img = mediaContainer.querySelector(".MediaViewerContent > div > img");
  623. // 1. Video player detected - Video or GIF
  624. // container > .MediaViewerSlides > .MediaViewerSlide > .MediaViewerContent > .VideoPlayer > video[src]
  625. const downloadIcon = document.createElement("i");
  626. downloadIcon.className = "icon icon-download";
  627. const downloadButton = document.createElement("button");
  628. downloadButton.className =
  629. "Button smaller translucent-white round tel-download";
  630. downloadButton.setAttribute("type", "button");
  631. downloadButton.setAttribute("title", "Download");
  632. downloadButton.setAttribute("aria-label", "Download");
  633. if (videoPlayer) {
  634. const videoUrl = videoPlayer.querySelector("video").currentSrc;
  635. downloadButton.setAttribute("data-tel-download-url", videoUrl);
  636. downloadButton.appendChild(downloadIcon);
  637. downloadButton.onclick = () => {
  638. tel_download_video(videoPlayer.querySelector("video").currentSrc);
  639. };
  640.  
  641. // Add download button to video controls
  642. const controls = videoPlayer.querySelector(".VideoPlayerControls");
  643. if (controls) {
  644. const buttons = controls.querySelector(".buttons");
  645. if (!buttons.querySelector("button.tel-download")) {
  646. const spacer = buttons.querySelector(".spacer");
  647. spacer.after(downloadButton);
  648. }
  649. }
  650.  
  651. // Add/Update/Remove download button to topbar
  652. if (mediaViewerActions.querySelector("button.tel-download")) {
  653. const telDownloadButton = mediaViewerActions.querySelector(
  654. "button.tel-download"
  655. );
  656. if (
  657. mediaViewerActions.querySelectorAll('button[title="Download"]')
  658. .length > 1
  659. ) {
  660. // There's existing download button, remove ours
  661. mediaViewerActions.querySelector("button.tel-download").remove();
  662. } else if (
  663. telDownloadButton.getAttribute("data-tel-download-url") !== videoUrl
  664. ) {
  665. // Update existing button
  666. telDownloadButton.onclick = () => {
  667. tel_download_video(videoPlayer.querySelector("video").currentSrc);
  668. };
  669. telDownloadButton.setAttribute("data-tel-download-url", videoUrl);
  670. }
  671. } else if (
  672. !mediaViewerActions.querySelector('button[title="Download"]')
  673. ) {
  674. // Add the button if there's no download button at all
  675. mediaViewerActions.prepend(downloadButton);
  676. }
  677. } else if (img && img.src) {
  678. downloadButton.setAttribute("data-tel-download-url", img.src);
  679. downloadButton.appendChild(downloadIcon);
  680. downloadButton.onclick = () => {
  681. tel_download_image(img.src);
  682. };
  683.  
  684. // Add/Update/Remove download button to topbar
  685. if (mediaViewerActions.querySelector("button.tel-download")) {
  686. const telDownloadButton = mediaViewerActions.querySelector(
  687. "button.tel-download"
  688. );
  689. if (
  690. mediaViewerActions.querySelectorAll('button[title="Download"]')
  691. .length > 1
  692. ) {
  693. // There's existing download button, remove ours
  694. mediaViewerActions.querySelector("button.tel-download").remove();
  695. } else if (
  696. telDownloadButton.getAttribute("data-tel-download-url") !== img.src
  697. ) {
  698. // Update existing button
  699. telDownloadButton.onclick = () => {
  700. tel_download_image(img.src);
  701. };
  702. telDownloadButton.setAttribute("data-tel-download-url", img.src);
  703. }
  704. } else if (
  705. !mediaViewerActions.querySelector('button[title="Download"]')
  706. ) {
  707. // Add the button if there's no download button at all
  708. mediaViewerActions.prepend(downloadButton);
  709. }
  710. }
  711. }, REFRESH_DELAY);
  712.  
  713. // For webk /k/ webapp
  714. setInterval(() => {
  715. /* Voice Message */
  716. const pinnedAudio = document.body.querySelector(".pinned-audio");
  717. let dataMid;
  718. let downloadButtonPinnedAudio =
  719. document.body.querySelector("._tel_download_button_pinned_container") ||
  720. document.createElement("button");
  721. if (pinnedAudio) {
  722. dataMid = pinnedAudio.getAttribute("data-mid");
  723. downloadButtonPinnedAudio.className =
  724. "btn-icon tgico-download _tel_download_button_pinned_container";
  725. downloadButtonPinnedAudio.innerHTML = `<span class="tgico button-icon">${DOWNLOAD_ICON}</span>`;
  726. }
  727. const voiceMessages = document.body.querySelectorAll("audio-element");
  728. voiceMessages.forEach((voiceMessage) => {
  729. const bubble = voiceMessage.closest(".bubble");
  730. if (
  731. !bubble ||
  732. bubble.querySelector("._tel_download_button_pinned_container")
  733. ) {
  734. return; /* Skip if there's already a download button */
  735. }
  736. if (
  737. dataMid &&
  738. downloadButtonPinnedAudio.getAttribute("data-mid") !== dataMid &&
  739. voiceMessage.getAttribute("data-mid") === dataMid
  740. ) {
  741. downloadButtonPinnedAudio.onclick = (e) => {
  742. e.stopPropagation();
  743. tel_download_audio(link);
  744. };
  745. downloadButtonPinnedAudio.setAttribute("data-mid", dataMid);
  746. const link =
  747. voiceMessage.audio && voiceMessage.audio.getAttribute("src");
  748. if (link) {
  749. pinnedAudio
  750. .querySelector(".pinned-container-wrapper-utils")
  751. .appendChild(downloadButtonPinnedAudio);
  752. }
  753. }
  754. });
  755.  
  756. // Stories
  757. const storiesContainer = document.getElementById("stories-viewer");
  758. if (storiesContainer) {
  759. const createDownloadButton = () => {
  760. const downloadButton = document.createElement("button");
  761. downloadButton.className = "btn-icon rp tel-download";
  762. downloadButton.innerHTML = `<span class="tgico">${DOWNLOAD_ICON}</span><div class="c-ripple"></div>`;
  763. downloadButton.setAttribute("type", "button");
  764. downloadButton.setAttribute("title", "Download");
  765. downloadButton.setAttribute("aria-label", "Download");
  766. downloadButton.onclick = () => {
  767. // 1. Story with video
  768. const video = storiesContainer.querySelector("video.media-video");
  769. const videoSrc =
  770. video?.src ||
  771. video?.currentSrc ||
  772. video?.querySelector("source")?.src;
  773. if (videoSrc) {
  774. tel_download_video(videoSrc);
  775. } else {
  776. // 2. Story with image
  777. const imageSrc =
  778. storiesContainer.querySelector("img.media-photo")?.src;
  779. if (imageSrc) tel_download_image(imageSrc);
  780. }
  781. };
  782. return downloadButton;
  783. };
  784.  
  785. const storyHeader = storiesContainer.querySelector(
  786. "[class^='_ViewerStoryHeaderRight']"
  787. );
  788. if (storyHeader && !storyHeader.querySelector(".tel-download")) {
  789. storyHeader.prepend(createDownloadButton());
  790. }
  791.  
  792. const storyFooter = storiesContainer.querySelector(
  793. "[class^='_ViewerStoryFooterRight']"
  794. );
  795. if (storyFooter && !storyFooter.querySelector(".tel-download")) {
  796. storyFooter.prepend(createDownloadButton());
  797. }
  798. }
  799.  
  800. // All media opened are located in .media-viewer-movers > .media-viewer-aspecter
  801. const mediaContainer = document.querySelector(".media-viewer-whole");
  802. if (!mediaContainer) return;
  803. const mediaAspecter = mediaContainer.querySelector(
  804. ".media-viewer-movers .media-viewer-aspecter"
  805. );
  806. const mediaButtons = mediaContainer.querySelector(
  807. ".media-viewer-topbar .media-viewer-buttons"
  808. );
  809. if (!mediaAspecter || !mediaButtons) return;
  810.  
  811. // Query hidden buttons and unhide them
  812. const hiddenButtons = mediaButtons.querySelectorAll("button.btn-icon.hide");
  813. let onDownload = null;
  814. for (const btn of hiddenButtons) {
  815. btn.classList.remove("hide");
  816. if (btn.textContent === FORWARD_ICON) {
  817. btn.classList.add("tgico-forward");
  818. }
  819. if (btn.textContent === DOWNLOAD_ICON) {
  820. btn.classList.add("tgico-download");
  821. // Use official download buttons
  822. onDownload = () => {
  823. btn.click();
  824. };
  825. logger.info("onDownload", onDownload);
  826. }
  827. }
  828.  
  829. if (mediaAspecter.querySelector(".ckin__player")) {
  830. // 1. Video player detected - Video and it has finished initial loading
  831. // container > .ckin__player > video[src]
  832.  
  833. // add download button to videos
  834. const controls = mediaAspecter.querySelector(
  835. ".default__controls.ckin__controls"
  836. );
  837. if (controls && !controls.querySelector(".tel-download")) {
  838. const brControls = controls.querySelector(
  839. ".bottom-controls .right-controls"
  840. );
  841. const downloadButton = document.createElement("button");
  842. downloadButton.className =
  843. "btn-icon default__button tgico-download tel-download";
  844. downloadButton.innerHTML = `<span class="tgico">${DOWNLOAD_ICON}</span>`;
  845. downloadButton.setAttribute("type", "button");
  846. downloadButton.setAttribute("title", "Download");
  847. downloadButton.setAttribute("aria-label", "Download");
  848. if (onDownload) {
  849. downloadButton.onclick = onDownload;
  850. } else {
  851. downloadButton.onclick = () => {
  852. tel_download_video(mediaAspecter.querySelector("video").src);
  853. };
  854. }
  855. brControls.prepend(downloadButton);
  856. }
  857. } else if (
  858. mediaAspecter.querySelector("video") &&
  859. mediaAspecter.querySelector("video") &&
  860. !mediaButtons.querySelector("button.btn-icon.tgico-download")
  861. ) {
  862. // 2. Video HTML element detected, could be either GIF or unloaded video
  863. // container > video[src]
  864. const downloadButton = document.createElement("button");
  865. downloadButton.className = "btn-icon tgico-download tel-download";
  866. downloadButton.innerHTML = `<span class="tgico button-icon">${DOWNLOAD_ICON}</span>`;
  867. downloadButton.setAttribute("type", "button");
  868. downloadButton.setAttribute("title", "Download");
  869. downloadButton.setAttribute("aria-label", "Download");
  870. if (onDownload) {
  871. downloadButton.onclick = onDownload;
  872. } else {
  873. downloadButton.onclick = () => {
  874. tel_download_video(mediaAspecter.querySelector("video").src);
  875. };
  876. }
  877. mediaButtons.prepend(downloadButton);
  878. } else if (!mediaButtons.querySelector("button.btn-icon.tgico-download")) {
  879. // 3. Image without download button detected
  880. // container > img.thumbnail
  881. if (
  882. !mediaAspecter.querySelector("img.thumbnail") ||
  883. !mediaAspecter.querySelector("img.thumbnail").src
  884. ) {
  885. return;
  886. }
  887. const downloadButton = document.createElement("button");
  888. downloadButton.className = "btn-icon tgico-download tel-download";
  889. downloadButton.innerHTML = `<span class="tgico button-icon">${DOWNLOAD_ICON}</span>`;
  890. downloadButton.setAttribute("type", "button");
  891. downloadButton.setAttribute("title", "Download");
  892. downloadButton.setAttribute("aria-label", "Download");
  893. if (onDownload) {
  894. downloadButton.onclick = onDownload;
  895. } else {
  896. downloadButton.onclick = () => {
  897. tel_download_image(mediaAspecter.querySelector("img.thumbnail").src);
  898. };
  899. }
  900. mediaButtons.prepend(downloadButton);
  901. }
  902. }, REFRESH_DELAY);
  903.  
  904. // Progress bar container setup
  905. (function setupProgressBar() {
  906. const body = document.querySelector("body");
  907. const container = document.createElement("div");
  908. container.id = "tel-downloader-progress-bar-container";
  909. container.style.position = "fixed";
  910. container.style.bottom = "100px";
  911. container.style.right = 0;
  912. if (location.pathname.startsWith("/k/")) {
  913. container.style.zIndex = 4;
  914. } else {
  915. container.style.zIndex = 1600;
  916. }
  917. body.appendChild(container);
  918. })();
  919.  
  920. logger.info("Completed script setup.");
  921. })();