// ==UserScript==
// @name Discord Media Exporter
// @namespace http://tampermonkey.net/
// @version 1.1.0
// @description Preview and download all images & videos from a Discord channel in one ZIP, or copy an aria2c‐compatible URL list to clipboard (with example command).
// @license CC-BY-NC-4.0
// @author DestCom
// @match https://discord.com/channels/*
// @grant GM_xmlhttpRequest
// @connect cdn.discordapp.com
// @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.7.1/jszip.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js
// ==/UserScript==
(function() {
'use strict';
//////////////////////////////////////////////////////
// 1. CREATE “EXPORT MEDIA” BUTTON
//////////////////////////////////////////////////////
const exportBtn = document.createElement('button');
exportBtn.textContent = '📥 Export Media';
Object.assign(exportBtn.style, {
position: 'fixed',
top: '70px',
right: '20px',
zIndex: 1000,
padding: '8px 12px',
backgroundColor: '#7289DA',
color: '#FFFFFF',
border: 'none',
borderRadius: '4px',
fontSize: '14px',
cursor: 'pointer',
boxShadow: '0 2px 6px rgba(0,0,0,0.2)'
});
document.body.appendChild(exportBtn);
//////////////////////////////////////////////////////
// 2. BUILD THE MAIN MODAL (Media Preview)
//////////////////////////////////////////////////////
const modalOverlay = document.createElement('div');
modalOverlay.id = 'dme-modal-overlay';
Object.assign(modalOverlay.style, {
position: 'fixed',
top: '0',
left: '0',
width: '100%',
height: '100%',
backgroundColor: 'rgba(0,0,0,0.75)',
display: 'none',
justifyContent: 'center',
alignItems: 'center',
zIndex: 2000,
overflowY: 'auto'
});
const modalContent = document.createElement('div');
modalContent.id = 'dme-modal-content';
Object.assign(modalContent.style, {
backgroundColor: '#2F3136',
borderRadius: '8px',
padding: '16px',
maxWidth: '90%',
maxHeight: '90%',
overflowY: 'auto',
color: '#FFFFFF',
boxSizing: 'border-box'
});
const modalHeader = document.createElement('div');
Object.assign(modalHeader.style, {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '12px'
});
const headerTitle = document.createElement('h2');
headerTitle.id = 'dme-modal-title';
headerTitle.textContent = 'Media Preview (0/0)';
Object.assign(headerTitle.style, {
margin: '0',
fontSize: '18px'
});
const closeBtn = document.createElement('button');
closeBtn.innerHTML = '✖';
Object.assign(closeBtn.style, {
background: 'none',
border: 'none',
color: '#CCC',
fontSize: '20px',
cursor: 'pointer'
});
closeBtn.title = 'Close';
modalHeader.appendChild(headerTitle);
modalHeader.appendChild(closeBtn);
const tableContainer = document.createElement('div');
tableContainer.id = 'dme-table-container';
Object.assign(tableContainer.style, {
overflowX: 'auto',
backgroundColor: '#2F3136'
});
const table = document.createElement('table');
table.id = 'dme-media-table';
Object.assign(table.style, {
width: '100%',
borderCollapse: 'collapse',
color: '#FFFFFF'
});
const thead = document.createElement('thead');
const headerRow = document.createElement('tr');
['#', 'Filename', 'Type', 'Include'].forEach(text => {
const th = document.createElement('th');
th.textContent = text;
Object.assign(th.style, {
borderBottom: '2px solid #40444B',
padding: '8px',
textAlign: 'left',
fontSize: '14px'
});
headerRow.appendChild(th);
});
thead.appendChild(headerRow);
const tbody = document.createElement('tbody');
table.appendChild(thead);
table.appendChild(tbody);
tableContainer.appendChild(table);
const modalFooter = document.createElement('div');
Object.assign(modalFooter.style, {
marginTop: '16px',
textAlign: 'right'
});
// Download ZIP button
const downloadSelectedBtn = document.createElement('button');
downloadSelectedBtn.textContent = 'Download Selected Media';
Object.assign(downloadSelectedBtn.style, {
padding: '8px 14px',
backgroundColor: '#43B581',
color: '#FFFFFF',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '14px',
marginRight: '8px'
});
// Copy aria2c list button
const copyAria2cBtn = document.createElement('button');
copyAria2cBtn.textContent = 'Copy aria2c List';
Object.assign(copyAria2cBtn.style, {
padding: '8px 14px',
backgroundColor: '#7289DA',
color: '#FFFFFF',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '14px'
});
modalFooter.appendChild(downloadSelectedBtn);
modalFooter.appendChild(copyAria2cBtn);
modalContent.appendChild(modalHeader);
modalContent.appendChild(tableContainer);
modalContent.appendChild(modalFooter);
modalOverlay.appendChild(modalContent);
document.body.appendChild(modalOverlay);
//////////////////////////////////////////////////////
// 3. BUILD THE STATUS MODAL (Success/Failure + Aria2c example)
//////////////////////////////////////////////////////
const statusOverlay = document.createElement('div');
statusOverlay.id = 'dme-status-overlay';
Object.assign(statusOverlay.style, {
position: 'fixed',
top: '0',
left: '0',
width: '100%',
height: '100%',
backgroundColor: 'rgba(0,0,0,0.75)',
display: 'none',
justifyContent: 'center',
alignItems: 'center',
zIndex: 3000,
overflowY: 'auto'
});
const statusContent = document.createElement('div');
statusContent.id = 'dme-status-content';
Object.assign(statusContent.style, {
backgroundColor: '#2F3136',
borderRadius: '8px',
padding: '16px',
maxWidth: '450px',
color: '#FFFFFF',
boxSizing: 'border-box',
textAlign: 'left'
});
const statusTextarea = document.createElement('textarea');
statusTextarea.readOnly = true;
Object.assign(statusTextarea.style, {
width: '100%',
height: '150px',
backgroundColor: '#1E1F22',
color: '#FFFFFF',
border: '1px solid #40444B',
borderRadius: '4px',
padding: '8px',
fontSize: '14px',
resize: 'vertical',
boxSizing: 'border-box',
whiteSpace: 'pre-wrap'
});
const statusOkBtn = document.createElement('button');
statusOkBtn.textContent = 'OK';
Object.assign(statusOkBtn.style, {
padding: '8px 14px',
backgroundColor: '#43B581',
color: '#FFFFFF',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '14px',
display: 'block',
margin: '12px auto 0'
});
statusContent.appendChild(statusTextarea);
statusContent.appendChild(statusOkBtn);
statusOverlay.appendChild(statusContent);
document.body.appendChild(statusOverlay);
//////////////////////////////////////////////////////
// 4. UTILITIES
//////////////////////////////////////////////////////
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function getMessageContainer() {
return document.querySelector('[aria-label="Messages"]');
}
async function loadAllMessages() {
const container = getMessageContainer();
if (!container) return;
await sleep(500);
let previousHeight = -1;
for (let i = 0; i < 50; i++) {
const currentHeight = container.scrollHeight;
if (currentHeight === previousHeight) break;
previousHeight = currentHeight;
container.scrollTop = 0;
await sleep(1000);
}
await sleep(2000);
}
function collectMediaLinks() {
const urls = new Set();
function sanitize(raw) {
return raw.replace(/[&;]+$/,'');
}
document.querySelectorAll('a').forEach(a => {
const href = a.getAttribute('href');
if (href && href.includes('cdn.discordapp.com/attachments')) {
const full = href.split('?')[0] + (href.includes('?') ? href.slice(href.indexOf('?')) : '');
urls.add(sanitize(full));
}
});
document.querySelectorAll('video').forEach(v => {
const src = v.getAttribute('src');
if (src && src.includes('cdn.discordapp.com/attachments')) {
const full = src.split('?')[0] + (src.includes('?') ? src.slice(src.indexOf('?')) : '');
urls.add(sanitize(full));
}
});
document.querySelectorAll('source').forEach(sourceElem => {
const src = sourceElem.getAttribute('src');
if (src && src.includes('cdn.discordapp.com/attachments')) {
const full = src.split('?')[0] + (src.includes('?') ? src.slice(src.indexOf('?')) : '');
urls.add(sanitize(full));
}
});
document.querySelectorAll('img').forEach(img => {
const src = img.getAttribute('src');
if (src && src.includes('cdn.discordapp.com/attachments')) {
const full = src.split('?')[0] + (src.includes('?') ? src.slice(src.indexOf('?')) : '');
urls.add(sanitize(full));
}
});
return Array.from(urls);
}
function isImageUrl(url) {
return /\.(jpe?g|png|webp|gif)(?:\?|$)/i.test(url);
}
function isVideoUrl(url) {
return /\.(mp4|webm|mov)(?:\?|$)/i.test(url);
}
function updateHeaderCount(selectedCount, totalCount) {
headerTitle.textContent = `Media Preview (${selectedCount}/${totalCount})`;
}
function openModal() {
modalOverlay.style.display = 'flex';
}
function closeModal() {
modalOverlay.style.display = 'none';
tbody.innerHTML = '';
}
function openStatus(messageText) {
statusTextarea.value = messageText;
statusOverlay.style.display = 'flex';
statusTextarea.select();
}
function closeStatus() {
statusOverlay.style.display = 'none';
}
// Promisified GM_xmlhttpRequest to fetch ArrayBuffer
function gmFetchArrayBuffer(url) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: url,
responseType: 'arraybuffer',
onload: res => {
if (res.status >= 200 && res.status < 300 && res.response) {
resolve(res.response);
} else {
reject(new Error(`Status ${res.status}`));
}
},
onerror: err => reject(err),
ontimeout: () => reject(new Error('Timeout')),
});
});
}
//////////////////////////////////////////////////////
// 5. DOWNLOAD LOGIC WITH STATUS
//////////////////////////////////////////////////////
async function downloadCheckedMedia() {
const checkedRows = Array.from(tbody.querySelectorAll('tr')).filter(tr => {
const checkbox = tr.querySelector('input[type="checkbox"]');
return checkbox && checkbox.checked;
});
if (checkedRows.length === 0) {
openStatus('No files selected to download.');
return;
}
const zip = new JSZip();
let successCount = 0;
let failureCount = 0;
downloadSelectedBtn.disabled = true;
downloadSelectedBtn.textContent = `⏳ Zipping 0/${checkedRows.length}…`;
for (let i = 0; i < checkedRows.length; i++) {
const row = checkedRows[i];
const url = row.dataset.url;
try {
const arrBuf = await gmFetchArrayBuffer(url);
const blob = new Blob([arrBuf]);
const filename = new URL(url).pathname.split('/').pop();
zip.file(filename, blob);
successCount++;
} catch (err) {
console.warn('[DME] Failed to fetch', url, err);
failureCount++;
}
downloadSelectedBtn.textContent = `⏳ Zipping ${Math.min(successCount + failureCount, checkedRows.length)}/${checkedRows.length}…`;
}
if (successCount > 0) {
const zipBlob = await zip.generateAsync({ type: 'blob' });
saveAs(zipBlob, 'discord_media.zip');
openStatus(
`✅ ${successCount} file(s) downloaded successfully, ${failureCount} failed.
— Example aria2c command —
aria2c --input-file="./urls.txt" \\
--dir="./discord_media" \\
--max-concurrent-downloads=5 \\
--split=4 \\
--max-connection-per-server=4 \\
--header="Referer: https://discord.com/" \\
-c`
);
} else {
openStatus('No files could be downloaded. Please check your network or permissions.');
}
downloadSelectedBtn.textContent = 'Download Selected Media';
downloadSelectedBtn.disabled = false;
closeModal();
}
//////////////////////////////////////////////////////
// 6. COPY ARIA2C LIST LOGIC
//////////////////////////////////////////////////////
function copyAria2cList() {
const rows = Array.from(tbody.querySelectorAll('tr'));
if (rows.length === 0) {
openStatus('No media to list.');
return;
}
let lines = '';
rows.forEach(tr => {
const url = tr.dataset.url;
const filename = new URL(url).pathname.split('/').pop();
lines += `${url}\n out=${filename}\n`;
});
navigator.clipboard.writeText(lines.trim())
.then(() => {
openStatus(
`✅ Copied ${rows.length} URLs to clipboard.
— Example aria2c command —
aria2c --input-file="./urls.txt" \\
--dir="./discord_media" \\
--max-concurrent-downloads=5 \\
--split=4 \\
--max-connection-per-server=4 \\
--header="Referer: https://discord.com/" \\
-c`
);
})
.catch(() => openStatus('Failed to copy to clipboard.'));
}
//////////////////////////////////////////////////////
// 7. EVENT BINDINGS
//////////////////////////////////////////////////////
exportBtn.addEventListener('click', async () => {
exportBtn.disabled = true;
exportBtn.textContent = '⏳ Loading messages…';
await loadAllMessages();
exportBtn.textContent = '⏳ Collecting media links…';
const mediaLinks = collectMediaLinks();
if (mediaLinks.length === 0) {
openStatus('No media found in this channel.');
exportBtn.textContent = '📥 Export Media';
exportBtn.disabled = false;
return;
}
tbody.innerHTML = '';
const totalCount = mediaLinks.length;
let selectedCount = totalCount;
updateHeaderCount(selectedCount, totalCount);
mediaLinks.forEach((url, index) => {
const tr = document.createElement('tr');
tr.dataset.url = url;
const tdIndex = document.createElement('td');
tdIndex.textContent = (index + 1).toString();
Object.assign(tdIndex.style, {
borderBottom: '1px solid #40444B',
padding: '6px 8px',
fontSize: '14px',
width: '40px'
});
const tdFilename = document.createElement('td');
tdFilename.textContent = new URL(url).pathname.split('/').pop();
Object.assign(tdFilename.style, {
borderBottom: '1px solid #40444B',
padding: '6px 8px',
fontSize: '14px'
});
const tdType = document.createElement('td');
tdType.textContent = isImageUrl(url) ? 'Image' : (isVideoUrl(url) ? 'Video' : 'Unknown');
Object.assign(tdType.style, {
borderBottom: '1px solid #40444B',
padding: '6px 8px',
fontSize: '14px',
width: '80px'
});
const tdCheck = document.createElement('td');
Object.assign(tdCheck.style, {
borderBottom: '1px solid #40444B',
padding: '6px 8px',
textAlign: 'center',
width: '60px'
});
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = true;
checkbox.addEventListener('change', () => {
selectedCount = Array.from(tbody.querySelectorAll('input[type="checkbox"]'))
.filter(cb => cb.checked).length;
updateHeaderCount(selectedCount, totalCount);
});
tdCheck.appendChild(checkbox);
tr.appendChild(tdIndex);
tr.appendChild(tdFilename);
tr.appendChild(tdType);
tr.appendChild(tdCheck);
tbody.appendChild(tr);
});
openModal();
exportBtn.textContent = '📥 Export Media';
exportBtn.disabled = false;
downloadSelectedBtn.onclick = async () => {
await downloadCheckedMedia();
};
copyAria2cBtn.onclick = () => {
copyAria2cList();
};
});
closeBtn.addEventListener('click', closeModal);
modalOverlay.addEventListener('click', e => {
if (e.target === modalOverlay) closeModal();
});
statusOkBtn.addEventListener('click', closeStatus);
statusOverlay.addEventListener('click', e => {
if (e.target === statusOverlay) closeStatus();
});
})();