config:stash
Differences
This shows you the differences between two versions of the page.
| Both sides previous revisionPrevious revisionNext revision | Previous revision | ||
| config:stash [2024/04/30 22:23] – [Video Compare Userscript] Wuff | config:stash [2025/08/30 00:01] (current) – [api] Wuff | ||
|---|---|---|---|
| Line 20: | Line 20: | ||
| - | ===== Custom CSS ===== | + | ===== Custom CSS/JS ===== |
| - | Custom CSS needs to be enabled in Settings-> | + | Custom CSS/ |
| Note: Firefox before v120/121 needs: about: | Note: Firefox before v120/121 needs: about: | ||
| Line 72: | Line 72: | ||
| | | ||
| } | } | ||
| + | |||
| + | /* Fix face AI plugin being popup being behind the content */ | ||
| + | .face-tabs.svelte-p95y28 { | ||
| + | // height: 60%; | ||
| + | z-index: 1; | ||
| + | } | ||
| + | |||
| </ | </ | ||
| + | |||
| + | ==== Scene Search at top ==== | ||
| + | |||
| + | This moves the scene search from the sidebar to the top bar | ||
| + | |||
| + | <code javascript> | ||
| + | // Move search from sidebar next to filter at top | ||
| + | (function() { | ||
| + | const sourceSelector = 'body div div.main.container-fluid div.item-list-container.scene-list div.sidebar-pane div.sidebar div.sidebar-search-container'; | ||
| + | const targetSelector = 'body div div.main.container-fluid div.item-list-container.scene-list.hide-sidebar div.sidebar-pane.hide-sidebar div div.scene-list-toolbar.btn-toolbar'; | ||
| + | |||
| + | const observer = new MutationObserver(() => { | ||
| + | const sourceElement = document.querySelector(sourceSelector); | ||
| + | const targetContainer = document.querySelector(targetSelector); | ||
| + | |||
| + | if (sourceElement && targetContainer) { | ||
| + | targetContainer.insertBefore(sourceElement, | ||
| + | observer.disconnect(); | ||
| + | console.log(' | ||
| + | } | ||
| + | }); | ||
| + | |||
| + | // Observe the entire document for added/ | ||
| + | observer.observe(document.body, | ||
| + | childList: true, | ||
| + | subtree: true | ||
| + | }); | ||
| + | })(); | ||
| + | </ | ||
| + | |||
| + | ==== Studio as Text ==== | ||
| + | |||
| + | This replaces unset studio logos with a clickable text link. | ||
| + | |||
| + | CSS | ||
| + | <code css> | ||
| + | /* Studio as text */ | ||
| + | a[data-processed] { | ||
| + | font-size: 1.5rem; | ||
| + | color: inherit; | ||
| + | text-decoration: | ||
| + | display: inline-block; | ||
| + | } | ||
| + | |||
| + | a[data-processed]: | ||
| + | text-decoration: | ||
| + | } | ||
| + | |||
| + | </ | ||
| + | |||
| + | Javascript | ||
| + | <code javascript> | ||
| + | /* Studio as text */ | ||
| + | function processStudioLogos() { | ||
| + | document.querySelectorAll(' | ||
| + | const link = img.closest(' | ||
| + | if (link && img.alt && !link.hasAttribute(' | ||
| + | const cleanName = img.alt.replace(/ | ||
| + | link.textContent = cleanName; | ||
| + | link.setAttribute(' | ||
| + | } | ||
| + | }); | ||
| + | } | ||
| + | |||
| + | const observer = new MutationObserver(function(mutations) { | ||
| + | mutations.forEach(function(mutation) { | ||
| + | if (mutation.addedNodes.length) { | ||
| + | processStudioLogos(); | ||
| + | } | ||
| + | }); | ||
| + | }); | ||
| + | |||
| + | observer.observe(document.body, | ||
| + | childList: true, | ||
| + | subtree: true | ||
| + | }); | ||
| + | </ | ||
| + | |||
| Line 80: | Line 165: | ||
| https:// | https:// | ||
| + | |||
| + | |||
| + | < | ||
| + | #If API authentication is required, add the following to the curl commands: | ||
| + | -H " | ||
| + | </ | ||
| + | |||
| + | < | ||
| + | # Metadata Scan of a particular directory | ||
| + | curl -X POST http:// | ||
| + | -H " | ||
| + | -d '{ | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | } | ||
| + | }' | ||
| + | |||
| + | curl -X POST http:// | ||
| + | -H " | ||
| + | -d '{ | ||
| + | " | ||
| + | }' | ||
| + | </ | ||
| + | |||
| + | |||
| + | < | ||
| + | # Full Metadata Scan | ||
| + | curl -X POST http:// | ||
| + | -H " | ||
| + | -d ' | ||
| + | |||
| + | curl -X POST http:// | ||
| + | -H " | ||
| + | -d ' | ||
| + | |||
| + | </ | ||
| + | |||
| + | Show status: | ||
| + | < | ||
| + | curl -X POST http:// | ||
| + | -H " | ||
| + | -d ' | ||
| + | </ | ||
| + | |||
| + | Show status until done: | ||
| + | < | ||
| + | while true; do | ||
| + | curl -s -X POST http:// | ||
| + | -H " | ||
| + | -d ' | ||
| + | | jq ' | ||
| + | sleep 5; | ||
| + | done | ||
| + | </ | ||
| + | |||
| Line 157: | Line 298: | ||
| })(); | })(); | ||
| </ | </ | ||
| + | |||
| + | ===== 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 | ||
| + | |||
| + | options: | ||
| + | -h, --help | ||
| + | -a, --all Process all scenes with duplicate filenames | ||
| + | -d, --delete | ||
| + | -n, --non-dry-run | ||
| + | </ | ||
| + | |||
| + | Adjust the scheme/ | ||
| + | <code python 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, | ||
| + | |||
| + | query = """ | ||
| + | query FindScenes($filter: | ||
| + | findScenesByPathRegex(filter: | ||
| + | count | ||
| + | scenes { | ||
| + | ...Scene | ||
| + | } | ||
| + | } | ||
| + | } | ||
| + | """ | ||
| + | if fragment: | ||
| + | query = re.sub(r' | ||
| + | |||
| + | filter[" | ||
| + | variables = { | ||
| + | " | ||
| + | " | ||
| + | } | ||
| + | |||
| + | result = self.call_GQL(query, | ||
| + | if get_count: | ||
| + | return result[' | ||
| + | else: | ||
| + | return result[' | ||
| + | |||
| + | def merge_scenes_with_same_filename(stash_scheme, | ||
| + | stash = StashInterface({ | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | }) | ||
| + | |||
| + | if dryrun: | ||
| + | print(" | ||
| + | else: | ||
| + | print(" | ||
| + | |||
| + | print(" | ||
| + | query = """ | ||
| + | mutation backupDatabase{ | ||
| + | backupDatabase(input: | ||
| + | } | ||
| + | """ | ||
| + | variables = {} | ||
| + | stash.call_GQL(query, | ||
| + | |||
| + | 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(' | ||
| + | scenes = find_scenes_by_path_regex(stash, | ||
| + | # | ||
| + | |||
| + | # print(len(scenes)) | ||
| + | |||
| + | # Group scenes by path without extension | ||
| + | scenes_by_path_ex_ext = {} | ||
| + | for scene in scenes: | ||
| + | # | ||
| + | for files in scene[' | ||
| + | # note rsplit fails for files without extension in directories starting with dot, but path in stash db is absolute | ||
| + | path = files[' | ||
| + | if path not in scenes_by_path_ex_ext: | ||
| + | scenes_by_path_ex_ext[path] = [] | ||
| + | scenes_by_path_ex_ext[path].append(scene) | ||
| + | # | ||
| + | |||
| + | for path, scene_group in scenes_by_path_ex_ext.items(): | ||
| + | if len(scene_group) > 1: | ||
| + | if dryrun: | ||
| + | print(f" | ||
| + | # Identify the target scene to merge into | ||
| + | # | ||
| + | #always merge into the lowest scene id | ||
| + | target_scene = sorted(scene_group, | ||
| + | if dryrun: | ||
| + | print(f" | ||
| + | target_scene_id = target_scene[' | ||
| + | mp4_file_id = None | ||
| + | print(f" | ||
| + | |||
| + | for scene in scene_group: | ||
| + | if scene[' | ||
| + | # Merge scene into target | ||
| + | print(f" | ||
| + | if not dryrun: | ||
| + | stash.merge_scenes(scene[' | ||
| + | |||
| + | # Identify mp4 file | ||
| + | for files in scene[' | ||
| + | if files[' | ||
| + | mp4_file_id = files[' | ||
| + | print(f" | ||
| + | |||
| + | # Set the primary file to be the mp4 file | ||
| + | if mp4_file_id: | ||
| + | print(f" | ||
| + | if not dryrun: | ||
| + | # first set all files NOT future primary to not be primary | ||
| + | stash.sql_commit(" | ||
| + | # now set new primary file id | ||
| + | stash.sql_commit(" | ||
| + | |||
| + | # 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[' | ||
| + | if files[' | ||
| + | print(f" | ||
| + | if not dryrun: | ||
| + | stash.destroy_files(files[' | ||
| + | print("" | ||
| + | |||
| + | def main(): | ||
| + | parser = argparse.ArgumentParser(description=" | ||
| + | |||
| + | # Optional argument for filename | ||
| + | parser.add_argument(' | ||
| + | |||
| + | # Non-positional flags with short forms | ||
| + | parser.add_argument(' | ||
| + | parser.add_argument(' | ||
| + | parser.add_argument(' | ||
| + | |||
| + | if len(sys.argv)==1: | ||
| + | print(" | ||
| + | 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_with_same_filename(STASH_SCHEME, | ||
| + | elif args.filename: | ||
| + | print(f" | ||
| + | merge_scenes_with_same_filename(STASH_SCHEME, | ||
| + | elif args.all and args.delete: | ||
| + | print(" | ||
| + | merge_scenes_with_same_filename(STASH_SCHEME, | ||
| + | elif args.all: | ||
| + | print(" | ||
| + | merge_scenes_with_same_filename(STASH_SCHEME, | ||
| + | else: | ||
| + | print(" | ||
| + | |||
| + | if __name__ == " | ||
| + | # Replace with your Stash app URL | ||
| + | STASH_SCHEME = " | ||
| + | STASH_HOST = " | ||
| + | STASH_PORT = " | ||
| + | |||
| + | main() | ||
| + | # | ||
| + | </ | ||
| + | |||
| + | |||
config/stash.1714512233.txt.gz · Last modified: by Wuff