Greasy Fork is available in English.

Telegram Media Downloader

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

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