Reddit Media Downloader

Adds a download button to Reddit posts with images or videos.

  1. // ==UserScript==
  2. // @name Reddit Media Downloader
  3. // @namespace http://tampermonkey.net/
  4. // @version 0.84
  5. // @description Adds a download button to Reddit posts with images or videos.
  6. // @author Yukiteru
  7. // @match https://www.reddit.com/*
  8. // @grant GM_download
  9. // @grant GM_log
  10. // @license MIT
  11. // @run-at document-idle
  12. // ==/UserScript==
  13.  
  14. (function () {
  15. "use strict";
  16.  
  17. const PROCESSED_MARKER_CLASS = "rmd-processed";
  18. const BUTTON_CLASSES =
  19. "button border-md flex flex-row justify-center items-center h-xl font-semibold relative text-12 button-secondary inline-flex items-center px-sm";
  20. const BUTTON_SPAN_CLASSES = "flex items-center";
  21.  
  22. // --- Helper Functions ---
  23.  
  24. function sanitizeFilename(name) {
  25. // Remove invalid filename characters and replace sequences of whitespace/underscores with a single underscore
  26. return name
  27. .replace(/[<>:"/\\|?*\x00-\x1F]/g, "")
  28. .replace(/\s+/g, "_")
  29. .replace(/__+/g, "_")
  30. .substring(0, 150); // Limit length to avoid issues
  31. }
  32.  
  33. function getOriginalImageUrl(previewUrl) {
  34. try {
  35. const url = new URL(previewUrl);
  36.  
  37. const pathname = url.pathname;
  38. const lastHyphenIndex = pathname.lastIndexOf("-");
  39. const filename = pathname.slice(lastHyphenIndex + 1);
  40.  
  41. return `https://i.redd.it/${filename}`;
  42. } catch (e) {
  43. GM_log(`Error during getOriginalImageUrl: ${e}`);
  44. }
  45. }
  46.  
  47. function triggerDownload(url, filename) {
  48. GM_log(`Downloading: ${filename} from ${url}`);
  49. try {
  50. GM_download({
  51. url: url,
  52. name: filename,
  53. onerror: err => GM_log(`Download failed for ${filename}:`, err),
  54. // onload: () => GM_log(`Download started for ${filename}`), // Optional: Log success start
  55. // ontimeout: () => GM_log(`Download timed out for ${filename}`) // Optional: Log timeout
  56. });
  57. } catch (e) {
  58. GM_log(`GM_download error for ${filename}:`, e);
  59. }
  60. }
  61.  
  62. // --- Core Logic ---
  63.  
  64. function processPost(postElement) {
  65. if (!postElement || postElement.classList.contains(PROCESSED_MARKER_CLASS)) {
  66. GM_log("invalid element or already processed");
  67. return; // Already processed or invalid element
  68. }
  69.  
  70. // Check for shadow root readiness - sometimes it takes a moment
  71. const buttonsContainer = postElement.shadowRoot?.querySelector(".shreddit-post-container");
  72. if (!buttonsContainer) {
  73. GM_log("Post shadowRoot not ready, will retry.");
  74. // Re-check shortly - avoids infinite loops if it never appears
  75. setTimeout(() => processPost(postElement), 250);
  76. return;
  77. }
  78.  
  79. // Prevent adding multiple buttons if processing runs slightly delayed
  80. if (buttonsContainer.querySelector(".rmd-download-button")) {
  81. GM_log("Already processed, skipping");
  82. postElement.classList.add(PROCESSED_MARKER_CLASS); // Ensure marked
  83. return;
  84. }
  85.  
  86. const postType = postElement.getAttribute("post-type");
  87. // GM_log(postType);
  88.  
  89. let mediaUrls = [];
  90.  
  91. // --- Media Detection ---
  92. switch (postType) {
  93. case "gallery":
  94. const galleryContainer = postElement.querySelector(
  95. 'shreddit-async-loader[bundlename="gallery_carousel"]'
  96. );
  97. const galleryClone = galleryContainer.querySelector("gallery-carousel").cloneNode(true);
  98. const imageContainers = galleryClone.querySelectorAll("ul > li");
  99. imageContainers.forEach(container => {
  100. const image = container.querySelector("img");
  101. const imageSrc = image.src || image.getAttribute("data-lazy-src");
  102. console.log(imageSrc);
  103. const originalUrl = getOriginalImageUrl(imageSrc);
  104. if (originalUrl) mediaUrls.push({ url: originalUrl, type: "image" });
  105. });
  106. break;
  107. case "image":
  108. const imageContainer = postElement.querySelector("shreddit-media-lightbox-listener");
  109. const img = imageContainer.querySelector('img[src^="https://preview.redd.it"]');
  110. if (img) {
  111. const originalUrl = getOriginalImageUrl(img.src);
  112. if (originalUrl) mediaUrls.push({ url: originalUrl, type: "image" });
  113. }
  114. break;
  115. case "video":
  116. const videoContainer = postElement.querySelector(
  117. 'shreddit-async-loader[bundlename="shreddit_player_2_loader"]'
  118. );
  119. const videoPlayer = videoContainer.querySelector("shreddit-player-2");
  120. // Need to wait for video player's shadow DOM and video tag if necessary
  121. const checkVideo = player => {
  122. if (!player.shadowRoot) {
  123. GM_log("Video player shadowRoot not ready, retrying...");
  124. setTimeout(() => checkVideo(player), 250);
  125. return;
  126. }
  127. const video = player.shadowRoot.querySelector("video");
  128. if (video && video.src) {
  129. // Prefer source tag if available and higher quality (heuristic)
  130. let bestSrc = video.src;
  131. const sources = player.shadowRoot.querySelectorAll("video > source[src]");
  132. if (sources.length > 0) {
  133. // Simple heuristic: assume last source might be better/direct mp4
  134. bestSrc = sources[sources.length - 1].src;
  135. }
  136.  
  137. mediaUrls.push({ url: bestSrc, type: "video" });
  138. addDownloadButton(postElement, buttonsContainer, mediaUrls);
  139. } else if (video && !video.src) {
  140. GM_log("Video tag found but no src yet, retrying...");
  141. setTimeout(() => checkVideo(player), 500); // Wait longer for video src
  142. } else if (!video) {
  143. GM_log("Video tag not found in player shadowRoot yet, retrying...");
  144. setTimeout(() => checkVideo(player), 250);
  145. } else {
  146. // Video player exists but no media found after checks
  147. postElement.classList.add(PROCESSED_MARKER_CLASS);
  148. }
  149. };
  150.  
  151. if (videoPlayer) {
  152. checkVideo(videoPlayer);
  153. // Button addition is handled inside checkVideo callback for videos
  154. return; // Stop further processing for this post until video is ready/checked
  155. }
  156. }
  157.  
  158. // Add button immediately for images/galleries if URLs were found
  159. if (mediaUrls.length > 0 && (postType === "image" || postType === "gallery")) {
  160. addDownloadButton(postElement, buttonsContainer, mediaUrls);
  161. } else {
  162. // If no media found after checking all types, mark as processed
  163. postElement.classList.add(PROCESSED_MARKER_CLASS);
  164. }
  165. }
  166.  
  167. function addDownloadButton(postElement, buttonsContainer, mediaUrls) {
  168. if (buttonsContainer.querySelector(".rmd-download-button")) return; // Double check
  169.  
  170. // --- Get Title ---
  171. let title = "Reddit_Media"; // Default title
  172.  
  173. // const article = postElement.closest("article");
  174. // const h1Title = document.querySelector("main h1"); // More specific for post pages
  175.  
  176. const subredditName = postElement.getAttribute("subreddit-name");
  177. const postId = postElement.getAttribute("id").slice(3);
  178. const postTitle = postElement.getAttribute("post-title").slice(0, 20);
  179.  
  180. title = `${subredditName}_${postId}_${postTitle.trim()}`;
  181. const cleanTitle = sanitizeFilename(title);
  182.  
  183. // --- Create Button ---
  184. const downloadButton = document.createElement("button");
  185. downloadButton.className = `${BUTTON_CLASSES} rmd-download-button`; // Add our class
  186. downloadButton.setAttribute("name", "comments-action-button"); // Match existing buttons
  187. downloadButton.setAttribute("type", "button");
  188.  
  189. const iconContainer = document.createElement("span");
  190. iconContainer.setAttribute("class", "flex text-16 mr-[var(--rem6)]");
  191.  
  192. const buttonIcon = buttonsContainer
  193. .querySelector('svg[icon-name="downvote-outline"]')
  194. .cloneNode(true);
  195. iconContainer.appendChild(buttonIcon);
  196. downloadButton.appendChild(iconContainer);
  197.  
  198. const buttonSpan = document.createElement("span");
  199. buttonSpan.className = BUTTON_SPAN_CLASSES;
  200. buttonSpan.textContent = "Download";
  201.  
  202. downloadButton.appendChild(buttonSpan);
  203.  
  204. // --- Add Click Listener ---
  205. downloadButton.addEventListener("click", event => {
  206. event.preventDefault();
  207. event.stopPropagation();
  208.  
  209. mediaUrls.forEach((media, index) => {
  210. try {
  211. const url = media.url;
  212. // Skip blob URLs if we couldn't resolve them better earlier
  213. if (url.startsWith("blob:")) {
  214. GM_log(`Skipping download for unresolved blob URL: ${url} in post: ${cleanTitle}`);
  215. alert(
  216. `This video is in blob format which this script can't handle, try use a external method to download it.`
  217. );
  218. return;
  219. }
  220.  
  221. const urlObj = new URL(url);
  222. let ext = urlObj.pathname.split(".").pop().toLowerCase();
  223. // Basic extension check/fix
  224. if (!ext || ext.length > 5) {
  225. // Basic check if extension extraction failed
  226. ext = media.type === "video" ? "mp4" : "jpg"; // Default extensions
  227. }
  228. // Refine extension for common image types if possible, keep original otherwise
  229. if (media.type === "image" && !["jpg", "jpeg", "png", "gif", "webp"].includes(ext)) {
  230. // Check original URL from preview if it exists
  231. const originalExtMatch = url.match(/\.(jpe?g|png|gif|webp)(?:[?#]|$)/i);
  232. if (originalExtMatch) ext = originalExtMatch[1].toLowerCase();
  233. else ext = "jpg"; // Fallback image extension
  234. } else if (media.type === "video" && !["mp4", "mov", "webm"].includes(ext)) {
  235. ext = "mp4"; // Fallback video extension
  236. }
  237.  
  238. let filename = `Reddit_${cleanTitle}`;
  239. if (mediaUrls.length > 1) {
  240. filename += `_${index + 1}`;
  241. }
  242. filename += `.${ext}`;
  243.  
  244. triggerDownload(url, filename);
  245. } catch (e) {
  246. GM_log(`Error during download preparation for ${media.url}:`, e);
  247. }
  248. });
  249. });
  250.  
  251. // --- Append Button ---
  252. // Insert after the comments button if possible, otherwise just append
  253. const shareButton = buttonsContainer.querySelector("[name='share-button']");
  254. shareButton.insertAdjacentElement("afterend", downloadButton);
  255.  
  256. // Mark as processed AFTER button is successfully added
  257. postElement.classList.add(PROCESSED_MARKER_CLASS);
  258. GM_log("Added download button to post:", cleanTitle);
  259. }
  260.  
  261. // --- Observer ---
  262.  
  263. function handleMutations(mutations) {
  264. GM_log("Handle Mutations");
  265. let postsToProcess = new Set();
  266.  
  267. for (const mutation of mutations) {
  268. if (mutation.type === "childList" && mutation.addedNodes.length > 0) {
  269. for (const node of mutation.addedNodes) {
  270. if (node.nodeType === Node.ELEMENT_NODE) {
  271. // Case 1: A whole post element is added
  272. if (node.matches && node.matches("shreddit-post")) {
  273. postsToProcess.add(node);
  274. }
  275. // Case 2: Content *inside* a post is added (like async media)
  276. // Check if the added node is a media container itself
  277. else if (
  278. node.matches &&
  279. (node.matches('shreddit-async-loader[bundlename="post_detail_gallery"]') ||
  280. node.matches("gallery-carousel") ||
  281. node.matches("shreddit-media-lightbox-listener") ||
  282. node.matches("shreddit-player-2"))
  283. ) {
  284. const parentPost = node.closest("shreddit-post");
  285. if (parentPost) {
  286. postsToProcess.add(parentPost);
  287. }
  288. }
  289. // Case 3: Handle posts potentially nested within added nodes (e.g., inside articles in feeds)
  290. else if (node.querySelectorAll) {
  291. node.querySelectorAll("shreddit-post").forEach(post => postsToProcess.add(post));
  292. }
  293. }
  294. }
  295. }
  296. }
  297. // Process all unique posts found in this mutation batch
  298. postsToProcess.forEach(processPost);
  299. }
  300.  
  301. function initObserver() {
  302. GM_log("Reddit Media Downloader initializing...");
  303.  
  304. // Initial scan for posts already on the page
  305. document.querySelectorAll(`shreddit-post:not(.${PROCESSED_MARKER_CLASS})`).forEach(processPost);
  306.  
  307. // Set up the observer
  308. const observer = new MutationObserver(handleMutations);
  309. const observerConfig = {
  310. childList: true,
  311. subtree: true,
  312. };
  313.  
  314. // Observe the body, as posts can appear in feeds, main content, etc.
  315. observer.observe(document.body, observerConfig);
  316.  
  317. GM_log("Reddit Media Downloader initialized and observing.");
  318. }
  319.  
  320. initObserver();
  321. })();