Skip to content

Instantly share code, notes, and snippets.

@secondfolder
Last active October 29, 2025 22:24
Show Gist options
  • Select an option

  • Save secondfolder/e2d3edddaa75331786a1cfd20b76c074 to your computer and use it in GitHub Desktop.

Select an option

Save secondfolder/e2d3edddaa75331786a1cfd20b76c074 to your computer and use it in GitHub Desktop.
[⚠️ This is unmaintained. I recommend https://github.com/enymawse/stasharr which is much more polished anyway.] Userscript for sending StashDB scenes to Whisparr
// ==UserScript==
// @name StashBox - Whisparr v3
// @namespace http://tampermonkey.net/
// @version 0.1
// @description try to take over the world!
// @author You
// @match https://stashdb.org/
// @match https://stashdb.org/*
// @icon.disabled https://www.google.com/s2/favicons?sz=64&domain=stashdb.org
// @updateURL
// @grant GM_addStyle
// ==/UserScript==
const whisparrBaseUrl = 'https://whisparr.yourdomain.com' // Root url of Whisparr v3 instance to use
const whisparrApiKey = "123abc" // API key of above Whisparr instance
const whisparrNewSiteTags = [1] // Array of IDs of tags in Whisparr that added scenes should be tagged with
const localStashRootUrl = 'https://stash.yourdomain.com'
const localStashGraphQlEndpoint = localStashRootUrl + '/graphql' // Stash graphql endpoint used for fetching url of downloaded scene
const localStashAuthHeaders = {} // Any headers that should be supplied when sending requests to stash
;(async function() {
'use strict';
GM_addStyle(`
body {
min-width: unset;
}
.navbar, .navbar-nav {
flex-wrap: wrap;
flex-grow: 1;
}
.navbar > .navbar-nav:last-child {
justify-content: end;
}
.SearchField {
max-width: 400px;
width: 100%;
}
.downloadInWhisparr {
display: flex;
align-items: center;
gap: 0.5em;
margin-left: 1em;
}
img {
//filter: blur(4px);
}
`);
function createButton() {
const containerElm = document.createElement("div");
const dlButtonElm = document.createElement("button");
const statusElm = document.createElement("span");
containerElm.classList.add("downloadInWhisparr")
dlButtonElm.textContent = "↻"
let lastOnClickValue
function updateStatus(newStatus) {
if (typeof newStatus === "string") {
statusElm.innerHTML = newStatus
} else {
if (typeof newStatus.button !== "undefined")
dlButtonElm.innerHTML = newStatus.button
if (typeof newStatus.extra !== "undefined")
statusElm.innerHTML = newStatus.extra
if (typeof newStatus.onClick !== "undefined") {
if (lastOnClickValue) {
dlButtonElm.removeEventListener("click", lastOnClickValue)
}
dlButtonElm.addEventListener("click", newStatus.onClick)
lastOnClickValue = newStatus.onClick
}
}
}
updateStatus("Loading...")
containerElm.appendChild(dlButtonElm)
containerElm.appendChild(statusElm)
return {downloadElm: containerElm, updateStatus}
}
async function addButtonToScenePage(downloadElm) {
let parentElement
while (!parentElement) {
parentElement = document.querySelector(".NarrowPage > .nav-tabs")
await new Promise(resolve => setTimeout(resolve, 50))
}
parentElement.appendChild(downloadElm)
}
let observer = new MutationObserver(async function(mutations) {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
console.log("added node")
if (node.nodeType === 1 && (node.classList.contains("scene-info"))) {
console.log("added scene node")
const {downloadElm, updateStatus} = createButton()
await addButtonToScenePage(downloadElm)
const stashId = location.pathname.split("/")[2]
await checkIfAvaliable(stashId, updateStatus)
}
}
}
});
const config = {subtree: true, childList: true};
observer.observe(document, config);
async function checkIfAvaliable(stashId, updateStatus) {
let whisparrScene
updateStatus({
extra: `Checking whisparr...`
})
try {
whisparrScene = await ensureSceneAdded(stashId)
} catch(error) {
updateStatus({
button: "❌",
extra: `Error adding scene in whisparr`
})
throw error
}
function updateStatusToMonitored() {
updateStatus({
button: "👁️➖",
extra: "",
onClick: async () => {
whisparrScene = await monitorScene(false, whisparrScene)
updateStatusToUnmonitored()
}
})
}
function updateStatusToUnmonitored() {
updateStatus({
button: "👁️➕",
extra: "",
onClick: async () => {
whisparrScene = await monitorScene(true, whisparrScene)
updateStatusToMonitored()
}
})
}
if (whisparrScene.hasFile) {
const localStashSceneId = await getLocalStashSceneId(whisparrScene)
const stashUrl = `${localStashRootUrl}/scenes/${localStashSceneId}`
updateStatus({
button: "▶️",
extra: "",
onClick: () => window.open(stashUrl, '_blank').focus()
})
return
} else if (whisparrScene.monitored) {
updateStatusToMonitored()
return
} else if (whisparrScene.queueStatus) {
updateStatus({
button: "↻",
extra: `Currently in <a href="${whisparrBaseUrl}/activity/queue">download queue</a>.`
})
return
}
let fileDownloadAvailablity
updateStatus({
extra: `Checking if available...`
})
try {
fileDownloadAvailablity = await getFileDownloadAvailablity(whisparrScene)
} catch(error) {
updateStatus({
button: "❌",
extra: `Error checking if scene available for download`
})
throw error
}
switch (fileDownloadAvailablity) {
case "available for download":
updateStatus({
button: "⬇️",
extra: "",
onClick: async () => {
updateStatus({
button: "↻",
extra: "Adding to download queue...",
onClick: () => {}
})
await downloadVideo(whisparrScene)
updateStatus({
extra: `Added to <a href="${whisparrBaseUrl}/activity/queue">download queue</a>.`
})
}
})
break;
case "already downloading":
updateStatus({
button: "↻",
extra: `Currently in <a href="${whisparrBaseUrl}/activity/queue">download queue</a>.`
})
break;
case "not available for download":
updateStatusToUnmonitored()
break;
default:
updateStatus({button: "❔", extra: "Unknown file availablility"})
}
}
async function ensureSceneAdded(stashId) {
const scenes = await fetchWhisparr("/movie")
const scene = scenes.find(scene => scene.stashId === stashId)
if (scene) {
if (!scene.hasFile) {
const queue = await fetchWhisparr("/queue/details?all=true")
scene.queueStatus = queue.find(queueItem => queueItem.movieId === scene.id)
}
return scene
}
try {
return await fetchWhisparr(
"/movie",
{
body: {
addOptions: {
monitor: "none",
searchForMovie: false,
},
foreignId: stashId,
monitored: false,
qualityProfileId: 1,
rootFolderPath: "/.second/Videos/scenes",
stashId: stashId,
tags: whisparrNewSiteTags,
title: "added via stashdb extention",
}
}
);
} catch(error) {
console.error(error.statusCode, error.resBody)
throw error
}
}
async function monitorScene(monitor, whisparrScene) {
return await fetchWhisparr(
`/movie/${whisparrScene.id}`,
{
method: "PUT",
body: {
foreignId: whisparrScene.foreignId,
monitored: monitor,
qualityProfileId: whisparrScene.qualityProfileId,
rootFolderPath: whisparrScene.rootFolderPath,
stashId: whisparrScene.stashId,
title: whisparrScene.title,
path: whisparrScene.path,
tags: monitor
? whisparrScene.tags.filter(tag => !whisparrNewSiteTags.includes(tag))
: [...whisparrScene.tags, ...whisparrNewSiteTags]
}
}
);
}
async function removeAutoTags(whisparrScene) {
return await fetchWhisparr(
`/movie/${whisparrScene.id}`,
{
method: "PUT",
body: {
foreignId: whisparrScene.foreignId,
monitored: whisparrScene.monitored,
qualityProfileId: whisparrScene.qualityProfileId,
rootFolderPath: whisparrScene.rootFolderPath,
stashId: whisparrScene.stashId,
title: whisparrScene.title,
path: whisparrScene.path,
tags: whisparrScene.tags.filter(tag => !whisparrNewSiteTags.includes(tag))
}
}
);
}
async function getFileDownloadAvailablity(whisparrScene) {
const releases = await fetchWhisparr(`release?movieId=${whisparrScene.id}`);
if (releases.some(release => release.approved)) {
return "available for download"
} else if (
releases.some(
release => release.rejections.some(rejection => rejection.startsWith("Release in queue already"))
)
) {
return "already downloading"
} else {
return "not available for download"
}
}
async function downloadVideo(whisparrScene) {
await fetchWhisparr(
"/command",
{
body: {
name: "MoviesSearch",
movieIds: [whisparrScene.id]
}
}
);
await removeAutoTags(whisparrScene)
}
async function getLocalStashSceneId(whisparrScene) {
const stashRes = await localStashGraphQl({
"variables": {
"scene_filter": {
"stash_id_endpoint": {
"endpoint": "",
"modifier": "EQUALS",
"stash_id": whisparrScene.stashId
}
}
},
query: `
query ($scene_filter: SceneFilterType) {
findScenes(scene_filter: $scene_filter) {
scenes {
id
}
}
}
`
});
return stashRes.data.findScenes.scenes[0]?.id
}
const fetchWhisparr = factoryFetchApi(`${whisparrBaseUrl}/api/v3/`, {"X-Api-Key": whisparrApiKey})
async function localStashGraphQl(request) {
const fetchLocalStash = factoryFetchApi(localStashGraphQlEndpoint, localStashAuthHeaders)
return fetchLocalStash("", {body: request})
}
function factoryFetchApi(baseUrl, defaultHeaders) {
return async (subPath, options = {}) => {
const res = await fetch(
new URL(subPath.replace(/^\/*/,''), baseUrl),
{
method: options.body ? "POST" : "GET",
mode: "cors",
...options,
...(options.body ? {body: JSON.stringify(options.body)} : {}),
headers: {
...defaultHeaders,
"X-Api-Key": whisparrApiKey,
...(options.body ? {"Content-Type": "application/json"} : {}),
...(options?.headers || {})
},
}
)
if (!res.ok) {
let body
try {
body = await res.json()
} catch (error) {}
const error = new Error()
error.statusCode = res.status
error.resBody = body
throw error
}
return res.json()
}
}
})();
@MrMxyzptlk
Copy link

Thanks for the script! I have it installed in GreaseMonkey Chrome Windows, but when I go to StashDB I don't see any 'action buttons'; how does this script work?

@secondfolder
Copy link
Author

Thanks for your interest! Unfortunately I don’t maintain this any more but I highly recommend you check out https://github.com/enymawse/stasharr which is much better than this script anyway.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment