// ==UserScript==
// @name Bluesky Image/Video Download Button
// @namespace KanashiiWolf
// @match https://bsky.app/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_info
// @version 1.8.0
// @author KanashiiWolf, the-nelsonator, coredumperror
// @description Adds a download button to Bluesky images and videos. Built off coredumperror's script with a few improvements.
// @license MIT
// ==/UserScript==
(function() {
'use strict';
// Filename template settings
const defaultTemplate = "@<%username>-bsky-<%post_id>-<%img_num>";
let filenameTemplate = GM_getValue('filename', defaultTemplate);
const postUrlRegex = /\/profile\/[^/]+\/post\/[A-Za-z0-9]+/;
// Download button HTML (using template literals for readability)
const downloadButtonHTML = `
<div class="download-button" style="
cursor: pointer;
z-index: 999;
display: table;
font-size: 15px;
color: white;
position: absolute;
left: 5px;
top: 5px;
background: #0000007f;
height: 30px;
width: 30px;
border-radius: 15px;
text-align: center;">
<svg class="icon" style="
width: 15px;
height: 15px;
vertical-align: top;
display: inline-block;
margin-top: 7px;
fill: currentColor;
overflow: hidden;"
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg">
<path d="M925.248 356.928l-258.176-258.176a64 64 0 0 0-45.248-18.752H144a64 64 0 0 0-64 64v736a64 64 0 0 0 64 64h736a64 64 0 0 0 64-64V402.176a64 64 0 0 0-18.752-45.248zM288 144h192V256H288V144z m448 736H288V736h448v144z m144 0H800V704a32 32 0 0 0-32-32H256a32 32 0 0 0-32 32v176H144v-736H224V288a32 32 0 0 0 32 32h256a32 32 0 0 0 32-32V144h77.824l258.176 258.176V880z"></path>
</svg>
</div>`;
const config = {
childList: true,
subtree: true
};
let headerNode;
let settingsButton = false;
const waitForLoad = (mutationList, observer) => {
for (const mutation of mutationList) {
for (let node of mutation.addedNodes) {
if (!(node instanceof HTMLElement)) continue;
headerNode = node.querySelector('[aria-label="Account"]');
if (headerNode && !settingsButton) {
addFilenameSettings(headerNode);
settingsButton = true;
observer.disconnect();
}
}
}
};
const waitForContent = (mutationList, observer) => {
for (const mutation of mutationList) {
for (let node of mutation.addedNodes) {
if (!(node instanceof HTMLElement)) continue;
const img = node.querySelector('img[src^="https://cdn.bsky.app/img/feed_thumbnail"]');
if (img) {
img.setAttribute('processed', '');
addDownloadButton(img);
}
const vid = node.querySelector('video[poster^="https://video.bsky.app/watch"]');
if (vid) {
vid.setAttribute('processed', '');
addDownloadButton(vid, true);
}
}
}
};
function addFilenameSettings(node) {
const settingsInput = document.createElement('input');
settingsInput.id = 'filename-input-space';
settingsInput.style.cssText = `
display: flex;
align-items: center;
justify-content: center;
margin-top: 10px;
text-align: center;
display: none;`;
settingsInput.addEventListener('keypress', (e) => {
if (e.which === 13) {
settingsInput.style.display = 'none';
settingsButton.style.display = 'flex';
filenameTemplate = settingsInput.value;
GM_setValue('filename', filenameTemplate);
}
});
const settingsButton = document.createElement('a');
settingsButton.id = 'filename-input-button';
settingsButton.textContent = `Download Button Filename Template v${GM_info.script.version}`;
settingsButton.style.cssText = `
display: flex;
align-items: center;
justify-content: center;
margin-top: 10px;
border: 2px solid;
cursor: pointer;`;
settingsButton.addEventListener('click', (e) => {
e.preventDefault();
settingsButton.style.display = 'none';
settingsInput.style.display = 'flex';
settingsInput.focus();
settingsInput.value = filenameTemplate;
});
node.parentNode.insertBefore(settingsButton, node);
node.parentNode.insertBefore(settingsInput, settingsButton);
}
const contentObserver = new MutationObserver(waitForContent);
const settingsObserver = new MutationObserver(waitForLoad);
settingsObserver.observe(document, config);
contentObserver.observe(document, config);
function downloadContent(url, data) {
const urlArray = url.split('/');
const did = data.isVideo ? urlArray[4] : urlArray[6];
const cid = data.isVideo ?
urlArray[5] :
urlArray[7].split('@')[0];
fetch(`https://bsky.social/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}`)
.then(response => {
if (!response.ok) {
throw new Error(`Couldn't retrieve blob! Response: ${response}`);
}
return response.blob();
})
.then(blob => sendFile(data, blob));
}
function getExtensionFromBlob(blob) {
// Create a mapping of common MIME types to their extensions
const mimeTypeToExtension = {
'image/jpeg': 'jpg',
'image/png': 'png',
'image/gif': 'gif',
'image/webp': 'webp',
'image/svg+xml': 'svg',
'audio/mpeg': 'mp3',
'audio/ogg': 'ogg',
'audio/wav': 'wav',
'video/mp4': 'mp4',
'video/webm': 'webm',
'video/ogg': 'ogv',
'application/pdf': 'pdf',
'text/plain': 'txt',
'text/html': 'html',
'application/json': 'json',
'application/zip': 'zip',
// Add more MIME types and extensions as needed
};
// Get the MIME type from the blob
const mimeType = blob.type;
// Check if the MIME type is in the mapping
if (mimeTypeToExtension[mimeType]) {
return mimeTypeToExtension[mimeType];
}
// If the MIME type is not found, try to guess the extension from the file name
if (blob.name) {
const fileName = blob.name;
const lastDotIndex = fileName.lastIndexOf('.');
if (lastDotIndex !== -1) {
return fileName.substring(lastDotIndex + 1).toLowerCase();
}
}
// If all else fails, return an empty string
return '';
}
function sendFile(data, blob) {
const filename = convertFilename(data) + `.${getExtensionFromBlob(blob)}`;
const downloadEl = document.createElement('a');
downloadEl.href = URL.createObjectURL(blob);
downloadEl.download = filename;
downloadEl.click();
}
function createDownloadLink() {
let downloadLink = document.getElementById('img-download-button');
if (!downloadLink) {
downloadLink = document.createElement('a');
downloadLink.id = 'img-download-button';
document.getElementById('root').appendChild(downloadLink);
}
return downloadLink;
}
function getImageNumber(image) {
const ancestor = image.parentElement.parentElement.parentElement.parentElement.parentElement.parentElement.parentElement;
const postImages = ancestor.getElementsByTagName('img');
for (let i = 0; i < postImages.length; i++) {
if (postImages[i].src === image.src) {
return i;
}
}
return 0;
}
function addDownloadButton(element, isVideo = false) {
if (element == null) return;
let downloadBtn = document.createElement('div');
let downloadBtnParent;
const mediaUrl = isVideo ? element.poster : element.src;
if (mediaUrl.includes('feed_thumbnail') || isVideo) {
downloadBtnParent = element.parentElement.parentElement;
downloadBtnParent.appendChild(downloadBtn);
downloadBtn.outerHTML = downloadButtonHTML;
} else if (mediaUrl.includes('feed_fullsize')) {
return;
}
downloadBtn = downloadBtnParent.getElementsByClassName('download-button')[0];
const postPath = getPostLink(element);
const pathArray = postPath.split('/');
const username = pathArray[2];
const uname = username.split('.')[0];
const postId = pathArray[4];
const timestamp = new Date().getTime();
const imageNumber = isVideo ? 0 : getImageNumber(element);
const data = {
uname: uname,
username: username,
postId: postId,
timestamp: timestamp,
imageNumber: imageNumber,
isVideo: isVideo
};
// Prevent non-click events
downloadBtn.addEventListener('mousedown', e => e.preventDefault());
downloadBtn.addEventListener('click', e => {
e.stopPropagation();
downloadContent(mediaUrl, data);
return false;
});
};
function getPostLink(element) {
const sep = element.src ? element.src : element.poster;
let path = element.parentElement.innerHTML.split(sep)[0].match(postUrlRegex);
while (path == null) {
element = element.parentElement;
path = element.innerHTML.split(sep)[0].match(postUrlRegex);
if (element.innerHTML.includes("postThreadItem")) {
return window.location.pathname;
}
}
return path[0];
}
function convertFilename(data) {
return filenameTemplate
.replace("<%uname>", data.uname)
.replace("<%username>", data.username)
.replace("<%post_id>", data.postId)
.replace("<%timestamp>", data.timestamp)
.replace("<%img_num>", data.imageNumber);
}
})();