Skip to content

Instantly share code, notes, and snippets.

@peolic
Last active December 28, 2023 17:52
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save peolic/7368022947a28ef11bf44d0ae802df45 to your computer and use it in GitHub Desktop.
Save peolic/7368022947a28ef11bf44d0ae802df45 to your computer and use it in GitHub Desktop.
Add image resolutions to StashDB

StashDB Images Userscript

This userscript adds image resolutions next to every performer image on StashDB.

screenshot

Installation requires a browser extension such as Tampermonkey or Greasemonkey.

// ==UserScript==
// @name StashDB Images
// @author peolic
// @version 1.62
// @description Adds image resolutions next to performer images.
// @namespace https://github.com/peolic
// @match https://stashdb.org/*
// @grant GM.addStyle
// @homepageURL https://gist.github.com/peolic/7368022947a28ef11bf44d0ae802df45
// @downloadURL https://gist.github.com/peolic/7368022947a28ef11bf44d0ae802df45/raw/stashdb-images.user.js
// @updateURL https://gist.github.com/peolic/7368022947a28ef11bf44d0ae802df45/raw/stashdb-images.user.js
// ==/UserScript==
//@ts-check
(() => {
function main() {
globalStyle();
dispatcher();
window.addEventListener(locationChanged, dispatcher);
}
async function dispatcher() {
await elementReadyIn('.MainContent .LoadingIndicator', 100);
const pathname = window.location.pathname.replace(/^\//, '');
const pathParts = pathname ? pathname.split(/\//g) : [];
if (pathParts.length === 0) return;
const [p1, p2, p3] = pathParts;
// /edits | /edits/:uuid | /users/:user/edits
if (
(p1 === 'edits' && !p3)
|| (p1 === 'users' && p2 && p3 === 'edits')
) {
return await iEditCards();
}
// /edits/:uuid/update
// /drafts/:uuid
if (
(p1 === 'edits' && p2 && p3 === 'update')
|| (p1 === 'drafts' && p2 && !p3)
) {
return await iEditUpdatePage(p2);
}
if (p1 === 'performers') {
// /performers/add | /performers/:uuid/edit | /performers/:uuid/merge
if (p2 === 'add' || (p2 && ['edit', 'merge'].includes(p3))) {
return await iPerformerEditPage(p3);
}
// /performers/:uuid
if (p2 && !p3) {
await iPerformerPage();
if (window.location.hash === '#edits') {
await iEditCards();
}
return;
}
}
if (p1 === 'scenes') {
// /scenes/add | /scenes/:uuid/edit
if (p2 === 'add' || (p2 && p3 === 'edit')) {
return await iSceneEditPage();
}
// /scenes/:uuid
if (p2 && !p3) {
await iScenePage();
if (window.location.hash === '#edits') {
await iEditCards();
}
return;
}
return;
}
if (p1 === 'studios') {
// /studios/add | /studios/:uuid/edit
if (p2 === 'add' || (p2 && p3 === 'edit')) {
return await iStudioEditPage();
}
// /studios/:uuid
if (p2 && !p3) {
if (window.location.hash === '#edits') {
await iEditCards();
}
return;
}
return;
}
}
function globalStyle() {
//@ts-expect-error
GM.addStyle(`
.image-resolution {
position: absolute;
left: 0;
bottom: 0;
background-color: #2fb59c;
transition: opacity .2s ease;
font-weight: bold;
padding: 0 .5rem;
}
a.resized-image-marker {
display: inline-block;
}
a.resized-image-marker:hover {
color: var(--bs-cyan);
}
`);
}
/**
* @typedef {Object} ImageData
* @property {string} id
* @property {number} width
* @property {number} height
* @property {string} url
*/
async function iEditCards() {
const selector = '.ImageChangeRow > * > .ImageChangeRow';
const isLoading = !!document.querySelector('.LoadingIndicator');
if (!await elementReadyIn(selector, isLoading ? 5000 : 2000)) return;
/**
* @param {HTMLImageElement} img
*/
const handleImage = async (img) => {
if (img.dataset.injectedResolution) return;
// https://github.com/stashapp/stash-box/blob/v0.2.7/frontend/src/components/imageChangeRow/ImageChangeRow.tsx#L45-L51
const resolution = /** @type {HTMLDivElement} */ (img.nextElementSibling);
// wait for native resolution
// sometimes it doesn't show, possibly due to the image being loaded from cache
// likely to be fixed with https://github.com/stashapp/stash-box/pull/724
if (!resolution.innerText)
await waitForFirstChange(resolution, { childList: true }, 250);
const imgFiber = getReactFiber(img);
/** @type {ImageData} */
const imgData = imgFiber?.return?.return?.memoizedProps?.images.find(({ id }) => id === imgFiber.return.key);
const [width, height] = resolveDimensions(imgData, img);
const isSVG = width <= 0 && height <= 0;
img.dataset.injectedResolution = 'true';
const imgLink = document.createElement('a');
imgLink.href = img.src;
imgLink.target = '_blank';
img.before(imgLink);
img.classList.add('mb-0');
if (isSVG)
imgLink.classList.add('Image', 'fs-6');
imgLink.classList.add('text-center', 'mb-2');
imgLink.appendChild(img);
resolution.innerText = isSVG ? '\u{221E} x \u{221E}' : `${width} x ${height}`;
imgLink.appendChild(resolution);
const resized = makeResizedMarker(imgData, 'ms-1');
if (resized) resolution.appendChild(resized);
};
/** @type {HTMLDivElement[]} */
(Array.from(document.querySelectorAll(selector))).forEach((cr) => {
/** @type {HTMLImageElement[]} */
(Array.from(cr.querySelectorAll(':scope img.ImageChangeRow-image'))).forEach((img) => {
imageReady(img).then(
() => handleImage(img),
() => setTimeout(handleAdBlocked, 100, img),
);
})
});
} // iEditCards
async function iPerformerPage() {
if (!await elementReadyIn('.PerformerInfo', 1000)) return;
const carousel = (
/** @type {HTMLDivElement} */
(await elementReadyIn('.performer-photo .image-carousel-img', 200))
);
if (!carousel || carousel.dataset.injectedResolution) return;
carousel.dataset.injectedResolution = 'true';
const subtitle = /** @type {HTMLHeadingElement} */ (document.querySelector('.performer-photo h5'));
const position = document.createElement('span');
subtitle.appendChild(position);
while (subtitle.firstChild && !subtitle.firstChild.isSameNode(position)) {
position.appendChild(subtitle.firstChild);
}
const separator = document.createElement('span');
separator.classList.add('mx-2');
separator.innerText = '/';
const resolution = document.createElement('span');
subtitle.append(separator, resolution);
/**
* @param {HTMLImageElement | null | undefined} [img]
* @param {ImageData | undefined} [imgData]
*/
const updateResolution = (img, imgData) => {
if (img === null)
resolution.innerText = '??? x ???';
else if (!img)
resolution.innerText = '... x ...';
else if (!imgData)
resolution.innerText = `${img.naturalWidth} x ${img.naturalHeight}`;
else
resolution.innerText = `${imgData.width} x ${imgData.height}`;
const resized = makeResizedMarker(img && imgData, 'ms-1');
if (resized) resolution.appendChild(resized);
};
const handleExistingImage = async () => {
const img = /** @type {HTMLImageElement} */ (carousel.querySelector('img'));
const imgFiber = getReactFiber(/** @type {HTMLDivElement} */ (carousel.querySelector(':scope > .Image')));
/** @type {ImageData} */
const imgData = imgFiber?.return?.memoizedProps?.images;
updateResolution();
imageReady(img).then(
() => updateResolution(img, imgData),
() => {
updateResolution(null, imgData)
setTimeout(handleAdBlocked, 100, img, () => {
const error = /** @type {HTMLElement} */ (
/** @type {HTMLElement} */ (img.nextElementSibling).firstElementChild
);
error.innerText += '\n\nThis image should be visible,\nbut an Ad Blocker is blocking it';
});
},
);
};
await handleExistingImage();
new MutationObserver(handleExistingImage).observe(carousel, { childList: true });
} // iPerformerPage
async function iScenePage() {
const selector = '.ScenePhoto';
const isLoading = !!document.querySelector('.LoadingIndicator');
const scenePhoto = await elementReadyIn(selector, isLoading ? 5000 : 2000);
if (!scenePhoto) return;
const img = /** @type {HTMLImageElement | null} */ (scenePhoto.querySelector('img:not([src=""])'));
if (!img) return;
const imgFiber = getReactFiber(img);
/** @type {ImageData} */
const imgData = imgFiber?.return?.memoizedProps?.images?.[0];
const resized = makeResizedMarker(imgData, 'position-relative');
if (resized) {
const container = document.createElement('div');
container.classList.add('position-absolute', 'end-0');
setStyles(resized, { top: '-26px' });
resized.title += ` (${imgData.width} x ${imgData.height})`;
container.appendChild(resized);
scenePhoto.prepend(container);
}
} // iScenePage
function handleEditPage() {
/** @param {HTMLDivElement} ii */
const handleExistingImage = (ii) => {
const imgFiber = getReactFiber(ii);
/** @type {ImageData} */
const imgData = imgFiber?.return?.memoizedProps?.image;
const img = /** @type {HTMLImageElement} */ (ii.querySelector('img'));
if (img.dataset.injectedResolution) return;
const [width, height] = resolveDimensions(imgData, img);
const isSVG = width <= 0 && height <= 0;
img.dataset.injectedResolution = 'true';
const imgLink = document.createElement('a');
imgLink.classList.add('text-center');
imgLink.href = img.src;
imgLink.target = '_blank';
imgLink.title = 'Open in new tab';
const icon = document.createElement('h4');
icon.innerText = '⎋';
icon.classList.add('position-absolute', 'end-0', 'lh-1', 'mb-0');
const resolution = document.createElement('div');
resolution.innerText = isSVG ? '\u{221E} x \u{221E}' : `${width} x ${height}`;
imgLink.append(icon, resolution);
ii.appendChild(imgLink);
const resized = makeResizedMarker(imgData, 'position-absolute');
if (resized) {
const resizedC = document.createElement('div');
resizedC.classList.add('position-relative');
resizedC.appendChild(resized);
imgLink.before(resizedC);
}
};
/** @type {HTMLDivElement[]} */
const existingImages = (Array.from(document.querySelectorAll('.EditImages .ImageInput')));
existingImages.forEach((ii) => {
const img = /** @type {HTMLImageElement} */ (ii.querySelector(':scope > img'));
if (!img) return;
imageReady(img).then(
() => handleExistingImage(ii),
() => setTimeout(handleAdBlocked, 100, img),
);
});
// Watch for new images (images tab)
const imageContainer = /** @type {HTMLElement} */ (document.querySelector('.EditImages-images'));
new MutationObserver((mutationRecords, observer) => {
mutationRecords.forEach((record) => {
record.addedNodes.forEach((node) => {
if (node.nodeType !== node.ELEMENT_NODE || node.nodeName !== 'DIV') return;
const element = /** @type {HTMLDivElement} */ (node);
if (!element.matches('.ImageInput')) return;
const img = /** @type {HTMLImageElement} */ (element.querySelector('img'));
imageReady(img).then(
() => handleExistingImage(element),
() => setTimeout(handleAdBlocked, 100, img),
);
});
});
}).observe(imageContainer, { childList: true });
// Watch for image input
const imageInputContainer = /** @type {HTMLDivElement} */ (document.querySelector('.EditImages-input-container'));
new MutationObserver((mutationRecords, observer) => {
mutationRecords.forEach((record) => {
const { target } = record;
if (target.nodeType !== target.ELEMENT_NODE || target.nodeName !== 'DIV') return;
const element = /** @type {HTMLDivElement} */ (target);
// Add image resolution on image input
if (element.matches('.EditImages-image')) {
record.addedNodes.forEach((node) => {
if (node.nodeType !== node.ELEMENT_NODE || node.nodeName !== 'IMG') return;
const img = /** @type {HTMLImageElement} */ (node);
imageReady(img, false).then(() => {
if (img.dataset.injectedResolution) return;
img.dataset.injectedResolution = 'true';
img.after(makeImageResolutionOverlay(img));
});
});
return;
}
// Remove image resolution on cancel / upload
if (element.matches('.EditImages-drop')) {
record.removedNodes.forEach((node) => {
if (node.nodeType !== node.ELEMENT_NODE || node.nodeName !== 'IMG') return;
/** @type {HTMLDivElement} */
(imageInputContainer.querySelector('div.image-resolution')).remove();
});
return;
}
});
}).observe(imageInputContainer, { childList: true, subtree: true });
// Watch for new images (confirm tab)
const confirmTab = /** @type {HTMLDivElement} */ (document.querySelector('form div[id$="-tabpane-confirm"]'));
new MutationObserver(() => iEditCards())
.observe(confirmTab, { childList: true, subtree: true });
} // handleEditPage
/** @param {string} action */
async function iPerformerEditPage(action) {
let ready = false;
if (action === 'merge') {
// SPECIAL CASE: indenfinitely wait for the merge form to appear first
ready = await Promise.race([
elementReady('.PerformerMerge .PerformerForm').then(() => true),
new Promise((resolve) =>
window.addEventListener(locationChanged, () => resolve(false), { once: true })),
]);
} else {
ready = !!await elementReadyIn('.PerformerForm', 1000);
}
if (!ready) return;
handleEditPage();
} // iPerformerEditPage
async function iSceneEditPage() {
if (!await elementReadyIn('.SceneForm', 1000)) return;
handleEditPage();
} // iSceneEditPage
async function iStudioEditPage() {
if (!await elementReadyIn('.StudioForm', 1000)) return;
handleEditPage();
} // iStudioEditPage
/**
* @param {string} editId
*/
async function iEditUpdatePage(editId) {
const form = /** @type {HTMLFormElement} */ (await elementReadyIn('main form', 2000));
switch (Array.from(form.classList).find((c) => c.endsWith('Form'))) {
case 'PerformerForm':
return await iPerformerEditPage('edit');
case 'SceneForm':
return await iSceneEditPage();
case 'StudioForm':
return await iStudioEditPage();
case 'TagForm':
default:
return;
}
} // iEditUpdatePage
/**
* @param {number} ms
*/
const wait = (/** @type {number} */ ms) => new Promise((resolve) => setTimeout(resolve, ms));
/**
* @param {string} selector
* @param {number} [timeout] fail after, in milliseconds
*/
const elementReadyIn = (selector, timeout) => {
/** @type {Promise<Element | null>[]} */
const promises = [elementReady(selector)];
if (timeout) promises.push(wait(timeout).then(() => null));
return Promise.race(promises);
};
/**
* @param {HTMLElement} el
* @param {MutationObserverInit} options
* @param {number} timeout
*/
const waitForFirstChange = async (el, options, timeout) => {
return Promise.race([
wait(timeout),
/** @type {Promise<void>} */ (new Promise((resolve) => {
new MutationObserver((mutationRecords, observer) => {
observer.disconnect();
resolve();
}).observe(el, options);
})),
]);
};
/**
* @param {Element} el
* @returns {Record<string, any> | undefined}
*/
const getReactFiber = (el) =>
el[Object.getOwnPropertyNames(el).find((p) => p.startsWith('__reactFiber$')) || ''];
/**
* @param {HTMLImageElement} img
* @param {boolean} [error=true] Reject promise on load error?
* @returns {Promise<void>}
*/
async function imageReady(img, error = true) {
if (img.complete && img.naturalHeight !== 0) return;
return new Promise((resolve, reject) => {
if (!error) {
img.addEventListener('load', () => resolve(), { once: true });
return;
}
const onLoad = () => {
img.removeEventListener('error', onError);
resolve();
}
const onError = (/** @type {ErrorEvent} */ event) => {
img.removeEventListener('load', onLoad);
reject(event.message || 'unknown');
}
img.addEventListener('load', onLoad, { once: true });
img.addEventListener('error', onError, { once: true });
});
}
/**
* @param {ImageData | null | undefined} imgData
* @param {HTMLImageElement} img
* @returns {[width: number, height: number]}
*/
const resolveDimensions = (imgData, img) =>
[imgData?.width ?? img.naturalWidth, imgData?.height ?? img.naturalHeight];
/**
* @template {HTMLElement | SVGSVGElement} E
* @param {E} el
* @param {Partial<CSSStyleDeclaration>} styles
* @returns {E}
*/
function setStyles(el, styles) {
Object.assign(el.style, styles);
return el;
}
/**
* @param {HTMLImageElement} img
* @param {() => void | undefined} after
*/
function handleAdBlocked(img, after) {
if (!(new URL(img.src)).pathname.startsWith('/ad/')) return;
for (const attr of img.attributes) {
if (/^[\w\d]{9}/.test(attr.name)) {
img.attributes.removeNamedItem(attr.name);
break;
}
}
if (after !== undefined) return after();
img.alt = 'This image should be visible,\nbut an Ad Blocker is blocking it';
setStyles(img, {
whiteSpace: 'pre',
border: '3px solid red',
padding: '5px',
height: 'min-content',
});
}
/**
* @param {HTMLImageElement} img
* @returns {HTMLDivElement}
*/
function makeImageResolutionOverlay(img) {
const imgRes = document.createElement('div');
imgRes.classList.add('image-resolution');
const isSVG = img.naturalWidth <= 0 && img.naturalHeight <= 0;
imgRes.innerText = isSVG ? '\u{221E} x \u{221E}' : `${img.naturalWidth} x ${img.naturalHeight}`;
img.addEventListener('mouseover', () => imgRes.style.opacity = '0');
img.addEventListener('mouseout', () => imgRes.style.opacity = '');
return imgRes;
}
/**
* @param {ImageData | null} [imgData]
* @param {...string} className
* @returns {HTMLAnchorElement | HTMLSpanElement | null}
*/
const makeResizedMarker = (imgData, ...className) => {
if (!imgData)
return null;
const id = imgData.id;
const imgURL = new URL(imgData.url);
if (id === imgURL.pathname.split(/\//g).slice(-1)[0])
return null;
const resized = document.createElement(true || isDev() ? 'a' : 'span');
resized.innerText = '(R)';
resized.title = 'Image is resized';
resized.classList.add('resized-image-marker', ...className);
if (resized instanceof HTMLAnchorElement) {
resized.href = [imgURL.origin, 'images', id.slice(0, 2), id.slice(2, 4), id].join('/');
resized.target = '_blank';
}
return resized;
}
const isDev = () => {
const profile = /** @type {HTMLAnchorElement} */ (document.querySelector('#root nav a[href^="/users/"]'));
return profile && ['peolic', 'root'].includes(profile.innerText);
};
// Based on: https://dirask.com/posts/JavaScript-on-location-changed-event-on-url-changed-event-DKeyZj
const locationChanged = (function() {
const { pushState, replaceState } = history;
// @ts-expect-error
const prefix = GM.info.script.name
.toLowerCase()
.trim()
.replace(/[^a-z0-9 -]/g, '')
.replace(/\s+/g, '-');
const eventLocationChange = new Event(`${prefix}$locationchange`);
history.pushState = function(...args) {
pushState.apply(history, args);
window.dispatchEvent(new Event(`${prefix}$pushstate`));
window.dispatchEvent(eventLocationChange);
}
history.replaceState = function(...args) {
replaceState.apply(history, args);
window.dispatchEvent(new Event(`${prefix}$replacestate`));
window.dispatchEvent(eventLocationChange);
}
window.addEventListener('popstate', function() {
window.dispatchEvent(eventLocationChange);
});
return eventLocationChange.type;
})();
// MIT Licensed
// Author: jwilson8767
// https://gist.github.com/jwilson8767/db379026efcbd932f64382db4b02853e
/**
* Waits for an element satisfying selector to exist, then resolves promise with the element.
* Useful for resolving race conditions.
*
* @param {string} selector
* @returns {Promise<Element>}
*/
function elementReady(selector) {
return new Promise((resolve, reject) => {
let el = document.querySelector(selector);
if (el) {resolve(el);}
new MutationObserver((mutationRecords, observer) => {
// Query for elements matching the specified selector
Array.from(document.querySelectorAll(selector)).forEach((element) => {
resolve(element);
//Once we have resolved we don't need the observer anymore.
observer.disconnect();
});
})
.observe(document.documentElement, {
childList: true,
subtree: true
});
});
}
main();
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment