# Description: This is a Stash plugin which manages duplicate files. # By David Maisonave (aka Axter) Jul-2024 (https://www.axter.com/) # Get the latest developers version from following link: https://github.com/David-Maisonave/Axter-Stash/tree/main/plugins/DupFileManager # Note: To call this script outside of Stash, pass argument --url # Example: python DupFileManager.py --url http://localhost:9999 -a try: import ModulesValidate ModulesValidate.modulesInstalled(["send2trash", "requests"], silent=True) except Exception as e: import traceback, sys tb = traceback.format_exc() print(f"ModulesValidate Exception. Error: {e}\nTraceBack={tb}", file=sys.stderr) import os, sys, time, pathlib, argparse, platform, shutil, traceback, logging, requests, json from datetime import datetime from StashPluginHelper import StashPluginHelper from stashapi.stash_types import PhashDistance from DupFileManager_config import config # Import config from DupFileManager_config.py from DupFileManager_report_config import report_config config |= report_config parser = argparse.ArgumentParser() parser.add_argument('--url', '-u', dest='stash_url', type=str, help='Add Stash URL') parser.add_argument('--trace', '-t', dest='trace', action='store_true', help='Enables debug trace mode.') parser.add_argument('--add_dup_tag', '-a', dest='dup_tag', action='store_true', help='Set a tag to duplicate files.') parser.add_argument('--clear_dup_tag', '-c', dest='clear_tag', action='store_true', help='Clear duplicates of duplicate tags.') parser.add_argument('--del_tag_dup', '-d', dest='del_tag', action='store_true', help='Only delete scenes having DuplicateMarkForDeletion tag.') parser.add_argument('--remove_dup', '-r', dest='remove', action='store_true', help='Remove (delete) duplicate files.') parse_args = parser.parse_args() settings = { "matchDupDistance": 0, "mergeDupFilename": False, "whitelistDelDupInSameFolder": False, "zvWhitelist": "", "zwGraylist": "", "zxBlacklist": "", "zxPinklist": "", "zyMaxDupToProcess": 0, "zySwapHighRes": False, "zySwapLongLength": False, "zySwapBetterBitRate": False, "zySwapCodec": False, "zySwapBetterFrameRate": False, "zzDebug": False, "zzTracing": False, "zzdryRun": False, "zzObsoleteSettingsCheckVer2": False, # This is a hidden variable that is NOT displayed in the UI # Obsolete setting names "zWhitelist": "", "zxGraylist": "", "zyBlacklist": "", "zyMatchDupDistance": 0, "zSwapHighRes": False, "zSwapLongLength": False, "zSwapBetterBitRate": False, "zSwapCodec": False, "zSwapBetterFrameRate": False, } stash = StashPluginHelper( stash_url=parse_args.stash_url, debugTracing=parse_args.trace, settings=settings, config=config, maxbytes=10*1024*1024, DebugTraceFieldName="zzTracing", DebugFieldName="zzDebug", ) stash.convertToAscii = True dry_run = stash.Setting("zzdryRun") advanceMenuOptions = [ "applyCombo", "applyComboPinklist", "applyComboGraylist", "applyComboBlacklist", "pathToDelete", "pathToDeleteBlacklist", "sizeToDeleteLess", "sizeToDeleteGreater", "sizeToDeleteBlacklistLess", "sizeToDeleteBlacklistGreater", "durationToDeleteLess", "durationToDeleteGreater", "durationToDeleteBlacklistLess", "durationToDeleteBlacklistGreater", "commonResToDeleteLess", "commonResToDeleteEq", "commonResToDeleteGreater", "commonResToDeleteBlacklistLess", "commonResToDeleteBlacklistEq", "commonResToDeleteBlacklistGreater", "resolutionToDeleteLess", "resolutionToDeleteEq", "resolutionToDeleteGreater", "resolutionToDeleteBlacklistLess", "resolutionToDeleteBlacklistEq", "resolutionToDeleteBlacklistGreater", "ratingToDeleteLess", "ratingToDeleteEq", "ratingToDeleteGreater", "ratingToDeleteBlacklistLess", "ratingToDeleteBlacklistEq", "ratingToDeleteBlacklistGreater", "tagToDelete", "tagToDeleteBlacklist", "titleToDelete", "titleToDeleteBlacklist", "pathStrToDelete", "pathStrToDeleteBlacklist"] doJsonReturnModeTypes = ["tag_duplicates_task", "removeDupTag", "addExcludeTag", "removeExcludeTag", "mergeTags", "getLocalDupReportPath", "createDuplicateReportWithoutTagging", "deleteLocalDupReportHtmlFiles", "clear_duplicate_tags_task", "deleteAllDupFileManagerTags", "deleteBlackListTaggedDuplicatesTask", "deleteTaggedDuplicatesLwrResOrLwrDuration", "deleteBlackListTaggedDuplicatesLwrResOrLwrDuration", "create_duplicate_report_task", "copyScene"] javascriptModeTypes = ["getReport", "getAdvanceMenu"] javascriptModeTypes += advanceMenuOptions javascriptModeTypes += doJsonReturnModeTypes doJsonReturn = False def isReportOrAdvMenu(): if len(sys.argv) < 2: if stash.PLUGIN_TASK_NAME in javascriptModeTypes: return True if stash.PLUGIN_TASK_NAME.endswith("Flag"): return True return False if isReportOrAdvMenu(): doJsonReturn = True stash.log_to_norm = stash.LogTo.FILE elif stash.PLUGIN_TASK_NAME == "doEarlyExit": time.sleep(3) stash.Log("Doing early exit because of task name") time.sleep(3) exit(0) stash.Log(f"******************* Starting ******************* json={doJsonReturn}") if len(sys.argv) > 1: stash.Log(f"argv = {sys.argv}") else: stash.Debug(f"No command line arguments. JSON_INPUT['args'] = {stash.JSON_INPUT['args']}; PLUGIN_TASK_NAME = {stash.PLUGIN_TASK_NAME}; argv = {sys.argv}") stash.status(logLevel=logging.DEBUG) obsoleteSettingsToConvert = {"zWhitelist" : "zvWhitelist", "zxGraylist" : "zwGraylist", "zyBlacklist" : "zxBlacklist", "zyMatchDupDistance" : "matchDupDistance", "zSwapHighRes" : "zySwapHighRes", "zSwapLongLength" : "zySwapLongLength", "zSwapBetterBitRate" : "zySwapBetterBitRate", "zSwapCodec" : "zySwapCodec", "zSwapBetterFrameRate" : "zySwapBetterFrameRate"} stash.replaceObsoleteSettings(obsoleteSettingsToConvert, "zzObsoleteSettingsCheckVer2") LOG_STASH_N_PLUGIN = stash.LogTo.STASH if stash.CALLED_AS_STASH_PLUGIN else stash.LogTo.CONSOLE + stash.LogTo.FILE listSeparator = stash.Setting('listSeparator', ',', notEmpty=True) addPrimaryDupPathToDetails = stash.Setting('addPrimaryDupPathToDetails') clearAllDupfileManagerTags = stash.Setting('clearAllDupfileManagerTags') doGeneratePhash = stash.Setting('doGeneratePhash') mergeDupFilename = stash.Setting('mergeDupFilename') moveToTrashCan = False if stash.Setting('permanentlyDelete') else True alternateTrashCanPath = stash.Setting('dup_path') whitelistDelDupInSameFolder = stash.Setting('whitelistDelDupInSameFolder') graylistTagging = stash.Setting('graylistTagging') maxDupToProcess = int(stash.Setting('zyMaxDupToProcess')) significantTimeDiff = float(stash.Setting('significantTimeDiff')) toRecycleBeforeSwap = stash.Setting('toRecycleBeforeSwap') cleanAfterDel = stash.Setting('cleanAfterDel') swapHighRes = stash.Setting('zySwapHighRes') swapLongLength = stash.Setting('zySwapLongLength') swapBetterBitRate = stash.Setting('zySwapBetterBitRate') swapCodec = stash.Setting('zySwapCodec') swapBetterFrameRate = stash.Setting('zySwapBetterFrameRate') favorLongerFileName = stash.Setting('favorLongerFileName') favorLargerFileSize = stash.Setting('favorLargerFileSize') favorBitRateChange = stash.Setting('favorBitRateChange') favorHighBitRate = stash.Setting('favorHighBitRate') favorFrameRateChange = stash.Setting('favorFrameRateChange') favorHigherFrameRate = stash.Setting('favorHigherFrameRate') favorCodecRanking = stash.Setting('favorCodecRanking') codecRankingSetToUse = stash.Setting('codecRankingSetToUse') if codecRankingSetToUse == 4: codecRanking = stash.Setting('codecRankingSet4') elif codecRankingSetToUse == 3: codecRanking = stash.Setting('codecRankingSet3') elif codecRankingSetToUse == 2: codecRanking = stash.Setting('codecRankingSet2') else: codecRanking = stash.Setting('codecRankingSet1') skipIfTagged = stash.Setting('skipIfTagged') killScanningPostProcess = stash.Setting('killScanningPostProcess') tagLongDurationLowRes = stash.Setting('tagLongDurationLowRes') bitRateIsImporantComp = stash.Setting('bitRateIsImporantComp') codecIsImporantComp = stash.Setting('codecIsImporantComp') DupFileManagerFolder = f"{stash.PLUGINS_PATH}{os.sep}community{os.sep}DupFileManager" if not os.path.isdir(DupFileManagerFolder): DupFileManagerFolder = f"{stash.PLUGINS_PATH}{os.sep}DupFileManager" reportHeader = f"{DupFileManagerFolder}{os.sep}DupFileManager_report_header" excludeFromReportIfSignificantTimeDiff = False htmlReportPaginate = stash.Setting('htmlReportPaginate') htmlIncludeImagePreview = stash.Setting('htmlIncludeImagePreview') htmlIncludeVideoPreview = stash.Setting('htmlIncludeVideoPreview') htmlHighlightTimeDiff = stash.Setting('htmlHighlightTimeDiff') htmlImagePreviewSize = stash.Setting('htmlImagePreviewSize') htmlImagePreviewPopupSize = stash.Setting('htmlImagePreviewPopupSize') htmlPreviewOrStream = "stream" if stash.Setting('streamOverPreview') else "preview" htmlSupperHighlight = stash.Setting('htmlSupperHighlight') htmlDetailDiffTextColor = stash.Setting('htmlDetailDiffTextColor') htmlLowerHighlight = stash.Setting('htmlLowerHighlight') htmlReportBackgroundColor = stash.Setting('htmlReportBackgroundColor') htmlReportTextColor = stash.Setting('htmlReportTextColor') htmlVideoPreviewWidth = stash.Setting('htmlVideoPreviewWidth') htmlVideoPreviewHeight = stash.Setting('htmlVideoPreviewHeight') htmlImagePreviewPopupEnable = True matchDupDistance = int(stash.Setting('matchDupDistance')) matchPhaseDistance = PhashDistance.EXACT matchPhaseDistanceText = "Exact Match" logTraceForAdvanceMenuOpt = False if (stash.PLUGIN_TASK_NAME == "tag_duplicates_task" or stash.PLUGIN_TASK_NAME == "create_duplicate_report_task") and 'Target' in stash.JSON_INPUT['args']: stash.enableProgressBar(False) if stash.JSON_INPUT['args']['Target'].startswith("0"): matchDupDistance = 0 elif stash.JSON_INPUT['args']['Target'].startswith("1"): matchDupDistance = 1 elif stash.JSON_INPUT['args']['Target'].startswith("2"): matchDupDistance = 2 elif stash.JSON_INPUT['args']['Target'].startswith("3"): matchDupDistance = 3 stash.Trace(f"Target = {stash.JSON_INPUT['args']['Target']}") targets = stash.JSON_INPUT['args']['Target'].split(":") if len(targets) > 1: significantTimeDiff = float(targets[1]) excludeFromReportIfSignificantTimeDiff = True if len(targets) > 16: if targets[2] == "true": htmlIncludeImagePreview = True else: htmlIncludeImagePreview = False htmlReportPaginate = int(targets[3]) htmlImagePreviewSize = int(targets[4]) htmlImagePreviewPopupSize = int(targets[5]) htmlHighlightTimeDiff = int(targets[6]) maxDupToProcess = int(targets[7]) if targets[8] == "true": htmlPreviewOrStream = "stream" else: htmlPreviewOrStream = "preview" htmlSupperHighlight = targets[9] htmlDetailDiffTextColor = targets[10] htmlLowerHighlight = targets[11] htmlReportBackgroundColor = targets[12] htmlReportTextColor = targets[13] htmlVideoPreviewWidth = targets[14] htmlVideoPreviewHeight = targets[15] if targets[16] == "true": htmlIncludeVideoPreview = True else: htmlIncludeVideoPreview = False logTraceForAdvanceMenuOpt = True if htmlIncludeImagePreview and (htmlImagePreviewSize == htmlImagePreviewPopupSize): htmlImagePreviewPopupEnable = False if matchDupDistance == 1: matchPhaseDistance = PhashDistance.HIGH matchPhaseDistanceText = "High Match" elif matchDupDistance == 2: matchPhaseDistance = PhashDistance.MEDIUM matchPhaseDistanceText = "Medium Match" elif matchDupDistance == 3: matchPhaseDistance = PhashDistance.LOW matchPhaseDistanceText = "Low Match" # significantTimeDiff can not be higher than 1 and shouldn't be lower than .5 if significantTimeDiff > 1: significantTimeDiff = float(1.00) if significantTimeDiff < .25: significantTimeDiff = float(0.25) if logTraceForAdvanceMenuOpt: stash.Trace(f"PLUGIN_TASK_NAME={stash.PLUGIN_TASK_NAME}; matchDupDistance={matchDupDistance}; matchPhaseDistanceText={matchPhaseDistanceText}; matchPhaseDistance={matchPhaseDistance}; significantTimeDiff={significantTimeDiff}; htmlReportPaginate={htmlReportPaginate}; htmlIncludeImagePreview={htmlIncludeImagePreview}; htmlIncludeVideoPreview={htmlIncludeVideoPreview}; maxDupToProcess={maxDupToProcess}; htmlHighlightTimeDiff={htmlHighlightTimeDiff}; htmlImagePreviewSize={htmlImagePreviewSize}; htmlImagePreviewPopupSize={htmlImagePreviewPopupSize}; htmlImagePreviewPopupEnable={htmlImagePreviewPopupEnable}; htmlPreviewOrStream={htmlPreviewOrStream};") duplicateMarkForDeletion = stash.Setting('DupFileTag') if duplicateMarkForDeletion == "": duplicateMarkForDeletion = 'DuplicateMarkForDeletion' base1_duplicateMarkForDeletion = duplicateMarkForDeletion duplicateWhitelistTag = stash.Setting('DupWhiteListTag') if duplicateWhitelistTag == "": duplicateWhitelistTag = '_DuplicateWhitelistFile' excludeDupFileDeleteTag = stash.Setting('excludeDupFileDeleteTag') if excludeDupFileDeleteTag == "": excludeDupFileDeleteTag = '_ExcludeDuplicateMarkForDeletion' graylistMarkForDeletion = stash.Setting('graylistMarkForDeletion') if graylistMarkForDeletion == "": graylistMarkForDeletion = '_GraylistMarkForDeletion' longerDurationLowerResolution = stash.Setting('longerDurationLowerResolution') if longerDurationLowerResolution == "": longerDurationLowerResolution = '_LongerDurationLowerResolution' excludeMergeTags = [duplicateMarkForDeletion, duplicateWhitelistTag, excludeDupFileDeleteTag] if stash.Setting('underscoreDupFileTag') and not duplicateMarkForDeletion.startswith('_'): duplicateMarkForDeletionWithOutUnderscore = duplicateMarkForDeletion duplicateMarkForDeletion = "_" + duplicateMarkForDeletion if stash.renameTag(duplicateMarkForDeletionWithOutUnderscore, duplicateMarkForDeletion): stash.Log(f"Renamed tag {duplicateMarkForDeletionWithOutUnderscore} to {duplicateMarkForDeletion}") stash.Trace(f"Added underscore to {duplicateMarkForDeletionWithOutUnderscore} = {duplicateMarkForDeletion}") excludeMergeTags += [duplicateMarkForDeletion] else: stash.Trace(f"duplicateMarkForDeletion = {duplicateMarkForDeletion}") base2_duplicateMarkForDeletion = duplicateMarkForDeletion if stash.Setting('appendMatchDupDistance'): duplicateMarkForDeletion += f"_{matchDupDistance}" excludeMergeTags += [duplicateMarkForDeletion] stash.initMergeMetadata(excludeMergeTags) apiKey = "" if 'apiKey' in stash.STASH_CONFIGURATION: apiKey = stash.STASH_CONFIGURATION['apiKey'] FileLink = f"file.html?GQL={stash.url}&apiKey={apiKey}&IS_DOCKER={stash.IS_DOCKER}&PageNo=" graylist = stash.Setting('zwGraylist').split(listSeparator) graylist = [item.lower() for item in graylist] if graylist == [""] : graylist = [] stash.Trace(f"graylist = {graylist}") whitelist = stash.Setting('zvWhitelist').split(listSeparator) whitelist = [item.lower() for item in whitelist] if whitelist == [""] : whitelist = [] stash.Trace(f"whitelist = {whitelist}") blacklist = stash.Setting('zxBlacklist').split(listSeparator) blacklist = [item.lower() for item in blacklist] if blacklist == [""] : blacklist = [] pinklist = stash.Setting('zxPinklist').split(listSeparator) pinklist = [item.lower() for item in pinklist] if pinklist == [""] : pinklist = [] stash.Trace(f"pinklist = {pinklist}") def realpath(path): """ get_symbolic_target for win """ try: import win32file f = win32file.CreateFile(path, win32file.GENERIC_READ, win32file.FILE_SHARE_READ, None, win32file.OPEN_EXISTING, win32file.FILE_FLAG_BACKUP_SEMANTICS, None) target = win32file.GetFinalPathNameByHandle(f, 0) # an above gives us something like u'\\\\?\\C:\\tmp\\scalarizr\\3.3.0.7978' return target.strip('\\\\?\\') except ImportError: handle = open_dir(path) target = get_symbolic_target(handle) check_closed(handle) return target def isReparsePoint(path): import win32api import win32con from parse_reparsepoint import Navigator FinalPathname = realpath(path) stash.Log(f"(path='{path}') (FinalPathname='{FinalPathname}')") if FinalPathname != path: stash.Log(f"Symbolic link '{path}'") return True if not os.path.isdir(path): path = os.path.dirname(path) return win32api.GetFileAttributes(path) & win32con.FILE_ATTRIBUTE_REPARSE_POINT def testReparsePointAndSymLink(merge=False, deleteDup=False): stash.Trace(f"Debug Tracing (platform.system()={platform.system()})") myTestPath1 = r"B:\V\V\Tip\POV - Holly Molly petite ginger anal slut - RedTube.mp4" # not a reparse point or symbolic link myTestPath2 = r"B:\_\SpecialSet\Amateur Anal Attempts\BRCC test studio name.m2ts" # reparse point myTestPath3 = r"B:\_\SpecialSet\Amateur Anal Attempts\Amateur Anal Attempts 4.mp4" #symbolic link myTestPath4 = r"E:\Stash\plugins\RenameFile\README.md" #symbolic link myTestPath5 = r"E:\_\David-Maisonave\Axter-Stash\plugins\RenameFile\README.md" #symbolic link myTestPath6 = r"E:\_\David-Maisonave\Axter-Stash\plugins\DeleteMe\Renamer\README.md" # not reparse point stash.Log(f"Testing '{myTestPath1}'") if isReparsePoint(myTestPath1): stash.Log(f"isSymLink '{myTestPath1}'") else: stash.Log(f"Not isSymLink '{myTestPath1}'") if isReparsePoint(myTestPath2): stash.Log(f"isSymLink '{myTestPath2}'") else: stash.Log(f"Not isSymLink '{myTestPath2}'") if isReparsePoint(myTestPath3): stash.Log(f"isSymLink '{myTestPath3}'") else: stash.Log(f"Not isSymLink '{myTestPath3}'") if isReparsePoint(myTestPath4): stash.Log(f"isSymLink '{myTestPath4}'") else: stash.Log(f"Not isSymLink '{myTestPath4}'") if isReparsePoint(myTestPath5): stash.Log(f"isSymLink '{myTestPath5}'") else: stash.Log(f"Not isSymLink '{myTestPath5}'") if isReparsePoint(myTestPath6): stash.Log(f"isSymLink '{myTestPath6}'") else: stash.Log(f"Not isSymLink '{myTestPath6}'") return detailPrefix = "BaseDup=" detailPostfix = "\n" def setTagId(tagName, sceneDetails, DupFileToKeep, TagReason="", ignoreAutoTag=False): details = "" ORG_DATA_DICT = {'id' : sceneDetails['id']} dataDict = ORG_DATA_DICT.copy() doAddTag = True if addPrimaryDupPathToDetails: BaseDupStr = f"{detailPrefix}{DupFileToKeep['files'][0]['path']}\n{stash.STASH_URL}/scenes/{DupFileToKeep['id']}\n{TagReason}(matchDupDistance={matchPhaseDistanceText})\n{detailPostfix}" if sceneDetails['details'] == "": details = BaseDupStr elif not sceneDetails['details'].startswith(detailPrefix): details = f"{BaseDupStr};\n{sceneDetails['details']}" for tag in sceneDetails['tags']: if tag['name'] == tagName: doAddTag = False break if doAddTag: stash.addTag(sceneDetails, tagName, ignoreAutoTag=ignoreAutoTag) if details != "": dataDict.update({'details' : details}) if dataDict != ORG_DATA_DICT: stash.updateScene(dataDict) stash.Trace(f"[setTagId] Updated {sceneDetails['files'][0]['path']} with metadata {dataDict} and tag {tagName}", toAscii=True) else: stash.Trace(f"[setTagId] Nothing to update {sceneDetails['files'][0]['path']} already has tag {tagName}.", toAscii=True) return doAddTag def setTagId_withRetry(tagName, sceneDetails, DupFileToKeep, TagReason="", ignoreAutoTag=False, retryCount = 12, sleepSecondsBetweenRetry = 5): errMsg = None for i in range(0, retryCount): try: if errMsg != None: stash.Warn(errMsg) return setTagId(tagName, sceneDetails, DupFileToKeep, TagReason, ignoreAutoTag) except (requests.exceptions.ConnectionError, ConnectionResetError): tb = traceback.format_exc() errMsg = f"[setTagId] Exception calling setTagId. Will retry; count({i}); Error: {e}\nTraceBack={tb}" except Exception as e: tb = traceback.format_exc() errMsg = f"[setTagId] Unknown exception calling setTagId. Will retry; count({i}); Error: {e}\nTraceBack={tb}" time.sleep(sleepSecondsBetweenRetry) def hasSameDir(path1, path2): if pathlib.Path(path1).resolve().parent == pathlib.Path(path2).resolve().parent: return True return False def sendToTrash(path): if not os.path.isfile(path): stash.Warn(f"File does not exist: {path}.", toAscii=True) return False try: from send2trash import send2trash # Requirement: pip install Send2Trash send2trash(path) return True except Exception as e: stash.Error(f"Failed to send file {path} to recycle bin. Error: {e}", toAscii=True) try: if os.path.isfile(path): os.remove(path) return True except Exception as e: stash.Error(f"Failed to delete file {path}. Error: {e}", toAscii=True) return False # If ckTimeDiff=False: Does durration2 have significant more time than durration1 def significantTimeDiffCheck(durration1, durration2, ckTimeDiff = False): # If ckTimeDiff=True: is time different significant in either direction. if not isinstance(durration1, int) and 'files' in durration1: durration1 = int(durration1['files'][0]['duration']) durration2 = int(durration2['files'][0]['duration']) timeDiff = getTimeDif(durration1, durration2) if ckTimeDiff and timeDiff > 1: timeDiff = getTimeDif(durration2, durration1) if timeDiff < significantTimeDiff: return True return False def getTimeDif(durration1, durration2): # Where durration1 is ecpected to be smaller than durration2 IE(45/60=.75) return durration1 / durration2 def isBetterVideo(scene1, scene2, swapCandidateCk = False): # is scene2 better than scene1 # Prioritize higher reslution over codec, bit rate, and frame rate if int(scene1['files'][0]['width']) * int(scene1['files'][0]['height']) > int(scene2['files'][0]['width']) * int(scene2['files'][0]['height']): return False if (favorBitRateChange and swapCandidateCk == False) or (swapCandidateCk and swapBetterBitRate): if (favorHighBitRate and int(scene2['files'][0]['bit_rate']) > int(scene1['files'][0]['bit_rate'])) or (not favorHighBitRate and int(scene2['files'][0]['bit_rate']) < int(scene1['files'][0]['bit_rate'])): stash.Trace(f"[isBetterVideo]:[favorHighBitRate={favorHighBitRate}] Better bit rate. {scene1['files'][0]['path']}={scene1['files'][0]['bit_rate']} v.s. {scene2['files'][0]['path']}={scene2['files'][0]['bit_rate']}") return True if (favorCodecRanking and swapCandidateCk == False) or (swapCandidateCk and swapCodec): scene1CodecRank = stash.indexStartsWithInList(codecRanking, scene1['files'][0]['video_codec']) scene2CodecRank = stash.indexStartsWithInList(codecRanking, scene2['files'][0]['video_codec']) if scene2CodecRank < scene1CodecRank: stash.Trace(f"[isBetterVideo] Better codec. {scene1['files'][0]['path']}={scene1['files'][0]['video_codec']}:Rank={scene1CodecRank} v.s. {scene2['files'][0]['path']}={scene2['files'][0]['video_codec']}:Rank={scene2CodecRank}") return True if (favorFrameRateChange and swapCandidateCk == False) or (swapCandidateCk and swapBetterFrameRate): if (favorHigherFrameRate and int(scene2['files'][0]['frame_rate']) > int(scene1['files'][0]['frame_rate'])) or (not favorHigherFrameRate and int(scene2['files'][0]['frame_rate']) < int(scene1['files'][0]['frame_rate'])): stash.Trace(f"[isBetterVideo]:[favorHigherFrameRate={favorHigherFrameRate}] Better frame rate. {scene1['files'][0]['path']}={scene1['files'][0]['frame_rate']} v.s. {scene2['files'][0]['path']}={scene2['files'][0]['frame_rate']}") return True return False def significantMoreTimeCompareToBetterVideo(scene1, scene2): # is scene2 better than scene1 if isinstance(scene1, int): scene1 = stash.find_scene(scene1) scene2 = stash.find_scene(scene2) if int(scene1['files'][0]['duration']) >= int(scene2['files'][0]['duration']): return False if int(scene1['files'][0]['width']) * int(scene1['files'][0]['height']) > int(scene2['files'][0]['width']) * int(scene2['files'][0]['height']): if significantTimeDiffCheck(scene1, scene2): if tagLongDurationLowRes: didAddTag = setTagId_withRetry(longerDurationLowerResolution, scene2, scene1, ignoreAutoTag=True) stash.Log(f"Tagged sene2 with tag {longerDurationLowerResolution}, because scene1 is better video, but it has significant less time ({getTimeDif(int(scene1['files'][0]['duration']), int(scene2['files'][0]['duration']))}%) compare to scene2; scene1={scene1['files'][0]['path']} (ID={scene1['id']})(duration={scene1['files'][0]['duration']}); scene2={scene2['files'][0]['path']} (ID={scene2['id']}) (duration={scene1['files'][0]['duration']}); didAddTag={didAddTag}") else: stash.Warn(f"Scene1 is better video, but it has significant less time ({getTimeDif(int(scene1['files'][0]['duration']), int(scene2['files'][0]['duration']))}%) compare to scene2; Scene1={scene1['files'][0]['path']} (ID={scene1['id']})(duration={scene1['files'][0]['duration']}); Scene2={scene2['files'][0]['path']} (ID={scene2['id']}) (duration={scene1['files'][0]['duration']})") return False return True def allThingsEqual(scene1, scene2): # If all important things are equal, return true if int(scene1['files'][0]['duration']) != int(scene2['files'][0]['duration']): return False if scene1['files'][0]['width'] != scene2['files'][0]['width']: return False if scene1['files'][0]['height'] != scene2['files'][0]['height']: return False if bitRateIsImporantComp and scene1['files'][0]['bit_rate'] != scene2['files'][0]['bit_rate']: return False if codecIsImporantComp and scene1['files'][0]['video_codec'] != scene2['files'][0]['video_codec']: return False return True def isSwapCandidate(DupFileToKeep, DupFile): # Don't move if both are in whitelist if stash.startsWithInList(whitelist, DupFileToKeep['files'][0]['path']) and stash.startsWithInList(whitelist, DupFile['files'][0]['path']): return False if swapHighRes and int(DupFileToKeep['files'][0]['width']) * int(DupFileToKeep['files'][0]['height']) > int(DupFile['files'][0]['width']) * int(DupFile['files'][0]['height']): if not significantTimeDiffCheck(DupFileToKeep, DupFile): return True else: stash.Warn(f"File '{DupFileToKeep['files'][0]['path']}' has a higher resolution than '{DupFile['files'][0]['path']}', but the duration is significantly shorter.", toAscii=True) if swapLongLength and int(DupFileToKeep['files'][0]['duration']) > int(DupFile['files'][0]['duration']): if int(DupFileToKeep['files'][0]['width']) >= int(DupFile['files'][0]['width']) or int(DupFileToKeep['files'][0]['height']) >= int(DupFile['files'][0]['height']): return True if isBetterVideo(DupFile, DupFileToKeep, swapCandidateCk=True): if not significantTimeDiffCheck(DupFileToKeep, DupFile): return True else: stash.Warn(f"File '{DupFileToKeep['files'][0]['path']}' has better codec/bit-rate than '{DupFile['files'][0]['path']}', but the duration is significantly shorter; DupFileToKeep-ID={DupFileToKeep['id']};DupFile-ID={DupFile['id']};BitRate {DupFileToKeep['files'][0]['bit_rate']} vs {DupFile['files'][0]['bit_rate']};Codec {DupFileToKeep['files'][0]['video_codec']} vs {DupFile['files'][0]['video_codec']};FrameRate {DupFileToKeep['files'][0]['frame_rate']} vs {DupFile['files'][0]['frame_rate']};", toAscii=True) return False dupWhitelistTagId = None def addDupWhitelistTag(): global dupWhitelistTagId stash.Trace(f"Adding tag duplicateWhitelistTag = {duplicateWhitelistTag}") descp = 'Tag added to duplicate scenes which are in the whitelist. This means there are two or more duplicates in the whitelist.' dupWhitelistTagId = stash.createTagId(duplicateWhitelistTag, descp, ignoreAutoTag=True) stash.Trace(f"dupWhitelistTagId={dupWhitelistTagId} name={duplicateWhitelistTag}") excludeDupFileDeleteTagId = None def addExcludeDupTag(): global excludeDupFileDeleteTagId stash.Trace(f"Adding tag excludeDupFileDeleteTag = {excludeDupFileDeleteTag}") descp = 'Excludes duplicate scene from DupFileManager tagging and deletion process. A scene having this tag will not get deleted by DupFileManager' excludeDupFileDeleteTagId = stash.createTagId(excludeDupFileDeleteTag, descp, ignoreAutoTag=True) stash.Trace(f"dupWhitelistTagId={excludeDupFileDeleteTagId} name={excludeDupFileDeleteTag}") def isTaggedExcluded(Scene): for tag in Scene['tags']: if tag['name'] == excludeDupFileDeleteTag: return True return False def isWorseKeepCandidate(DupFileToKeep, Scene): if not stash.startsWithInList(whitelist, Scene['files'][0]['path']) and stash.startsWithInList(whitelist, DupFileToKeep['files'][0]['path']): return True if not stash.startsWithInList(graylist, Scene['files'][0]['path']) and stash.startsWithInList(graylist, DupFileToKeep['files'][0]['path']): return True if not stash.startsWithInList(blacklist, DupFileToKeep['files'][0]['path']) and stash.startsWithInList(blacklist, Scene['files'][0]['path']): return True if stash.startsWithInList(graylist, Scene['files'][0]['path']) and stash.startsWithInList(graylist, DupFileToKeep['files'][0]['path']) and stash.indexStartsWithInList(graylist, DupFileToKeep['files'][0]['path']) < stash.indexStartsWithInList(graylist, Scene['files'][0]['path']): return True if stash.startsWithInList(blacklist, DupFileToKeep['files'][0]['path']) and stash.startsWithInList(blacklist, Scene['files'][0]['path']) and stash.indexStartsWithInList(blacklist, DupFileToKeep['files'][0]['path']) < stash.indexStartsWithInList(blacklist, Scene['files'][0]['path']): return True return False def killScanningJobs(): try: if killScanningPostProcess: stash.stopJobs(1, "Scanning...") except Exception as e: tb = traceback.format_exc() stash.Error(f"Exception while trying to kill scan jobs; Error: {e}\nTraceBack={tb}") def getPath(Scene, getParent = False): path = stash.asc2(Scene['files'][0]['path']) path = path.replace("'", "") path = path.replace("\\\\", "\\") if getParent: return pathlib.Path(path).resolve().parent return path htmlReportPrefix = None def getHtmlReportTableRow(qtyResults, tagDuplicates): global htmlReportPrefix if htmlReportPrefix != None: return htmlReportPrefix with open(reportHeader, 'r') as file: htmlReportPrefix = file.read() htmlReportPrefix = htmlReportPrefix.replace('http://127.0.0.1:9999/graphql', stash.url) htmlReportPrefix = htmlReportPrefix.replace('http://localhost:9999/graphql', stash.url) htmlReportPrefix = htmlReportPrefix.replace("[remoteReportDirURL]", stash.Setting('remoteReportDirURL')) htmlReportPrefix = htmlReportPrefix.replace("[js_DirURL]", stash.Setting('js_DirURL')) if 'apiKey' in stash.STASH_CONFIGURATION and stash.STASH_CONFIGURATION['apiKey'] != "": htmlReportPrefix = htmlReportPrefix.replace('var apiKey = "";', f"var apiKey = \"{stash.STASH_CONFIGURATION['apiKey']}\";") if tagDuplicates == False: htmlReportPrefix = htmlReportPrefix.replace('