Files
Axter-Stash/plugins/RenameFile/renamefile.py
David Maisonave e1f3335db8 Updates to plugins RenameFile and DupFileManager
RenameFile Plugin Changes
### 0.5.6
- Fixed bug with studio getting the studio ID instead of the name of the studio in rename process.
- Improved performance by having code get all required scene details in one call to stash.
- To remove UI clutter, move rarely used options (performerAppendEnable, studioAppendEnable, tagAppendEnable, & fileRenameViaMove) to renamefile_settings.py
- Change options (performerAppendEnable, studioAppendEnable, tagAppendEnable) to default to True (enabled)

DupFileManager Plugin Changes
### 0.2.2
- Added dropdown menu logic to Advance Menu and reports.
- Added Graylist deletion option to Advance Menu.
- Report option to clear all flags from report.
- Report option to clear all (_DuplicateMarkForDeletion_?) tag from all scenes.
- Report option to delete from Stash DB all scenes with missing files in file system.
- Added popup tag list to report which list all tags associated with scene.
- Added popup performer list to report which list all performers associated with scene.
- Added popup gallery list to report which list all galleries associated with scene.
- Added popup group list to report which list all groups associated with scene.
- After merging tags in report, the report gets updated with the merged scene metadata.
- Added graylist deletion option to [**Advance Duplicate File Deletion Menu**].
- Added pinklist option to Settings->Plugins->Plugins and to [**Advance Duplicate File Deletion Menu**]
  - The pinklist is only used with the [**Advance Duplicate File Deletion Menu**], and it's **NOT** used in the primary process to selected candidates for deletion.
- Advance Menu now works with non-tagged scenes that are in the current report.
2024-11-26 09:29:26 -05:00

505 lines
25 KiB
Python

