Telegram图片视频下载器 (Improved)

从禁止下载的Telegram频道中下载图片、视频及语音消息

  1. // ==UserScript==
  2. // @name Telegram Media Downloader (Improved)
  3. // @name:zh-CN Telegram图片视频下载器 (Improved)
  4. // @version 1.4
  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 = "\uE95A";
  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. if (!progressBar || !progressBar.classList.contains("aborted")) {
  185. return;
  186. }
  187. progressBar.classList.remove("aborted");
  188. progressBar.querySelector("div.progress div").style.backgroundColor = "#6093B5";
  189. const videoUrl = progressBar.getAttribute("tel-video-url");
  190. // console.log("Retrying download: " + videoUrl);
  191. tel_download_video(videoUrl, videoId);
  192. };
  193.  
  194. const AbortProgress = (videoId) => {
  195. const progressBar = document
  196. .getElementById("tel-downloader-progress-" + videoId)
  197. .querySelector("div.progress");
  198. progressBar.classList.add("aborted");
  199. progressBar.querySelector("p").innerText = "Aborted";
  200. progressBar.querySelector("div").style.backgroundColor = "#D16666";
  201. progressBar.querySelector("div").style.width = "100%";
  202. const retryLink = document.createElement("a");
  203. retryLink.innerText = "retry";
  204. retryLink.style.marginLeft = "5px";
  205. retryLink.href = "javascript:void(0);";
  206. retryLink.onclick = () => { retryDownload(videoId); };
  207. progressBar.querySelector("p").appendChild(retryLink);
  208. window.setTimeout(() => retryDownload(videoId), 30000);
  209. checkResumeNextDownload();
  210. };
  211.  
  212. const tel_download_video = (url, videoId) => {
  213. let _blobs = [];
  214. let _next_offset = 0;
  215. let _total_size = null;
  216. let _file_extension = "mp4";
  217.  
  218. videoId = videoId || (Math.random() + 1).toString(36).substring(2, 10) + "_" + Date.now().toString();
  219. let fileName = hashCode(url).toString(36) + "." + _file_extension;
  220.  
  221. // Some video src is in format:
  222. // 'stream/{"dcId":5,"location":{...},"size":...,"mimeType":"video/mp4","fileName":"xxxx.MP4"}'
  223. try {
  224. const metadata = JSON.parse(
  225. decodeURIComponent(url.split("/")[url.split("/").length - 1])
  226. );
  227. if (metadata.fileName) {
  228. fileName = metadata.fileName;
  229. }
  230. } catch (e) {
  231. // Invalid JSON string, pass extracting fileName
  232. }
  233. logger.info(`URL: ${url}`, fileName);
  234.  
  235. const fetchNextPart = (_writable) => {
  236. fetch(url, {
  237. method: "GET",
  238. headers: {
  239. Range: `bytes=${_next_offset}-`,
  240. },
  241. "User-Agent":
  242. "User-Agent Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/117.0",
  243. })
  244. .then((res) => {
  245. if (![200, 206].includes(res.status)) {
  246. throw new Error("Non 200/206 response was received: " + res.status);
  247. }
  248. const mime = res.headers.get("Content-Type").split(";")[0];
  249. if (!mime.startsWith("video/")) {
  250. throw new Error("Get non video response with MIME type " + mime);
  251. }
  252. _file_extension = mime.split("/")[1];
  253. fileName =
  254. fileName.substring(0, fileName.indexOf(".") + 1) + _file_extension;
  255.  
  256. const match = res.headers
  257. .get("Content-Range")
  258. .match(contentRangeRegex);
  259.  
  260. const startOffset = parseInt(match[1]);
  261. const endOffset = parseInt(match[2]);
  262. const totalSize = parseInt(match[3]);
  263.  
  264. if (startOffset !== _next_offset) {
  265. logger.error("Gap detected between responses.", fileName);
  266. logger.info("Last offset: " + _next_offset, fileName);
  267. logger.info("New start offset " + match[1], fileName);
  268. throw "Gap detected between responses.";
  269. }
  270. if (_total_size && totalSize !== _total_size) {
  271. logger.error("Total size differs", fileName);
  272. throw "Total size differs";
  273. }
  274.  
  275. _next_offset = endOffset + 1;
  276. _total_size = totalSize;
  277.  
  278. logger.info(
  279. `Get response: ${res.headers.get(
  280. "Content-Length"
  281. )} bytes data from ${res.headers.get("Content-Range")}`,
  282. fileName
  283. );
  284. logger.info(
  285. `Progress: ${((_next_offset * 100) / _total_size).toFixed(0)}%`,
  286. fileName
  287. );
  288. updateProgress(
  289. videoId,
  290. fileName,
  291. ((_next_offset * 100) / _total_size).toFixed(0),
  292. url
  293. );
  294. return res.blob();
  295. })
  296. .then((resBlob) => {
  297. if (_writable !== null) {
  298. _writable.write(resBlob).then(() => {});
  299. } else {
  300. _blobs.push(resBlob);
  301. }
  302. })
  303. .then(() => {
  304. if (!_total_size) {
  305. throw new Error("_total_size is NULL");
  306. }
  307.  
  308. if (_next_offset < _total_size) {
  309. const progressBar = document.getElementById("tel-downloader-progress-" + videoId)
  310. if (progressBar.classList.contains("queued")) {
  311. progressBar.resume = () => fetchNextPart(_writable);
  312. } else {
  313. fetchNextPart(_writable);
  314. }
  315. } else {
  316. if (_writable !== null) {
  317. _writable.close().then(() => {
  318. logger.info("Download finished", fileName);
  319. });
  320. } else {
  321. save();
  322. }
  323. completeProgress(videoId);
  324. }
  325. })
  326. .catch((reason) => {
  327. logger.error("Download failed: ", reason, fileName);
  328. AbortProgress(videoId);
  329. });
  330. };
  331.  
  332. const save = () => {
  333. logger.info("Finish downloading blobs", fileName);
  334. logger.info("Concatenating blobs and downloading...", fileName);
  335.  
  336. const blob = new Blob(_blobs, { type: "video/mp4" });
  337. const blobUrl = window.URL.createObjectURL(blob);
  338.  
  339. logger.info("Final blob size: " + blob.size + " bytes", fileName);
  340.  
  341. const a = document.createElement("a");
  342. document.body.appendChild(a);
  343. a.href = blobUrl;
  344. a.download = fileName;
  345. a.click();
  346. document.body.removeChild(a);
  347. window.URL.revokeObjectURL(blobUrl);
  348.  
  349. logger.info("Download triggered", fileName);
  350. };
  351.  
  352. const supportsFileSystemAccess =
  353. "showSaveFilePicker" in unsafeWindow &&
  354. (() => {
  355. try {
  356. return unsafeWindow.self === unsafeWindow.top;
  357. } catch {
  358. return false;
  359. }
  360. })();
  361. // console.log("supportsFileSystemAccess:", supportsFileSystemAccess);
  362. if (supportsFileSystemAccess) {
  363. unsafeWindow
  364. .showSaveFilePicker({
  365. suggestedName: fileName,
  366. })
  367. .then((handle) => {
  368. handle
  369. .createWritable()
  370. .then((writable) => {
  371. const progressBar = createProgressBar(videoId);
  372. // if (progressBar.getAttribute("queued") === "true") {
  373. // progressBar.resume = () => fetchNextPart(writable);
  374. // } else {
  375. fetchNextPart(writable);
  376. // }
  377. })
  378. .catch((err) => {
  379. console.error(err.name, err.message);
  380. });
  381. })
  382. .catch((err) => {
  383. if (err.name !== "AbortError") {
  384. console.error(err.name, err.message);
  385. }
  386. });
  387. } else {
  388. const progressBar = createProgressBar(videoId);
  389. // if (progressBar.getAttribute("queued") === "true") {
  390. // progressBar.resume = () => fetchNextPart(null);
  391. // } else {
  392. fetchNextPart(null);
  393. // }
  394. }
  395. };
  396.  
  397. const tel_download_audio = (url) => {
  398. let _blobs = [];
  399. let _next_offset = 0;
  400. let _total_size = null;
  401. const fileName = hashCode(url).toString(36) + ".ogg";
  402.  
  403. const fetchNextPart = (_writable) => {
  404. fetch(url, {
  405. method: "GET",
  406. headers: {
  407. Range: `bytes=${_next_offset}-`,
  408. },
  409. })
  410. .then((res) => {
  411. if (res.status !== 206 && res.status !== 200) {
  412. logger.error(
  413. "Non 200/206 response was received: " + res.status,
  414. fileName
  415. );
  416. return;
  417. }
  418.  
  419. const mime = res.headers.get("Content-Type").split(";")[0];
  420. if (!mime.startsWith("audio/")) {
  421. logger.error(
  422. "Get non audio response with MIME type " + mime,
  423. fileName
  424. );
  425. throw "Get non audio response with MIME type " + mime;
  426. }
  427.  
  428. try {
  429. const match = res.headers
  430. .get("Content-Range")
  431. .match(contentRangeRegex);
  432.  
  433. const startOffset = parseInt(match[1]);
  434. const endOffset = parseInt(match[2]);
  435. const totalSize = parseInt(match[3]);
  436.  
  437. if (startOffset !== _next_offset) {
  438. logger.error("Gap detected between responses.");
  439. logger.info("Last offset: " + _next_offset);
  440. logger.info("New start offset " + match[1]);
  441. throw "Gap detected between responses.";
  442. }
  443. if (_total_size && totalSize !== _total_size) {
  444. logger.error("Total size differs");
  445. throw "Total size differs";
  446. }
  447.  
  448. _next_offset = endOffset + 1;
  449. _total_size = totalSize;
  450. } finally {
  451. logger.info(
  452. `Get response: ${res.headers.get(
  453. "Content-Length"
  454. )} bytes data from ${res.headers.get("Content-Range")}`
  455. );
  456. return res.blob();
  457. }
  458. })
  459. .then((resBlob) => {
  460. if (_writable !== null) {
  461. _writable.write(resBlob).then(() => {});
  462. } else {
  463. _blobs.push(resBlob);
  464. }
  465. })
  466. .then(() => {
  467. if (_next_offset < _total_size) {
  468. fetchNextPart(_writable);
  469. } else {
  470. if (_writable !== null) {
  471. _writable.close().then(() => {
  472. logger.info("Download finished", fileName);
  473. });
  474. } else {
  475. save();
  476. }
  477. }
  478. })
  479. .catch((reason) => {
  480. logger.error(reason, fileName);
  481. });
  482. };
  483.  
  484. const save = () => {
  485. logger.info(
  486. "Finish downloading blobs. Concatenating blobs and downloading...",
  487. fileName
  488. );
  489.  
  490. let blob = new Blob(_blobs, { type: "audio/ogg" });
  491. const blobUrl = window.URL.createObjectURL(blob);
  492.  
  493. logger.info("Final blob size in bytes: " + blob.size, fileName);
  494.  
  495. blob = 0;
  496.  
  497. const a = document.createElement("a");
  498. document.body.appendChild(a);
  499. a.href = blobUrl;
  500. a.download = fileName;
  501. a.click();
  502. document.body.removeChild(a);
  503. window.URL.revokeObjectURL(blobUrl);
  504.  
  505. logger.info("Download triggered", fileName);
  506. };
  507.  
  508. const supportsFileSystemAccess =
  509. "showSaveFilePicker" in unsafeWindow &&
  510. (() => {
  511. try {
  512. return unsafeWindow.self === unsafeWindow.top;
  513. } catch {
  514. return false;
  515. }
  516. })();
  517. if (supportsFileSystemAccess) {
  518. unsafeWindow
  519. .showSaveFilePicker({
  520. suggestedName: fileName,
  521. })
  522. .then((handle) => {
  523. handle
  524. .createWritable()
  525. .then((writable) => {
  526. fetchNextPart(writable);
  527. })
  528. .catch((err) => {
  529. console.error(err.name, err.message);
  530. });
  531. })
  532. .catch((err) => {
  533. if (err.name !== "AbortError") {
  534. console.error(err.name, err.message);
  535. }
  536. });
  537. } else {
  538. fetchNextPart(null);
  539. }
  540. };
  541.  
  542. const tel_download_image = (imageUrl) => {
  543. const fileName =
  544. (Math.random() + 1).toString(36).substring(2, 10) + ".jpeg"; // assume jpeg
  545.  
  546. const a = document.createElement("a");
  547. document.body.appendChild(a);
  548. a.href = imageUrl;
  549. a.download = fileName;
  550. a.click();
  551. document.body.removeChild(a);
  552.  
  553. logger.info("Download triggered", fileName);
  554. };
  555.  
  556. logger.info("Initialized");
  557.  
  558. // For webz /a/ webapp
  559. setInterval(() => {
  560. // Stories
  561. const storiesContainer = document.getElementById("StoryViewer");
  562. if (storiesContainer) {
  563. console.log("storiesContainer");
  564. const createDownloadButton = () => {
  565. console.log("createDownloadButton");
  566. const downloadIcon = document.createElement("i");
  567. downloadIcon.className = "icon icon-download";
  568. const downloadButton = document.createElement("button");
  569. downloadButton.className =
  570. "Button TkphaPyQ tiny translucent-white round tel-download";
  571. downloadButton.appendChild(downloadIcon);
  572. downloadButton.setAttribute("type", "button");
  573. downloadButton.setAttribute("title", "Download");
  574. downloadButton.setAttribute("aria-label", "Download");
  575. downloadButton.onclick = () => {
  576. // 1. Story with video
  577. const video = storiesContainer.querySelector("video");
  578. const videoSrc =
  579. video?.src ||
  580. video?.currentSrc ||
  581. video?.querySelector("source")?.src;
  582. if (videoSrc) {
  583. tel_download_video(videoSrc);
  584. } else {
  585. // 2. Story with image
  586. const images = storiesContainer.querySelectorAll("img.PVZ8TOWS");
  587. if (images.length > 0) {
  588. const imageSrc = images[images.length - 1]?.src;
  589. if (imageSrc) tel_download_image(imageSrc);
  590. }
  591. }
  592. };
  593. return downloadButton;
  594. };
  595.  
  596. const storyHeader =
  597. storiesContainer.querySelector(".GrsJNw3y") ||
  598. storiesContainer.querySelector(".DropdownMenu").parentNode;
  599. if (storyHeader && !storyHeader.querySelector(".tel-download")) {
  600. console.log("storyHeader");
  601. storyHeader.insertBefore(
  602. createDownloadButton(),
  603. storyHeader.querySelector("button")
  604. );
  605. }
  606. }
  607.  
  608. // All media opened are located in .media-viewer-movers > .media-viewer-aspecter
  609. const mediaContainer = document.querySelector(
  610. "#MediaViewer .MediaViewerSlide--active"
  611. );
  612. const mediaViewerActions = document.querySelector(
  613. "#MediaViewer .MediaViewerActions"
  614. );
  615. if (!mediaContainer || !mediaViewerActions) return;
  616.  
  617. // Videos in channels
  618. const videoPlayer = mediaContainer.querySelector(
  619. ".MediaViewerContent > .VideoPlayer"
  620. );
  621. const img = mediaContainer.querySelector(".MediaViewerContent > div > img");
  622. // 1. Video player detected - Video or GIF
  623. // container > .MediaViewerSlides > .MediaViewerSlide > .MediaViewerContent > .VideoPlayer > video[src]
  624. const downloadIcon = document.createElement("i");
  625. downloadIcon.className = "icon icon-download";
  626. const downloadButton = document.createElement("button");
  627. downloadButton.className =
  628. "Button smaller translucent-white round tel-download";
  629. downloadButton.setAttribute("type", "button");
  630. downloadButton.setAttribute("title", "Download");
  631. downloadButton.setAttribute("aria-label", "Download");
  632. if (videoPlayer) {
  633. const videoUrl = videoPlayer.querySelector("video").currentSrc;
  634. downloadButton.setAttribute("data-tel-download-url", videoUrl);
  635. downloadButton.appendChild(downloadIcon);
  636. downloadButton.onclick = () => {
  637. tel_download_video(videoPlayer.querySelector("video").currentSrc);
  638. };
  639.  
  640. // Add download button to video controls
  641. const controls = videoPlayer.querySelector(".VideoPlayerControls");
  642. if (controls) {
  643. const buttons = controls.querySelector(".buttons");
  644. if (!buttons.querySelector("button.tel-download")) {
  645. const spacer = buttons.querySelector(".spacer");
  646. spacer.after(downloadButton);
  647. }
  648. }
  649.  
  650. // Add/Update/Remove download button to topbar
  651. if (mediaViewerActions.querySelector("button.tel-download")) {
  652. const telDownloadButton = mediaViewerActions.querySelector(
  653. "button.tel-download"
  654. );
  655. if (
  656. mediaViewerActions.querySelectorAll('button[title="Download"]')
  657. .length > 1
  658. ) {
  659. // There's existing download button, remove ours
  660. mediaViewerActions.querySelector("button.tel-download").remove();
  661. } else if (
  662. telDownloadButton.getAttribute("data-tel-download-url") !== videoUrl
  663. ) {
  664. // Update existing button
  665. telDownloadButton.onclick = () => {
  666. tel_download_video(videoPlayer.querySelector("video").currentSrc);
  667. };
  668. telDownloadButton.setAttribute("data-tel-download-url", videoUrl);
  669. }
  670. } else if (
  671. !mediaViewerActions.querySelector('button[title="Download"]')
  672. ) {
  673. // Add the button if there's no download button at all
  674. mediaViewerActions.prepend(downloadButton);
  675. }
  676. } else if (img && img.src) {
  677. downloadButton.setAttribute("data-tel-download-url", img.src);
  678. downloadButton.appendChild(downloadIcon);
  679. downloadButton.onclick = () => {
  680. tel_download_image(img.src);
  681. };
  682.  
  683. // Add/Update/Remove download button to topbar
  684. if (mediaViewerActions.querySelector("button.tel-download")) {
  685. const telDownloadButton = mediaViewerActions.querySelector(
  686. "button.tel-download"
  687. );
  688. if (
  689. mediaViewerActions.querySelectorAll('button[title="Download"]')
  690. .length > 1
  691. ) {
  692. // There's existing download button, remove ours
  693. mediaViewerActions.querySelector("button.tel-download").remove();
  694. } else if (
  695. telDownloadButton.getAttribute("data-tel-download-url") !== img.src
  696. ) {
  697. // Update existing button
  698. telDownloadButton.onclick = () => {
  699. tel_download_image(img.src);
  700. };
  701. telDownloadButton.setAttribute("data-tel-download-url", img.src);
  702. }
  703. } else if (
  704. !mediaViewerActions.querySelector('button[title="Download"]')
  705. ) {
  706. // Add the button if there's no download button at all
  707. mediaViewerActions.prepend(downloadButton);
  708. }
  709. }
  710. }, REFRESH_DELAY);
  711.  
  712. // For webk /k/ webapp
  713. setInterval(() => {
  714. /* Voice Message */
  715. const pinnedAudio = document.body.querySelector(".pinned-audio");
  716. let dataMid;
  717. let downloadButtonPinnedAudio =
  718. document.body.querySelector("._tel_download_button_pinned_container") ||
  719. document.createElement("button");
  720. if (pinnedAudio) {
  721. dataMid = pinnedAudio.getAttribute("data-mid");
  722. downloadButtonPinnedAudio.className =
  723. "btn-icon tgico-download _tel_download_button_pinned_container";
  724. downloadButtonPinnedAudio.innerHTML = `<span class="tgico button-icon">${DOWNLOAD_ICON}</span>`;
  725. }
  726. const voiceMessages = document.body.querySelectorAll("audio-element");
  727. voiceMessages.forEach((voiceMessage) => {
  728. const bubble = voiceMessage.closest(".bubble");
  729. if (
  730. !bubble ||
  731. bubble.querySelector("._tel_download_button_pinned_container")
  732. ) {
  733. return; /* Skip if there's already a download button */
  734. }
  735. if (
  736. dataMid &&
  737. downloadButtonPinnedAudio.getAttribute("data-mid") !== dataMid &&
  738. voiceMessage.getAttribute("data-mid") === dataMid
  739. ) {
  740. downloadButtonPinnedAudio.onclick = (e) => {
  741. e.stopPropagation();
  742. tel_download_audio(link);
  743. };
  744. downloadButtonPinnedAudio.setAttribute("data-mid", dataMid);
  745. const link =
  746. voiceMessage.audio && voiceMessage.audio.getAttribute("src");
  747. if (link) {
  748. pinnedAudio
  749. .querySelector(".pinned-container-wrapper-utils")
  750. .appendChild(downloadButtonPinnedAudio);
  751. }
  752. }
  753. });
  754.  
  755. // Stories
  756. const storiesContainer = document.getElementById("stories-viewer");
  757. if (storiesContainer) {
  758. const createDownloadButton = () => {
  759. const downloadButton = document.createElement("button");
  760. downloadButton.className = "btn-icon rp tel-download";
  761. downloadButton.innerHTML = `<span class="tgico">${DOWNLOAD_ICON}</span><div class="c-ripple"></div>`;
  762. downloadButton.setAttribute("type", "button");
  763. downloadButton.setAttribute("title", "Download");
  764. downloadButton.setAttribute("aria-label", "Download");
  765. downloadButton.onclick = () => {
  766. // 1. Story with video
  767. const video = storiesContainer.querySelector("video.media-video");
  768. const videoSrc =
  769. video?.src ||
  770. video?.currentSrc ||
  771. video?.querySelector("source")?.src;
  772. if (videoSrc) {
  773. tel_download_video(videoSrc);
  774. } else {
  775. // 2. Story with image
  776. const imageSrc =
  777. storiesContainer.querySelector("img.media-photo")?.src;
  778. if (imageSrc) tel_download_image(imageSrc);
  779. }
  780. };
  781. return downloadButton;
  782. };
  783.  
  784. const storyHeader = storiesContainer.querySelector(
  785. "[class^='_ViewerStoryHeaderRight']"
  786. );
  787. if (storyHeader && !storyHeader.querySelector(".tel-download")) {
  788. storyHeader.prepend(createDownloadButton());
  789. }
  790.  
  791. const storyFooter = storiesContainer.querySelector(
  792. "[class^='_ViewerStoryFooterRight']"
  793. );
  794. if (storyFooter && !storyFooter.querySelector(".tel-download")) {
  795. storyFooter.prepend(createDownloadButton());
  796. }
  797. }
  798.  
  799. // All media opened are located in .media-viewer-movers > .media-viewer-aspecter
  800. const mediaContainer = document.querySelector(".media-viewer-whole");
  801. if (!mediaContainer) return;
  802. const mediaAspecter = mediaContainer.querySelector(
  803. ".media-viewer-movers .media-viewer-aspecter"
  804. );
  805. const mediaButtons = mediaContainer.querySelector(
  806. ".media-viewer-topbar .media-viewer-buttons"
  807. );
  808. if (!mediaAspecter || !mediaButtons) return;
  809.  
  810. // Query hidden buttons and unhide them
  811. const hiddenButtons = mediaButtons.querySelectorAll("button.btn-icon.hide");
  812. let onDownload = null;
  813. for (const btn of hiddenButtons) {
  814. btn.classList.remove("hide");
  815. if (btn.textContent === FORWARD_ICON) {
  816. btn.classList.add("tgico-forward");
  817. }
  818. if (btn.textContent === DOWNLOAD_ICON) {
  819. btn.classList.add("tgico-download");
  820. // Use official download buttons
  821. onDownload = () => {
  822. btn.click();
  823. };
  824. logger.info("onDownload", onDownload);
  825. }
  826. }
  827.  
  828. if (mediaAspecter.querySelector(".ckin__player")) {
  829. // 1. Video player detected - Video and it has finished initial loading
  830. // container > .ckin__player > video[src]
  831.  
  832. // add download button to videos
  833. const controls = mediaAspecter.querySelector(
  834. ".default__controls.ckin__controls"
  835. );
  836. if (controls && !controls.querySelector(".tel-download")) {
  837. const brControls = controls.querySelector(
  838. ".bottom-controls .right-controls"
  839. );
  840. const downloadButton = document.createElement("button");
  841. downloadButton.className =
  842. "btn-icon default__button tgico-download tel-download";
  843. downloadButton.innerHTML = `<span class="tgico">${DOWNLOAD_ICON}</span>`;
  844. downloadButton.setAttribute("type", "button");
  845. downloadButton.setAttribute("title", "Download");
  846. downloadButton.setAttribute("aria-label", "Download");
  847. if (onDownload) {
  848. downloadButton.onclick = onDownload;
  849. } else {
  850. downloadButton.onclick = () => {
  851. tel_download_video(mediaAspecter.querySelector("video").src);
  852. };
  853. }
  854. brControls.prepend(downloadButton);
  855. }
  856. } else if (
  857. mediaAspecter.querySelector("video") &&
  858. mediaAspecter.querySelector("video") &&
  859. !mediaButtons.querySelector("button.btn-icon.tgico-download")
  860. ) {
  861. // 2. Video HTML element detected, could be either GIF or unloaded video
  862. // container > video[src]
  863. const downloadButton = document.createElement("button");
  864. downloadButton.className = "btn-icon tgico-download tel-download";
  865. downloadButton.innerHTML = `<span class="tgico button-icon">${DOWNLOAD_ICON}</span>`;
  866. downloadButton.setAttribute("type", "button");
  867. downloadButton.setAttribute("title", "Download");
  868. downloadButton.setAttribute("aria-label", "Download");
  869. if (onDownload) {
  870. downloadButton.onclick = onDownload;
  871. } else {
  872. downloadButton.onclick = () => {
  873. tel_download_video(mediaAspecter.querySelector("video").src);
  874. };
  875. }
  876. mediaButtons.prepend(downloadButton);
  877. } else if (!mediaButtons.querySelector("button.btn-icon.tgico-download")) {
  878. // 3. Image without download button detected
  879. // container > img.thumbnail
  880. if (
  881. !mediaAspecter.querySelector("img.thumbnail") ||
  882. !mediaAspecter.querySelector("img.thumbnail").src
  883. ) {
  884. return;
  885. }
  886. const downloadButton = document.createElement("button");
  887. downloadButton.className = "btn-icon tgico-download tel-download";
  888. downloadButton.innerHTML = `<span class="tgico button-icon">${DOWNLOAD_ICON}</span>`;
  889. downloadButton.setAttribute("type", "button");
  890. downloadButton.setAttribute("title", "Download");
  891. downloadButton.setAttribute("aria-label", "Download");
  892. if (onDownload) {
  893. downloadButton.onclick = onDownload;
  894. } else {
  895. downloadButton.onclick = () => {
  896. tel_download_image(mediaAspecter.querySelector("img.thumbnail").src);
  897. };
  898. }
  899. mediaButtons.prepend(downloadButton);
  900. }
  901. }, REFRESH_DELAY);
  902.  
  903. // Progress bar container setup
  904. (function setupProgressBar() {
  905. const body = document.querySelector("body");
  906. const container = document.createElement("div");
  907. container.id = "tel-downloader-progress-bar-container";
  908. container.style.position = "fixed";
  909. container.style.bottom = "100px";
  910. container.style.top = "56px";
  911. container.style.overflow = "auto";
  912. container.style.right = 0;
  913. if (location.pathname.startsWith("/k/")) {
  914. container.style.zIndex = 4;
  915. } else {
  916. container.style.zIndex = 1600;
  917. }
  918. body.appendChild(container);
  919. })();
  920.  
  921. logger.info("Completed script setup.");
  922. })();