Table of Contents
Stash
Sort order/Filter:
The default sort order of items of the tabs is stored with the default filter. Overwriting an existing filter does not overwrite the sort order though. The filter needs to be deleted, then saved and set to default again.
The filters are stored in the database, not the configuration file.
Plugins:
https://github.com/stashapp/CommunityScripts/
User script for local performer face detection:
https://github.com/cc1234475/visage
https://github.com/cc1234475/visage-ml
Custom CSS
Custom CSS needs to be enabled in Settings→Interface→Custom CSS→Enable then Edit
Note: Firefox before v120/121 needs: about:config value of layout.css.has-selector.enabled set to true
/* [Performer tab] Show more item per row */ @media only screen and (min-device-width: 768px){ :not(.recommendation-row .performer-card).performer-card { width: 15%; } :not(.recommendation-row .performer-card-image).performer-card-image { width: 100%; } .performer-card h5 { text-align: center !important; display: block; } .performer-card-image { height: 18rem; } } /* [Studios tab] Show more item per row */ :not(.recommendation-row .studio-card).studio-card { width: 15% } :not(.recommendation-row .studio-card-image).studio-card-image { width: 100% } .studio-card h5 { text-align: center !important; display: block; } /* [Global changes] Hide the Donate button */ .btn-primary.btn.donate.minimal { display: none; } button.minimal.donate.btn.btn-primary { display: none; } /* [Global changes] Modify card when checkbox is selected Note: Firefox before v120/121 needs: about:config value of layout.css.has-selector.enabled set to true */ .grid-card.card:has(input:checked) { box-shadow: 0 0 0 1px var(--primary,rgba(255, 255, 255, 0.30)); } /* Fix face AI plugin being popup being behind the content */ .face-tabs.svelte-p95y28 { // height: 60%; z-index: 1; }
api
Video Compare Userscript
This needs violentmonkey or similar browser plugin.
Base script from: https://gist.github.com/DogmaDragon/fb3ed033c0d1f0a6811137dfea0c4ce8
- video_compare_userscript.user.js
// ==UserScript== // @name Stash Video Compare Userscript // @description Userscript for Stash // @match http://192.168.1.2:9999/* // @match http://192.168.1.2:9998/* // @grant GM_openInTab // @require https://code.jquery.com/jquery-1.12.4.min.js // @require https://gist.github.com/raw/2625891/waitForKeyElements.js // @version 1.1 // @author Scruffynerf / AnonTester // ==/UserScript== (function() { 'use strict'; console.log('Script Stash initialize'); waitForKeyElements(".navbar-brand", addbutton); function addbutton() { const navBar = document.querySelector(".navbar-nav"); const buttonClass = navBar.firstChild.attributes.class.value; const linkClass = navBar.firstChild.firstChild.attributes.class.value; const newButton = document.createElement("div"); newButton.setAttribute("class", buttonClass); newButton.onclick = compare const innerLink = document.createElement("a"); innerLink.setAttribute("class", linkClass); const buttonLabel = document.createElement("span"); buttonLabel.innerText = "Video Compare"; innerLink.appendChild(buttonLabel); newButton.appendChild(innerLink); navBar.appendChild(newButton); } function compare() { if (window.location.pathname == "/sceneDuplicateChecker") { var numberOfChecked = document.querySelectorAll('input.position-static[type="checkbox"]:checked').length; if (numberOfChecked == 2) { var lr = [] const list = document.querySelectorAll('input[type="checkbox"].position-static:checked') for (let item of list) { const row = item.closest('tr'); const nextAnchor = row.querySelector('a'); const url = nextAnchor.getAttribute('href').replace(/\/scenes\//g, '/scene/') lr.push(window.location.origin.concat(url)) } var site = "http://scruffynerf.stashapp.cc" var url = site.concat("?leftVideoUrl=", lr[0], "/stream&rightVideoUrl=", lr[1], "/stream&hideHelp=1") GM_openInTab(url, true); } } else { var numberOfChecked = document.querySelectorAll('input[type="checkbox"]:checked').length; if (numberOfChecked == 2) { const r = /[^"]+\/scene\/\d+\/s/ms; var lr = [] const list = document.querySelectorAll('input[type=checkbox]:checked') for (let item of list) { var urlstuff = item.nextElementSibling.innerHTML var m = r.exec(urlstuff) lr.push(m[0]) } var site = "http://scruffynerf.stashapp.cc" var url = site.concat("?leftVideoUrl=", lr[0], "tream&rightVideoUrl=", lr[1], "tream&hideHelp=1") GM_openInTab(url, true); } } } })();
Scene merge script after video conversions
After converting video files already included in stash and doing a rescan, new scenes will be created for the new files. This leaves duplicate scenes in the database.
This script creates a backup of the stash database first, then finds all scenes in stash that have the same filenames but different extensions, merges the scenes into the older one and optionally delete the non-mp4 file from the merged scene.
This script uses stashapp-tools module to interface with stash. Installation of this:
pip install stashapp-tools
This script performs a dry run unless the -n option is provided to avoid data loss. It can take a filename or part of a path as option or the -a option to scan all stash scenes. The -d parameter deletes the non-mp4 files after merging scenes.
usage: stash-merge.py [-h] [-a] [-d] [-n] [filename] Merge stash scenes with the same filenames in different formats, optionally delete non-mp4 files. positional arguments: filename The filename to process options: -h, --help show this help message and exit -a, --all Process all scenes with duplicate filenames -d, --delete Delete non mp4 files after merging scenes -n, --non-dry-run Actually perform actions, runs in dry-run mode by default
Adjust the scheme/IP/port at the end of the script to your requirements if not http, localhost and port 9999.
- stash-merge.py
import stashapi.log as log from stashapi.stashapp import StashInterface import argparse import os, sys def find_scenes_by_path_regex(self, f:dict={}, filter:dict={"per_page": -1}, q:str="", fragment=None, get_count=False, callback=None): query = """ query FindScenes($filter: FindFilterType) { findScenesByPathRegex(filter: $filter) { count scenes { ...Scene } } } """ if fragment: query = re.sub(r'\.\.\.Scene', fragment, query) filter["q"] = q variables = { "filter": filter, "scene_filter": f } result = self.call_GQL(query, variables, callback=callback) if get_count: return result['findScenesByPathRegex']['count'], result['findScenes']['scenes'] else: return result['findScenesByPathRegex']['scenes'] def merge_scenes_with_same_filename(stash_scheme, stash_host, stash_port, delete_non_mp4=False, dryrun=True, file=None): stash = StashInterface({ "scheme": stash_scheme, "host": stash_host, "port": stash_port, "logger": log }) if dryrun: print("Dry-run:\n") else: print("Live-run:\n") print("Backing up database ...") query = """ mutation backupDatabase{ backupDatabase(input: {download: false}) } """ variables = {} stash.call_GQL(query, variables) if file is None: # Fetch all scenes scenes = stash.find_scenes() else: # note rsplit fails for files without extension in directories starting with dot, but path in stash db is absolute file = file.rsplit('.', maxsplit=1)[0] scenes = find_scenes_by_path_regex(stash,q=file) #print(scenes) # print(len(scenes)) # Group scenes by path without extension scenes_by_path_ex_ext = {} for scene in scenes: #print(scene) for files in scene['files']: # note rsplit fails for files without extension in directories starting with dot, but path in stash db is absolute path = files['path'].rsplit('.', maxsplit=1)[0] if path not in scenes_by_path_ex_ext: scenes_by_path_ex_ext[path] = [] scenes_by_path_ex_ext[path].append(scene) #print(len(scenes_by_path_ex_ext)) for path, scene_group in scenes_by_path_ex_ext.items(): if len(scene_group) > 1: if dryrun: print(f"Scene group:\n{scene_group}") # Identify the target scene to merge into #target_scene = max(scene_group, key=lambda s: (bool(s['performers']), bool(s['stash_ids']), s['organized']==True)) #always merge into the lowest scene id target_scene = sorted(scene_group, key=lambda s: s['id'])[0] if dryrun: print(f"Target scene:\n{target_scene}") target_scene_id = target_scene['id'] mp4_file_id = None print(f"Target scene id: {target_scene_id} Path: {path}") for scene in scene_group: if scene['id'] != target_scene_id: # Merge scene into target print(f"Merging scene {scene['id']} into {target_scene_id} Source title: {scene['title']}") if not dryrun: stash.merge_scenes(scene['id'], target_scene_id) # Identify mp4 file for files in scene['files']: if files['path'].endswith('.mp4'): mp4_file_id = files['id'] print(f"scene with mp4 path: {scene['id']} file id: {mp4_file_id} path: {files['path']}") # Set the primary file to be the mp4 file if mp4_file_id: print(f"setting primary id to {mp4_file_id}") if not dryrun: # first set all files NOT future primary to not be primary stash.sql_commit("UPDATE `scenes_files` SET `primary`=? WHERE ((`scenes_files`.`scene_id` = ?) AND (`scenes_files`.`file_id` != ?))", ("0", target_scene_id, mp4_file_id) ) # now set new primary file id stash.sql_commit("UPDATE `scenes_files` SET `primary`=? WHERE ((`scenes_files`.`scene_id` = ?) AND (`scenes_files`.`file_id` = ?))", ("1", target_scene_id, mp4_file_id) ) # Optionally delete non-mp4 files if more than 1 file is assigned to the scene if delete_non_mp4 and len(scene_group) > 1 and mp4_file_id: for scene in scene_group: for files in scene['files']: if files['id'] != mp4_file_id: print(f"Deleting file id {files['id']} while keeping {mp4_file_id} Deleted file path: {files['path']}") if not dryrun: stash.destroy_files(files['id']) print("") def main(): parser = argparse.ArgumentParser(description="Merge stash scenes with the same filenames in different formats, optionally delete non-mp4 files.") # Optional argument for filename parser.add_argument('filename', nargs='?', default=None, help='The filename to process') # Non-positional flags with short forms parser.add_argument('-a', '--all', action='store_true', help='Process all scenes with duplicate filenames') parser.add_argument('-d', '--delete', action='store_true', help='Delete non mp4 files after merging scenes') parser.add_argument('-n', '--non-dry-run', action='store_true', help='Actually perform actions, runs in dry-run mode by default') if len(sys.argv)==1: print("Error: No filename or arguments provided.\n") parser.print_help(sys.stderr) sys.exit(1) args = parser.parse_args() if args.filename: FILE=args.filename if args.filename and args.delete: print(f"Merge scenes and delete non mp4 files: {args.filename}\n") merge_scenes_with_same_filename(STASH_SCHEME, STASH_HOST, STASH_PORT, delete_non_mp4=True, dryrun=not args.non_dry_run, file=args.filename) elif args.filename: print(f"Merge scenes and keeping files containing: {args.filename}\n") merge_scenes_with_same_filename(STASH_SCHEME, STASH_HOST, STASH_PORT, delete_non_mp4=False, dryrun=not args.non_dry_run, file=args.filename) elif args.all and args.delete: print("Merge all scenes with duplicate files, deleting duplicate files.\n") merge_scenes_with_same_filename(STASH_SCHEME, STASH_HOST, STASH_PORT, delete_non_mp4=True, dryrun=not args.non_dry_run, file=None) elif args.all: print("Merge all scenes with duplicate files, not deleting duplicate files.\n") merge_scenes_with_same_filename(STASH_SCHEME, STASH_HOST, STASH_PORT, delete_non_mp4=False, dryrun=not args.non_dry_run, file=None) else: print("No filename or all argument provided, not doing anything\n") if __name__ == "__main__": # Replace with your Stash app URL STASH_SCHEME = "http" STASH_HOST = "localhost" STASH_PORT = "9999" main() #merge_scenes_with_same_filename(STASH_SCHEME, STASH_HOST, STASH_PORT, DELETE, DRYRUN, FILE)