# Description: This is a Stash plugin which allows users to rename the video (scene) file name by editing the [Title] field located in the scene [Edit] tab.
# 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/RenameFile
# Based on source code from https://github.com/Serechops/Serechops-Stash/tree/main/plugins/Renamer
# To automatically install missing modules, uncomment the following lines of code.
# try:
# import ModulesValidate
# ModulesValidate.modulesInstalled(["stashapp-tools", "requests"])
# 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, shutil, json, hashlib, pathlib, logging, time, traceback
from pathlib import Path
import stashapi.log as log # Importing stashapi.log as log for critical events ONLY
from stashapi.stashapp import StashInterface
from StashPluginHelper import StashPluginHelper
from StashPluginHelper import taskQueue
from renamefile_settings import config # Import settings from renamefile_settings.py
# **********************************************************************
# Constant global variables --------------------------------------------
DEFAULT_FIELD_KEY_LIST = "title,performers,studio,tags" # Default Field Key List with the desired order
DEFAULT_SEPERATOR = "-"
# GraphQL query to fetch all scenes
QUERY_ALL_SCENES = """
query AllScenes {
allScenes {
id
updated_at
}
}
"""
# **********************************************************************
# Global variables --------------------------------------------
inputToUpdateScenePost = False
doNothing = False
exitMsg = "Change success!!"
# **********************************************************************
# ----------------------------------------------------------------------
settings = {
"yRenameEvenIfTitleEmpty": False,
"z_keyFIeldsIncludeInFileName": False,
"zfieldKeyList": DEFAULT_FIELD_KEY_LIST,
"zmaximumTagKeys": 12,
"zseparators": DEFAULT_SEPERATOR,
"zzdebugTracing": False,
"zzdryRun": False,
}
stash = StashPluginHelper(
settings=settings,
config=config,
maxbytes=10*1024*1024,
)
# stash.status(logLevel=logging.DEBUG)
if stash.PLUGIN_ID in stash.PLUGIN_CONFIGURATION:
stash.pluginSettings.update(stash.PLUGIN_CONFIGURATION[stash.PLUGIN_ID])
if stash.IS_DOCKER:
stash.log_to_wrn_set = stash.LogTo.STASH + stash.LogTo.FILE
# ----------------------------------------------------------------------
WRAPPER_STYLES = config["wrapper_styles"]
POSTFIX_STYLES = config["postfix_styles"]
renameEvenIfTitleEmpty = stash.pluginSettings["yRenameEvenIfTitleEmpty"]
# Extract dry_run setting from settings
dry_run = stash.pluginSettings["zzdryRun"]
dry_run_prefix = ''
try:
stash.Trace(f"hookContext={stash.JSON_INPUT['args']['hookContext']}")
if stash.JSON_INPUT['args']['hookContext']['input']:
if stash.JSON_INPUT['args']['hookContext']['input'] == None:
doNothing = True
stash.Log("input = None")
else:
inputToUpdateScenePost = True # This avoids calling rename logic twice
except:
pass
stash.Warn("Exception thrown")
if dry_run:
stash.Log("Dry run mode is enabled.")
dry_run_prefix = "Would've "
max_tag_keys = stash.pluginSettings["zmaximumTagKeys"] if stash.pluginSettings["zmaximumTagKeys"] != 0 else 12 # Need this incase use explicitly sets value to zero in UI
# ToDo: Add split logic here to slpit possible string array into an array
exclude_paths = config["pathToExclude"]
exclude_paths = exclude_paths.split()
if len(exclude_paths) > 0:
stash.Trace(f"(exclude_paths={exclude_paths})")
excluded_tags = config["excludeTags"]
# Extract tag whitelist from settings
tag_whitelist = config["tagWhitelist"]
if not tag_whitelist:
tag_whitelist = ""
if len(tag_whitelist) > 0:
stash.Trace(f"(tag_whitelist={tag_whitelist})")
handleExe = stash.pluginConfig['handleExe']
openedfile = None
if handleExe != None and handleExe != "" and os.path.isfile(handleExe):
# ModulesValidate.modulesInstalled(["psutil"], silent=True)
from openedFile import openedFile
openedfile = openedFile(handleExe, stash)
endpointHost = stash.JSON_INPUT['server_connection']['Host']
if endpointHost == "0.0.0.0":
endpointHost = "localhost"
endpoint = f"{stash.JSON_INPUT['server_connection']['Scheme']}://{endpointHost}:{stash.JSON_INPUT['server_connection']['Port']}/graphql"
# stash.Trace(f"(endpoint={endpoint})")
move_files = stash.Setting("fileRenameViaMove")
fieldKeyList = stash.pluginSettings["zfieldKeyList"] # Default Field Key List with the desired order
if not fieldKeyList or fieldKeyList == "":
fieldKeyList = DEFAULT_FIELD_KEY_LIST
fieldKeyList = fieldKeyList.replace(" ", "")
fieldKeyList = fieldKeyList.replace(";", ",")
fieldKeyList = fieldKeyList.split(",")
# stash.Trace(f"(fieldKeyList={fieldKeyList})")
separator = stash.pluginSettings["zseparators"]
# ----------------------------------------------------------------------
# **********************************************************************
double_separator = separator + separator
# stash.Trace(f"(WRAPPER_STYLES={WRAPPER_STYLES}) (POSTFIX_STYLES={POSTFIX_STYLES})")
# Function to replace illegal characters in filenames
def replace_illegal_characters(filename):
illegal_characters = ['<', '>', ':', '"', '/', '\\', '|', '?', '*']
for char in illegal_characters:
filename = filename.replace(char, '-')
return filename
def should_exclude_path(scene_details):
scene_path = scene_details['files'][0]['path'] # Assuming the first file path is representative
for exclude_path in exclude_paths:
if scene_path.startswith(exclude_path):
return True
return False
include_keyField_if_in_name = stash.pluginSettings["z_keyFIeldsIncludeInFileName"]
excludeIgnoreAutoTags = config["excludeIgnoreAutoTags"]
def getPerformers(scene, title):
title = title.lower()
results = ""
for performer in scene['performers']:
name = performer['name']
stash.Trace(f"performer = {name}")
if not include_keyField_if_in_name:
if name.lower() in title:
stash.Trace(f"Skipping performer name '{name}' because already in title: '{title}'")
continue
results += f"{name}, "
return results.strip(", ")
def getGalleries(scene, title):
results = ""
for gallery in scene['galleries']:
name = gallery = stash.find_gallery(gallery['id'])['title']
stash.Trace(f"gallery = {name}")
if not include_keyField_if_in_name:
if name.lower() in title:
stash.Trace(f"Skipping gallery name '{name}' because already in title: '{title}'")
continue
results += f"{name}, "
return results.strip(", ")
def getTags(scene, title):
title = title.lower()
results = ""
for tag in scene['tags']:
name = tag['name']
stash.Trace(f"tag = {name}")
if excludeIgnoreAutoTags == True and tag['ignore_auto_tag'] == True:
stash.Trace(f"Skipping tag name '{name}' because ignore_auto_tag is True.")
continue
if not include_keyField_if_in_name:
if name.lower() in title:
stash.Trace(f"Skipping tag name '{name}' because already in title: '{title}'")
continue
results += f"{name}, "
return results.strip(", ")
# Function to form the new filename based on scene details and user settings
def form_filename(original_file_stem, scene_details):
filename_parts = []
tag_keys_added = 0
default_title = ''
if_notitle_use_org_filename = config["if_notitle_use_org_filename"]
if if_notitle_use_org_filename:
default_title = original_file_stem
# ...................
# Title needs to be set here incase user changes the fieldKeyList where tags or performers come before title.
title = scene_details.get('title', default_title)
if not title:
if if_notitle_use_org_filename:
title = default_title
# ...................
stash.Trace(f"(title=\"{title}\")")
# Function to add tag to filename
def add_tag(tag_name):
nonlocal tag_keys_added
nonlocal filename_parts
stash.Trace(f"(tag_name={tag_name})")
if max_tag_keys == -1 or (max_tag_keys is not None and tag_keys_added >= int(max_tag_keys)):
return # Skip adding more tags if the maximum limit is reached
if tag_name in excluded_tags:
stash.Trace(f"EXCLUDING (tag_name={tag_name})")
return
# Check if the tag name is in the whitelist
if tag_whitelist == "" or tag_whitelist == None or (tag_whitelist and tag_name in tag_whitelist):
if WRAPPER_STYLES.get('tag'):
filename_parts.append(f"{WRAPPER_STYLES['tag'][0]}{tag_name}{WRAPPER_STYLES['tag'][1]}")
else:
filename_parts.append(tag_name)
tag_keys_added += 1
else:
stash.Log(f"Skipping tag not in whitelist: {tag_name}")
stash.Trace(f"(tag_keys_added={tag_keys_added})")
stash.Trace(f"scene_details = {scene_details}")
for key in fieldKeyList:
if key == 'studio':
if stash.Setting("studioAppendEnable"):
studio = scene_details.get('studio')
if studio != None:
studio_name = studio.get('name')
else:
studio_name = None
stash.Trace(f"(studio_name={studio_name})")
if studio_name:
studio_name += POSTFIX_STYLES.get('studio')
if include_keyField_if_in_name or studio_name.lower() not in title.lower():
if WRAPPER_STYLES.get('studio'):
filename_parts.append(f"{WRAPPER_STYLES['studio'][0]}{studio_name}{WRAPPER_STYLES['studio'][1]}")
else:
filename_parts.append(studio_name)
else:
stash.Trace("Skipping studio because of user setting studioAppend disabled.")
elif key == 'title':
if title: # This value has already been fetch in start of function because it needs to be defined before tags and performers
title += POSTFIX_STYLES.get('title')
if WRAPPER_STYLES.get('title'):
filename_parts.append(f"{WRAPPER_STYLES['title'][0]}{title}{WRAPPER_STYLES['title'][1]}")
else:
filename_parts.append(title)
elif key == 'performers':
if stash.Setting("performerAppendEnable"):
performers = getPerformers(scene_details, title)
if performers != "":
performers += POSTFIX_STYLES.get('performers')
stash.Trace(f"(performers={performers})")
if WRAPPER_STYLES.get('performers'):
filename_parts.append(f"{WRAPPER_STYLES['performers'][0]}{performers}{WRAPPER_STYLES['performers'][1]}")
else:
filename_parts.append(performers)
elif key == 'date':
scene_date = scene_details.get('date')
if scene_date:
scene_date += POSTFIX_STYLES.get('date')
if WRAPPER_STYLES.get('date'):
scene_date = f"{WRAPPER_STYLES['date'][0]}{scene_date}{WRAPPER_STYLES['date'][1]}"
if scene_date not in title:
filename_parts.append(scene_date)
elif key == 'resolution':
# width = str(scene_details.get('files', [{}])[0].get('width', '')) # Convert width to string
# height = str(scene_details.get('files', [{}])[0].get('height', '')) # Convert height to string
width = str(scene_details['files'][0]['width'])
height = str(scene_details['files'][0]['height'])
if width and height:
resolution = width + POSTFIX_STYLES.get('width_height_seperator') + height + POSTFIX_STYLES.get('resolution')
if WRAPPER_STYLES.get('resolution'):
resolution = f"{WRAPPER_STYLES['resolution'][0]}{resolution}{WRAPPER_STYLES['width'][1]}"
if resolution not in title:
filename_parts.append(resolution)
elif key == 'width':
width = str(scene_details['files'][0]['width'])
if width:
width += POSTFIX_STYLES.get('width')
if WRAPPER_STYLES.get('width'):
width = f"{WRAPPER_STYLES['width'][0]}{width}{WRAPPER_STYLES['width'][1]}"
if width not in title:
filename_parts.append(width)
elif key == 'height':
height = str(scene_details['files'][0]['height'])
if height:
height += POSTFIX_STYLES.get('height')
if WRAPPER_STYLES.get('height'):
height = f"{WRAPPER_STYLES['height'][0]}{height}{WRAPPER_STYLES['height'][1]}"
if height not in title:
filename_parts.append(height)
elif key == 'video_codec':
video_codec = scene_details['files'][0]['video_codec'].upper() # Convert to uppercase
if video_codec:
video_codec += POSTFIX_STYLES.get('video_codec')
if WRAPPER_STYLES.get('video_codec'):
video_codec = f"{WRAPPER_STYLES['video_codec'][0]}{video_codec}{WRAPPER_STYLES['video_codec'][1]}"
if video_codec not in title:
filename_parts.append(video_codec)
elif key == 'frame_rate':
frame_rate = str(scene_details['files'][0]['frame_rate']) + 'FPS' # Convert to string and append ' FPS'
if frame_rate:
frame_rate += POSTFIX_STYLES.get('frame_rate')
if WRAPPER_STYLES.get('frame_rate'):
frame_rate = f"{WRAPPER_STYLES['frame_rate'][0]}{frame_rate}{WRAPPER_STYLES['frame_rate'][1]}"
if frame_rate not in title:
filename_parts.append(frame_rate)
elif key == 'galleries':
galleries = getGalleries(scene_details, title)
if galleries != "":
galleries += POSTFIX_STYLES.get('galleries')
if WRAPPER_STYLES.get('galleries'):
filename_parts.append(f"{WRAPPER_STYLES['galleries'][0]}{galleries}{WRAPPER_STYLES['galleries'][1]}")
else:
filename_parts.append(galleries)
stash.Trace(f"(galleries={galleries})")
elif key == 'tags':
if stash.Setting("tagAppendEnable"):
tags = getTags(scene_details, title)
if tags != "":
tags += POSTFIX_STYLES.get('tag')
if WRAPPER_STYLES.get('tag'):
filename_parts.append(f"{WRAPPER_STYLES['tag'][0]}{tags}{WRAPPER_STYLES['tag'][1]}")
else:
filename_parts.append(tags)
stash.Trace(f"(tags={tags})")
stash.Trace(f"(filename_parts={filename_parts})")
new_filename = separator.join(filename_parts).replace(double_separator, separator)
stash.Trace(f"(new_filename={new_filename})")
# Check if the scene's path matches any of the excluded paths
if exclude_paths and should_exclude_path(scene_details):
stash.Log(f"Scene belongs to an excluded path. Skipping filename modification.")
return Path(scene_details['files'][0]['path']).name # Return the original filename
return replace_illegal_characters(new_filename)
def rename_scene(scene_id):
global exitMsg
POST_SCAN_DELAY = 3
fragment = 'id title performers {name} tags {id name ignore_auto_tag} studio {name} galleries {id} files {id path width height video_codec frame_rate} date'
scene_details = stash.find_scene(scene_id, fragment)
stash.Trace(f"(scene_details={scene_details})")
if not scene_details:
stash.Error(f"Scene with ID {scene_id} not found.")
return None
taskqueue = taskQueue(stash.job_queue())
original_file_path = scene_details['files'][0]['path']
original_parent_directory = Path(original_file_path).parent
maxScanCountDefault = 5
maxScanCountForUpdate = 10
if scene_details['title'] == None or scene_details['title'] == "":
if renameEvenIfTitleEmpty == False:
stash.Log("Nothing to do because title is empty.")
return None
stash.Warn("Title is empty.")
maxScanCountDefault = 1
maxScanCountForUpdate = 1
if not os.path.isfile(original_file_path) and not taskqueue.clearDupTagsJobOnTaskQueue() and not taskqueue.deleteTaggedScenesJobOnTaskQueue() and not taskqueue.tooManyScanOnTaskQueue(maxScanCountDefault):
stash.Warn(f"[metadata_scan] Have to rescan scene ID {scene_id}, because Stash library path '{original_file_path}' does not exist. Scanning path: {original_parent_directory.resolve().as_posix()}")
stash.metadata_scan(paths=[original_parent_directory.resolve().as_posix()])
time.sleep(POST_SCAN_DELAY) # After a scan, need a few seconds delay before fetching data.
scene_details = stash.find_scene(scene_id)
original_file_path = scene_details['files'][0]['path']
if not os.path.isfile(original_file_path):
stash.Error(f"Can not rename file because path {original_file_path} doesn't exist.")
return None
stash.Trace(f"(original_file_path={original_file_path})")
# Check if the scene's path matches any of the excluded paths
if exclude_paths and any(Path(original_file_path).match(exclude_path) for exclude_path in exclude_paths):
stash.Log(f"Scene with ID {scene_id} belongs to an excluded path. Skipping modifications.")
return None
original_file_stem = Path(original_file_path).stem
original_file_name = Path(original_file_path).name
new_filename = form_filename(original_file_stem, scene_details)
max_filename_length = int(config["max_filename_length"])
if len(new_filename) > max_filename_length:
extension_length = len(Path(original_file_path).suffix)
max_base_filename_length = max_filename_length - extension_length
truncated_filename = new_filename[:max_base_filename_length]
hash_suffix = hashlib.md5(new_filename.encode()).hexdigest()
new_filename = truncated_filename + '_' + hash_suffix + Path(original_file_path).suffix
newFilenameWithExt = new_filename + Path(original_file_path).suffix
new_file_path = f"{original_parent_directory}{os.sep}{new_filename}{Path(original_file_name).suffix}"
stash.Trace(f"(original_file_name={original_file_name}) (newFilenameWithExt={newFilenameWithExt})(new_file_path={new_file_path}) (FileID={scene_details['files'][0]['id']})")
if original_file_name == newFilenameWithExt or original_file_name == new_filename:
stash.Log(f"Nothing to do, because new file name matches original file name: (newFilenameWithExt={newFilenameWithExt})")
return None
targetDidExist = True if os.path.isfile(new_file_path) else False
try:
if openedfile != None:
results = openedfile.closeFile(original_file_path)
if results != None:
stash.Warn(f"Had to close '{original_file_path}', because it was opened by following pids:{results['pids']}")
if move_files:
if not dry_run:
shutil.move(original_file_path, new_file_path)
exitMsg = f"{dry_run_prefix}Moved file to '{new_file_path}' from '{original_file_path}'"
else:
stash.Trace(f"Rename('{original_file_path}', '{new_file_path}')")
if not dry_run:
os.rename(original_file_path, new_file_path)
exitMsg = f"{dry_run_prefix}Renamed file to '{new_file_path}' from '{original_file_path}'"
except OSError as e:
exitMsg = f"Failed to move/rename file: From {original_file_path} to {new_file_path}; targetDidExist={targetDidExist}. Error: {e}"
stash.Error(exitMsg)
if not taskqueue.tooManyScanOnTaskQueue(maxScanCountDefault):
stash.Trace(f"Calling [metadata_scan] for path {original_parent_directory.resolve().as_posix()}")
stash.metadata_scan(paths=[original_parent_directory.resolve().as_posix()])
if targetDidExist:
raise
if os.path.isfile(new_file_path):
if os.path.isfile(original_file_path):
os.remove(original_file_path)
pass
else:
# ToDo: Add delay rename here
raise
if stash.renameFileNameInDB(scene_details['files'][0]['id'], original_file_name, newFilenameWithExt):
stash.Trace("DB rename success")
elif not taskqueue.tooManyScanOnTaskQueue(maxScanCountForUpdate):
stash.Trace(f"Calling [metadata_scan] for path {original_parent_directory.resolve().as_posix()}")
stash.metadata_scan(paths=[original_parent_directory.resolve().as_posix()])
time.sleep(POST_SCAN_DELAY) # After a scan, need a few seconds delay before fetching data.
scene_details = stash.find_scene(scene_id)
if new_file_path != scene_details['files'][0]['path'] and not targetDidExist and not taskqueue.tooManyScanOnTaskQueue(maxScanCountDefault):
stash.Trace(f"Calling [metadata_scan] for path {original_parent_directory.resolve().as_posix()}")
stash.metadata_scan(paths=[original_parent_directory.resolve().as_posix()])
time.sleep(POST_SCAN_DELAY) # After a scan, need a few seconds delay before fetching data.
scene_details = stash.find_scene(scene_id)
if new_file_path != scene_details['files'][0]['path']:
if not os.path.isfile(new_file_path):
stash.Error(f"Failed to rename file from {scene_details['files'][0]['path']} to {new_file_path}.")
elif os.path.isfile(scene_details['files'][0]['path']):
stash.Warn(f"Failed to rename file from {scene_details['files'][0]['path']} to {new_file_path}. Old file still exist. Will attempt delay deletion.")
for i in range(1, 5*60):
time.sleep(60)
if not os.path.isfile(new_file_path):
stash.Error(f"Not deleting old file name {original_file_path} because new file name (new_file_path) does NOT exist.")
break
os.remove(original_file_path)
if not os.path.isfile(original_file_path):
stash.Log(f"Deleted {original_file_path} in delay deletion after {i} minutes.")
stash.Trace(f"Calling [metadata_scan] for path {original_parent_directory.resolve().as_posix()}")
stash.metadata_scan(paths=[original_parent_directory.resolve().as_posix()])
break
else:
org_stem = Path(scene_details['files'][0]['path']).stem
new_stem = Path(new_file_path).stem
file_id = scene_details['files'][0]['id']
stash.Warn(f"Failed to update Stash library with new name. Will try direct SQL update. org_name={org_stem}; new_name={new_stem}; file_id={file_id}")
# stash.set_file_basename(file_id, new_stem)
else:
stash.Warn(f"Not performming [metadata_scan] because too many scan jobs are already on the Task Queue. Recommend running a full scan, and a clean job to make sure Stash DB is up to date.")
if not taskqueue.cleanJobOnTaskQueue():
stash.metadata_scan()
stash.metadata_clean()
if not taskqueue.cleanGeneratedJobOnTaskQueue():
stash.metadata_clean_generated()
stash.Log(exitMsg)
return new_filename
def rename_files_task():
scene_result = stash.get_all_scenes()
all_scenes = scene_result['allScenes']
if not all_scenes:
stash.Error("No scenes found.")
exit()
# Find the scene with the latest updated_at timestamp
latest_scene = max(all_scenes, key=lambda scene: scene['updated_at'])
# Extract the ID of the latest scene
latest_scene_id = latest_scene.get('id')
# Rename the latest scene and trigger metadata scan
new_filename = rename_scene(latest_scene_id)
# Log dry run state and indicate if no changes were made
if dry_run:
stash.Log("Dry run: Script executed in dry run mode. No changes were made.")
elif not new_filename:
stash.Log("No changes were made.")
return
try:
if stash.PLUGIN_TASK_NAME == "rename_files_task":
stash.Trace(f"PLUGIN_TASK_NAME={stash.PLUGIN_TASK_NAME}")
rename_files_task()
elif inputToUpdateScenePost:
rename_files_task()
else:
stash.Trace(f"Nothing to do. doNothing={doNothing}")
except Exception as e:
tb = traceback.format_exc()
stash.Error(f"Exception while running Plugin. Error: {e}\nTraceBack={tb}")
# stash.log.exception('Got exception on main handler')
stash.Trace("\n*********************************\nEXITING ***********************\n*********************************")