Last active
October 29, 2025 22:24
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==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() | |
| } | |
| } | |
| })(); |
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
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?