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)