// ==UserScript==
// @name Reddit Media Downloader
// @namespace http://tampermonkey.net/
// @version 0.84
// @description Adds a download button to Reddit posts with images or videos.
// @author Yukiteru
// @match https://www.reddit.com/*
// @grant GM_download
// @grant GM_log
// @license MIT
// @run-at document-idle
// ==/UserScript==
(function () {
"use strict";
const PROCESSED_MARKER_CLASS = "rmd-processed";
const BUTTON_CLASSES =
"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";
const BUTTON_SPAN_CLASSES = "flex items-center";
// --- Helper Functions ---
function sanitizeFilename(name) {
// Remove invalid filename characters and replace sequences of whitespace/underscores with a single underscore
return name
.replace(/[<>:"/\\|?*\x00-\x1F]/g, "")
.replace(/\s+/g, "_")
.replace(/__+/g, "_")
.substring(0, 150); // Limit length to avoid issues
}
function getOriginalImageUrl(previewUrl) {
try {
const url = new URL(previewUrl);
const pathname = url.pathname;
const lastHyphenIndex = pathname.lastIndexOf("-");
const filename = pathname.slice(lastHyphenIndex + 1);
return `https://i.redd.it/${filename}`;
} catch (e) {
GM_log(`Error during getOriginalImageUrl: ${e}`);
}
}
function triggerDownload(url, filename) {
GM_log(`Downloading: ${filename} from ${url}`);
try {
GM_download({
url: url,
name: filename,
onerror: err => GM_log(`Download failed for ${filename}:`, err),
// onload: () => GM_log(`Download started for ${filename}`), // Optional: Log success start
// ontimeout: () => GM_log(`Download timed out for ${filename}`) // Optional: Log timeout
});
} catch (e) {
GM_log(`GM_download error for ${filename}:`, e);
}
}
// --- Core Logic ---
function processPost(postElement) {
if (!postElement || postElement.classList.contains(PROCESSED_MARKER_CLASS)) {
GM_log("invalid element or already processed");
return; // Already processed or invalid element
}
// Check for shadow root readiness - sometimes it takes a moment
const buttonsContainer = postElement.shadowRoot?.querySelector(".shreddit-post-container");
if (!buttonsContainer) {
GM_log("Post shadowRoot not ready, will retry.");
// Re-check shortly - avoids infinite loops if it never appears
setTimeout(() => processPost(postElement), 250);
return;
}
// Prevent adding multiple buttons if processing runs slightly delayed
if (buttonsContainer.querySelector(".rmd-download-button")) {
GM_log("Already processed, skipping");
postElement.classList.add(PROCESSED_MARKER_CLASS); // Ensure marked
return;
}
const postType = postElement.getAttribute("post-type");
// GM_log(postType);
let mediaUrls = [];
// --- Media Detection ---
switch (postType) {
case "gallery":
const galleryContainer = postElement.querySelector(
'shreddit-async-loader[bundlename="gallery_carousel"]'
);
const galleryClone = galleryContainer.querySelector("gallery-carousel").cloneNode(true);
const imageContainers = galleryClone.querySelectorAll("ul > li");
imageContainers.forEach(container => {
const image = container.querySelector("img");
const imageSrc = image.src || image.getAttribute("data-lazy-src");
console.log(imageSrc);
const originalUrl = getOriginalImageUrl(imageSrc);
if (originalUrl) mediaUrls.push({ url: originalUrl, type: "image" });
});
break;
case "image":
const imageContainer = postElement.querySelector("shreddit-media-lightbox-listener");
const img = imageContainer.querySelector('img[src^="https://preview.redd.it"]');
if (img) {
const originalUrl = getOriginalImageUrl(img.src);
if (originalUrl) mediaUrls.push({ url: originalUrl, type: "image" });
}
break;
case "video":
const videoContainer = postElement.querySelector(
'shreddit-async-loader[bundlename="shreddit_player_2_loader"]'
);
const videoPlayer = videoContainer.querySelector("shreddit-player-2");
// Need to wait for video player's shadow DOM and video tag if necessary
const checkVideo = player => {
if (!player.shadowRoot) {
GM_log("Video player shadowRoot not ready, retrying...");
setTimeout(() => checkVideo(player), 250);
return;
}
const video = player.shadowRoot.querySelector("video");
if (video && video.src) {
// Prefer source tag if available and higher quality (heuristic)
let bestSrc = video.src;
const sources = player.shadowRoot.querySelectorAll("video > source[src]");
if (sources.length > 0) {
// Simple heuristic: assume last source might be better/direct mp4
bestSrc = sources[sources.length - 1].src;
}
mediaUrls.push({ url: bestSrc, type: "video" });
addDownloadButton(postElement, buttonsContainer, mediaUrls);
} else if (video && !video.src) {
GM_log("Video tag found but no src yet, retrying...");
setTimeout(() => checkVideo(player), 500); // Wait longer for video src
} else if (!video) {
GM_log("Video tag not found in player shadowRoot yet, retrying...");
setTimeout(() => checkVideo(player), 250);
} else {
// Video player exists but no media found after checks
postElement.classList.add(PROCESSED_MARKER_CLASS);
}
};
if (videoPlayer) {
checkVideo(videoPlayer);
// Button addition is handled inside checkVideo callback for videos
return; // Stop further processing for this post until video is ready/checked
}
}
// Add button immediately for images/galleries if URLs were found
if (mediaUrls.length > 0 && (postType === "image" || postType === "gallery")) {
addDownloadButton(postElement, buttonsContainer, mediaUrls);
} else {
// If no media found after checking all types, mark as processed
postElement.classList.add(PROCESSED_MARKER_CLASS);
}
}
function addDownloadButton(postElement, buttonsContainer, mediaUrls) {
if (buttonsContainer.querySelector(".rmd-download-button")) return; // Double check
// --- Get Title ---
let title = "Reddit_Media"; // Default title
// const article = postElement.closest("article");
// const h1Title = document.querySelector("main h1"); // More specific for post pages
const subredditName = postElement.getAttribute("subreddit-name");
const postId = postElement.getAttribute("id").slice(3);
const postTitle = postElement.getAttribute("post-title").slice(0, 20);
title = `${subredditName}_${postId}_${postTitle.trim()}`;
const cleanTitle = sanitizeFilename(title);
// --- Create Button ---
const downloadButton = document.createElement("button");
downloadButton.className = `${BUTTON_CLASSES} rmd-download-button`; // Add our class
downloadButton.setAttribute("name", "comments-action-button"); // Match existing buttons
downloadButton.setAttribute("type", "button");
const iconContainer = document.createElement("span");
iconContainer.setAttribute("class", "flex text-16 mr-[var(--rem6)]");
const buttonIcon = buttonsContainer
.querySelector('svg[icon-name="downvote-outline"]')
.cloneNode(true);
iconContainer.appendChild(buttonIcon);
downloadButton.appendChild(iconContainer);
const buttonSpan = document.createElement("span");
buttonSpan.className = BUTTON_SPAN_CLASSES;
buttonSpan.textContent = "Download";
downloadButton.appendChild(buttonSpan);
// --- Add Click Listener ---
downloadButton.addEventListener("click", event => {
event.preventDefault();
event.stopPropagation();
mediaUrls.forEach((media, index) => {
try {
const url = media.url;
// Skip blob URLs if we couldn't resolve them better earlier
if (url.startsWith("blob:")) {
GM_log(`Skipping download for unresolved blob URL: ${url} in post: ${cleanTitle}`);
alert(
`This video is in blob format which this script can't handle, try use a external method to download it.`
);
return;
}
const urlObj = new URL(url);
let ext = urlObj.pathname.split(".").pop().toLowerCase();
// Basic extension check/fix
if (!ext || ext.length > 5) {
// Basic check if extension extraction failed
ext = media.type === "video" ? "mp4" : "jpg"; // Default extensions
}
// Refine extension for common image types if possible, keep original otherwise
if (media.type === "image" && !["jpg", "jpeg", "png", "gif", "webp"].includes(ext)) {
// Check original URL from preview if it exists
const originalExtMatch = url.match(/\.(jpe?g|png|gif|webp)(?:[?#]|$)/i);
if (originalExtMatch) ext = originalExtMatch[1].toLowerCase();
else ext = "jpg"; // Fallback image extension
} else if (media.type === "video" && !["mp4", "mov", "webm"].includes(ext)) {
ext = "mp4"; // Fallback video extension
}
let filename = `Reddit_${cleanTitle}`;
if (mediaUrls.length > 1) {
filename += `_${index + 1}`;
}
filename += `.${ext}`;
triggerDownload(url, filename);
} catch (e) {
GM_log(`Error during download preparation for ${media.url}:`, e);
}
});
});
// --- Append Button ---
// Insert after the comments button if possible, otherwise just append
const shareButton = buttonsContainer.querySelector("[name='share-button']");
shareButton.insertAdjacentElement("afterend", downloadButton);
// Mark as processed AFTER button is successfully added
postElement.classList.add(PROCESSED_MARKER_CLASS);
GM_log("Added download button to post:", cleanTitle);
}
// --- Observer ---
function handleMutations(mutations) {
GM_log("Handle Mutations");
let postsToProcess = new Set();
for (const mutation of mutations) {
if (mutation.type === "childList" && mutation.addedNodes.length > 0) {
for (const node of mutation.addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) {
// Case 1: A whole post element is added
if (node.matches && node.matches("shreddit-post")) {
postsToProcess.add(node);
}
// Case 2: Content *inside* a post is added (like async media)
// Check if the added node is a media container itself
else if (
node.matches &&
(node.matches('shreddit-async-loader[bundlename="post_detail_gallery"]') ||
node.matches("gallery-carousel") ||
node.matches("shreddit-media-lightbox-listener") ||
node.matches("shreddit-player-2"))
) {
const parentPost = node.closest("shreddit-post");
if (parentPost) {
postsToProcess.add(parentPost);
}
}
// Case 3: Handle posts potentially nested within added nodes (e.g., inside articles in feeds)
else if (node.querySelectorAll) {
node.querySelectorAll("shreddit-post").forEach(post => postsToProcess.add(post));
}
}
}
}
}
// Process all unique posts found in this mutation batch
postsToProcess.forEach(processPost);
}
function initObserver() {
GM_log("Reddit Media Downloader initializing...");
// Initial scan for posts already on the page
document.querySelectorAll(`shreddit-post:not(.${PROCESSED_MARKER_CLASS})`).forEach(processPost);
// Set up the observer
const observer = new MutationObserver(handleMutations);
const observerConfig = {
childList: true,
subtree: true,
};
// Observe the body, as posts can appear in feeds, main content, etc.
observer.observe(document.body, observerConfig);
GM_log("Reddit Media Downloader initialized and observing.");
}
initObserver();
})();