Added logic to allow RenameFile to close open file handles before renaming

This commit is contained in:
David Maisonave
2024-08-31 21:11:42 -04:00
parent 1a25a4cf38
commit 7efb0d7bd9
9 changed files with 309 additions and 86 deletions

View File

@@ -1,29 +1,31 @@
"""
StashPluginHelper (By David Maisonave aka Axter)
See end of this file for example usage
Log Features:
Can optionally log out to multiple outputs for each Log or Trace call.
Logging includes source code line number
Sets a maximum plugin log file size
Stash Interface Features:
Gets STASH_URL value from command line argument and/or from STDIN_READ
Sets FRAGMENT_SERVER based on command line arguments or STDIN_READ
Sets PLUGIN_ID based on the main script file name (in lower case)
Gets PLUGIN_TASK_NAME value
Sets pluginSettings (The plugin UI settings)
Misc Features:
Gets DRY_RUN value from command line argument and/or from UI and/or from config file
Gets DEBUG_TRACING value from command line argument and/or from UI and/or from config file
Sets RUNNING_IN_COMMAND_LINE_MODE to True if detects multiple arguments
Sets CALLED_AS_STASH_PLUGIN to True if it's able to read from STDIN_READ
"""
from stashapi.stashapp import StashInterface
from logging.handlers import RotatingFileHandler
import re, inspect, sys, os, pathlib, logging, json
import re, inspect, sys, os, pathlib, logging, json, ctypes
import concurrent.futures
from stashapi.stash_types import PhashDistance
import __main__
_ARGUMENT_UNSPECIFIED_ = "_ARGUMENT_UNSPECIFIED_"
# StashPluginHelper (By David Maisonave aka Axter)
# See end of this file for example usage
# Log Features:
# Can optionally log out to multiple outputs for each Log or Trace call.
# Logging includes source code line number
# Sets a maximum plugin log file size
# Stash Interface Features:
# Gets STASH_URL value from command line argument and/or from STDIN_READ
# Sets FRAGMENT_SERVER based on command line arguments or STDIN_READ
# Sets PLUGIN_ID based on the main script file name (in lower case)
# Gets PLUGIN_TASK_NAME value
# Sets pluginSettings (The plugin UI settings)
# Misc Features:
# Gets DRY_RUN value from command line argument and/or from UI and/or from config file
# Gets DEBUG_TRACING value from command line argument and/or from UI and/or from config file
# Sets RUNNING_IN_COMMAND_LINE_MODE to True if detects multiple arguments
# Sets CALLED_AS_STASH_PLUGIN to True if it's able to read from STDIN_READ
class StashPluginHelper(StashInterface):
# Primary Members for external reference
PLUGIN_TASK_NAME = None

View File

@@ -1,29 +1,31 @@
"""
StashPluginHelper (By David Maisonave aka Axter)
See end of this file for example usage
Log Features:
Can optionally log out to multiple outputs for each Log or Trace call.
Logging includes source code line number
Sets a maximum plugin log file size
Stash Interface Features:
Gets STASH_URL value from command line argument and/or from STDIN_READ
Sets FRAGMENT_SERVER based on command line arguments or STDIN_READ
Sets PLUGIN_ID based on the main script file name (in lower case)
Gets PLUGIN_TASK_NAME value
Sets pluginSettings (The plugin UI settings)
Misc Features:
Gets DRY_RUN value from command line argument and/or from UI and/or from config file
Gets DEBUG_TRACING value from command line argument and/or from UI and/or from config file
Sets RUNNING_IN_COMMAND_LINE_MODE to True if detects multiple arguments
Sets CALLED_AS_STASH_PLUGIN to True if it's able to read from STDIN_READ
"""
from stashapi.stashapp import StashInterface
from logging.handlers import RotatingFileHandler
import re, inspect, sys, os, pathlib, logging, json
import re, inspect, sys, os, pathlib, logging, json, ctypes
import concurrent.futures
from stashapi.stash_types import PhashDistance
import __main__
_ARGUMENT_UNSPECIFIED_ = "_ARGUMENT_UNSPECIFIED_"
# StashPluginHelper (By David Maisonave aka Axter)
# See end of this file for example usage
# Log Features:
# Can optionally log out to multiple outputs for each Log or Trace call.
# Logging includes source code line number
# Sets a maximum plugin log file size
# Stash Interface Features:
# Gets STASH_URL value from command line argument and/or from STDIN_READ
# Sets FRAGMENT_SERVER based on command line arguments or STDIN_READ
# Sets PLUGIN_ID based on the main script file name (in lower case)
# Gets PLUGIN_TASK_NAME value
# Sets pluginSettings (The plugin UI settings)
# Misc Features:
# Gets DRY_RUN value from command line argument and/or from UI and/or from config file
# Gets DEBUG_TRACING value from command line argument and/or from UI and/or from config file
# Sets RUNNING_IN_COMMAND_LINE_MODE to True if detects multiple arguments
# Sets CALLED_AS_STASH_PLUGIN to True if it's able to read from STDIN_READ
class StashPluginHelper(StashInterface):
# Primary Members for external reference
PLUGIN_TASK_NAME = None

View File

@@ -592,9 +592,9 @@ def start_library_monitor():
if lastScanJob['timeOutDelayProcess'] > MAX_TIMEOUT_FOR_DELAY_PATH_PROCESS:
lastScanJob['timeOutDelayProcess'] = MAX_TIMEOUT_FOR_DELAY_PATH_PROCESS
timeOutInSeconds = lastScanJob['timeOutDelayProcess']
stash.LogOnce(f"Awaiting file change-trigger, with a short timeout ({timeOutInSeconds} seconds), because of active delay path processing.")
stash.Log(f"Awaiting file change-trigger, with a short timeout ({timeOutInSeconds} seconds), because of active delay path processing.")
else:
stash.LogOnce(f"Waiting for a file change-trigger. Timeout = {timeOutInSeconds} seconds.")
stash.Log(f"Waiting for a file change-trigger. Timeout = {timeOutInSeconds} seconds.")
signal.wait(timeout=timeOutInSeconds)
if lastScanJob['DelayedProcessTargetPaths'] != []:
stash.TraceOnce(f"Processing delay scan for path(s) {lastScanJob['DelayedProcessTargetPaths']}")
@@ -727,7 +727,11 @@ def start_library_monitor_service():
args = args + ["-a", stash.API_KEY]
stash.ExecutePythonScript(args)
runTypeID=0
runTypeName=["NothingToDo", "stop_library_monitor", "StartFileMonitorAsAServiceTaskID", "StartFileMonitorAsAPluginTaskID", "CommandLineStartLibMonitor"]
try:
if parse_args.stop or parse_args.restart or stash.PLUGIN_TASK_NAME == "stop_library_monitor":
runTypeID=1
stop_library_monitor()
if parse_args.restart:
time.sleep(5)
@@ -736,20 +740,22 @@ if parse_args.stop or parse_args.restart or stash.PLUGIN_TASK_NAME == "stop_libr
else:
stash.Trace(f"Stop FileMonitor EXIT")
elif stash.PLUGIN_TASK_NAME == StartFileMonitorAsAServiceTaskID:
runTypeID=2
start_library_monitor_service()
stash.Trace(f"{StartFileMonitorAsAServiceTaskID} EXIT")
elif stash.PLUGIN_TASK_NAME == StartFileMonitorAsAPluginTaskID:
runTypeID=3
start_library_monitor()
stash.Trace(f"{StartFileMonitorAsAPluginTaskID} EXIT")
elif not stash.CALLED_AS_STASH_PLUGIN:
try:
runTypeID=4
start_library_monitor()
stash.Trace("Command line FileMonitor EXIT")
except Exception as e:
tb = traceback.format_exc()
stash.Error(f"Exception while running FileMonitor from the command line. Error: {e}\nTraceBack={tb}")
stash.log.exception('Got exception on main handler')
else:
stash.Log(f"Nothing to do!!! (stash.PLUGIN_TASK_NAME={stash.PLUGIN_TASK_NAME})")
except Exception as e:
tb = traceback.format_exc()
stash.Error(f"Exception while running FileMonitor. runType='{runTypeName[runTypeID]}'; Error: {e}\nTraceBack={tb}")
stash.log.exception('Got exception on main handler')
stash.Trace("\n*********************************\nEXITING ***********************\n*********************************")

View File

@@ -1,4 +1,4 @@
# RenameFile: Ver 0.4.6 (By David Maisonave)
# RenameFile: Ver 0.5.0 (By David Maisonave)
RenameFile is a [Stash](https://github.com/stashapp/stash) plugin which performs the following tasks.
- **Rename Scene File Name** (On-The-Fly)
- **Append tag names** to file name

View File

@@ -1,29 +1,31 @@
"""
StashPluginHelper (By David Maisonave aka Axter)
See end of this file for example usage
Log Features:
Can optionally log out to multiple outputs for each Log or Trace call.
Logging includes source code line number
Sets a maximum plugin log file size
Stash Interface Features:
Gets STASH_URL value from command line argument and/or from STDIN_READ
Sets FRAGMENT_SERVER based on command line arguments or STDIN_READ
Sets PLUGIN_ID based on the main script file name (in lower case)
Gets PLUGIN_TASK_NAME value
Sets pluginSettings (The plugin UI settings)
Misc Features:
Gets DRY_RUN value from command line argument and/or from UI and/or from config file
Gets DEBUG_TRACING value from command line argument and/or from UI and/or from config file
Sets RUNNING_IN_COMMAND_LINE_MODE to True if detects multiple arguments
Sets CALLED_AS_STASH_PLUGIN to True if it's able to read from STDIN_READ
"""
from stashapi.stashapp import StashInterface
from logging.handlers import RotatingFileHandler
import re, inspect, sys, os, pathlib, logging, json
import re, inspect, sys, os, pathlib, logging, json, ctypes
import concurrent.futures
from stashapi.stash_types import PhashDistance
import __main__
_ARGUMENT_UNSPECIFIED_ = "_ARGUMENT_UNSPECIFIED_"
# StashPluginHelper (By David Maisonave aka Axter)
# See end of this file for example usage
# Log Features:
# Can optionally log out to multiple outputs for each Log or Trace call.
# Logging includes source code line number
# Sets a maximum plugin log file size
# Stash Interface Features:
# Gets STASH_URL value from command line argument and/or from STDIN_READ
# Sets FRAGMENT_SERVER based on command line arguments or STDIN_READ
# Sets PLUGIN_ID based on the main script file name (in lower case)
# Gets PLUGIN_TASK_NAME value
# Sets pluginSettings (The plugin UI settings)
# Misc Features:
# Gets DRY_RUN value from command line argument and/or from UI and/or from config file
# Gets DEBUG_TRACING value from command line argument and/or from UI and/or from config file
# Sets RUNNING_IN_COMMAND_LINE_MODE to True if detects multiple arguments
# Sets CALLED_AS_STASH_PLUGIN to True if it's able to read from STDIN_READ
class StashPluginHelper(StashInterface):
# Primary Members for external reference
PLUGIN_TASK_NAME = None

View File

@@ -0,0 +1,187 @@
"""
openedFile (By David Maisonave aka Axter)
https://github.com/David-Maisonave/Axter-Stash/tree/main/plugins/RenameFile
Description:
Close all (open file) handles on all processes for a given file.
Use Case:
Can be used when a file needs to be deleted or moved,
but the file is locked by one or more other processes.
Requirements:
This class requires Sysinternals handle.exe, which can be downloaded from following link:
https://learn.microsoft.com/en-us/sysinternals/downloads/handle
Important: **MUST call this class in admin mode with elevated privileges!!!**
Example Usage:
handleExe = r"C:\Sysinternals\handle64.exe"
of = openedFile(handleExe)
of.closeFile(r"B:\V\V\testdup\deleme2.mp4")
"""
import ctypes, os, sys, psutil, argparse, traceback, logging, numbers, string
from ctypes import wintypes
# from StashPluginHelper import StashPluginHelper
# Look at the following links to enhance this code:
# https://stackoverflow.com/questions/35106511/how-to-access-the-peb-of-another-process-with-python-ctypes
# https://www.codeproject.com/Articles/19685/Get-Process-Info-with-NtQueryInformationProcess
# Important: MUST call this class in admin mode with elevated privileges!!!
# This class has member function runMeAsAdmin, which will elevate privileges.
# When member function closeFile is called, it will call runMeAsAdmin as needed.
# getPid is the only function which does NOT require elevated admin privileges.
class openedFile():
# generic strings and constants
ntdll = ctypes.WinDLL('ntdll')
kernel32 = ctypes.WinDLL('kernel32', use_last_error=True)
NTSTATUS = wintypes.LONG
INVALID_HANDLE_VALUE = wintypes.HANDLE(-1).value
FILE_READ_ATTRIBUTES = 0x80
FILE_SHARE_READ = 1
OPEN_EXISTING = 3
FILE_FLAG_BACKUP_SEMANTICS = 0x02000000
FILE_INFORMATION_CLASS = wintypes.ULONG
FileProcessIdsUsingFileInformation = 47 # see https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/wdm/ne-wdm-_file_information_class
LPSECURITY_ATTRIBUTES = wintypes.LPVOID
ULONG_PTR = wintypes.WPARAM
lastPath = None
handleExe = None
stash = None
def __init__(self, handleExe, stash = None):
self.handleExe = handleExe
self.stash = stash
if handleExe == None or handleExe == "" or not os.path.isfile(handleExe):
raise Exception(f"handleExe requires a valid path to Sysinternals 'handle.exe' or 'handle64.exe' executable. Can be downloaded from following link:\nhttps://learn.microsoft.com/en-us/sysinternals/downloads/handle")
# create handle on concerned file with dwDesiredAccess == self.FILE_READ_ATTRIBUTES
self.kernel32.CreateFileW.restype = wintypes.HANDLE
self.kernel32.CreateFileW.argtypes = (
wintypes.LPCWSTR, # In lpFileName
wintypes.DWORD, # In dwDesiredAccess
wintypes.DWORD, # In dwShareMode
self.LPSECURITY_ATTRIBUTES, # In_opt lpSecurityAttributes
wintypes.DWORD, # In dwCreationDisposition
wintypes.DWORD, # In dwFlagsAndAttributes
wintypes.HANDLE) # In_opt hTemplateFile
def getPid(self, path):
self.lastPath = path
hFile = self.kernel32.CreateFileW(
path, self.FILE_READ_ATTRIBUTES, self.FILE_SHARE_READ, None, self.OPEN_EXISTING,
self.FILE_FLAG_BACKUP_SEMANTICS, None)
if hFile == self.INVALID_HANDLE_VALUE:
raise ctypes.WinError(ctypes.get_last_error())
# prepare data types for system call
class IO_STATUS_BLOCK(ctypes.Structure):
class _STATUS(ctypes.Union):
_fields_ = (('Status', self.NTSTATUS),
('Pointer', wintypes.LPVOID))
_anonymous_ = '_Status',
_fields_ = (('_Status', _STATUS),
('Information', self.ULONG_PTR))
iosb = IO_STATUS_BLOCK()
class FILE_PROCESS_IDS_USING_FILE_INFORMATION(ctypes.Structure):
_fields_ = (('NumberOfProcessIdsInList', wintypes.LARGE_INTEGER),
('ProcessIdList', wintypes.LARGE_INTEGER * 64))
info = FILE_PROCESS_IDS_USING_FILE_INFORMATION()
PIO_STATUS_BLOCK = ctypes.POINTER(IO_STATUS_BLOCK)
self.ntdll.NtQueryInformationFile.restype = self.NTSTATUS
self.ntdll.NtQueryInformationFile.argtypes = (
wintypes.HANDLE, # In FileHandle
PIO_STATUS_BLOCK, # Out IoStatusBlock
wintypes.LPVOID, # Out FileInformation
wintypes.ULONG, # In Length
self.FILE_INFORMATION_CLASS) # In FileInformationClass
# system call to retrieve list of PIDs currently using the file
status = self.ntdll.NtQueryInformationFile(hFile, ctypes.byref(iosb),
ctypes.byref(info),
ctypes.sizeof(info),
self.FileProcessIdsUsingFileInformation)
pidList = info.ProcessIdList[0:info.NumberOfProcessIdsInList]
if len(pidList) > 0:
return pidList
return None
def isAdmin(self):
if os.name=='nt':
try:
return ctypes.windll.shell32.IsUserAnAdmin()
except:
return False
else:
return os.getuid() == 0 # For unix like systems
def runMeAsAdmin(self):
if self.isAdmin() == True:
return
if os.name=='nt':
# Below is a Windows only method which does NOT popup a console.
import win32com.shell.shell as shell # Requires: pip install pywin32
script = os.path.abspath(sys.argv[0])
params = ' '.join([script] + sys.argv[1:])
shell.ShellExecuteEx(lpVerb='runas', lpFile=sys.executable, lpParameters=params)
sys.exit(0)
else:
from elevate import elevate # Requires: pip install elevate
elevate()
def getPidExeFileName(self, pid): # Requires running with admin privileges.
import win32api, win32con, win32process
handle = win32api.OpenProcess(win32con.PROCESS_ALL_ACCESS, False, pid) #get handle for the pid
filename = win32process.GetModuleFileNameEx(handle, 0) #get exe path & filename for handle
return filename
def getFilesOpen(self, pid:int): # Requires running with admin privileges.
p = psutil.Process(pid1)
return p.open_files()
def getFileHandle(self, pid, path = None): # Requires running with admin privileges.
if path == None:
path = self.lastPath
args = f"{self.handleExe} -p {pid} -nobanner"
# if self.stash != None: self.stash.Log(args)
results = os.popen(args).read()
results = results.splitlines()
# if self.stash != None: self.stash.Log(results)
hdls = []
for line in results:
# if self.stash != None: self.stash.Log(line)
if line.endswith(path):
epos = line.find(":")
if epos > 0:
hdls += [line[0:epos]]
else:
break
if len(hdls) == 0:
return None
return hdls
def closeHandle(self, pid, fileHandle): # Requires running with admin privileges.
args = f"{self.handleExe} -p {pid} -c {fileHandle} -y -nobanner"
if self.stash != None: self.stash.Log(args)
results = os.popen(args).read()
results = results.strip("\n")
if results.endswith("Handle closed."):
return True
if self.stash != None: self.stash.Error(f"Could not close pid {pid} file handle {fileHandle}; results={results}")
return False
def closeFile(self, path): # Requires running with admin privileges.
pids = self.getPid(path)
if pids == None:
return None
# if self.stash != None: self.stash.Log(f"pids={pids}")
results = []
# Need admin privileges starting here.
self.runMeAsAdmin()
for pid in pids:
hdls = self.getFileHandle(pid, path)
if hdls == None:
# if self.stash != None: self.stash.Log(f"No handle for pid {pid}")
continue
else:
for hdl in hdls:
# if self.stash != None: self.stash.Log(f"pid {pid} hdl={hdl}")
results += [self.closeHandle(pid, hdl)]
if len(results) == 0:
return None
return {"results" : results, "pids" : pids}

View File

@@ -8,6 +8,7 @@ import stashapi.log as log # Importing stashapi.log as log for critical events O
from stashapi.stashapp import StashInterface
from StashPluginHelper import StashPluginHelper
from renamefile_settings import config # Import settings from renamefile_settings.py
from openedFile import openedFile
# **********************************************************************
# Constant global variables --------------------------------------------
@@ -76,6 +77,10 @@ tag_whitelist = config["tagWhitelist"]
if not tag_whitelist:
tag_whitelist = ""
stash.Trace(f"(tag_whitelist={tag_whitelist})")
handleExe = stash.pluginConfig['handleExe']
openedfile = None
if handleExe != None and handleExe != "" and os.path.isfile(handleExe):
openedfile = openedFile(handleExe, stash)
endpointHost = stash.JSON_INPUT['server_connection']['Host']
if endpointHost == "0.0.0.0":
@@ -299,6 +304,10 @@ def rename_scene(scene_id):
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)
@@ -340,12 +349,15 @@ def rename_files_task():
stash.Log("No changes were made.")
return
try:
if stash.PLUGIN_TASK_NAME == "rename_files_task":
rename_files_task()
elif inputToUpdateScenePost:
rename_files_task()
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*********************************")
# ToDo: Wish List
# Add code to get tags from duplicate filenames

View File

@@ -1,6 +1,6 @@
name: RenameFile
description: Renames video (scene) file names when the user edits the [Title] field located in the scene [Edit] tab.
version: 0.4.6
version: 0.5.0
url: https://github.com/David-Maisonave/Axter-Stash/tree/main/plugins/RenameFile
settings:
performerAppend:

View File

@@ -47,4 +47,16 @@ config = {
"if_notitle_use_org_filename": True, # Warning: Do not recommend setting this to False.
# Current Stash DB schema only allows maximum base file name length to be 255
"max_filename_length": 255,
# handleExe is for Windows only.
# In Windows, a file can't be renamed if the file is opened by another process.
# In other words, if a file is being played by Stash or any other video player, the RenameFile plugin
# will get an access denied error when trying to rename the file.
# As a workaround, the 'handleExe' field can be populated with a full path to handle.exe or handle64.exe.
# This executable can be downloaded from the following link:
# https://learn.microsoft.com/en-us/sysinternals/downloads/handle
# RenameFile can use the Handle.exe program to close all opened file handles by all processes before renaming the file.
#
# Warning: This feature can cause the process playing the video to crash.
"handleExe": r"C:\Sysinternals\handle64.exe", # https://learn.microsoft.com/en-us/sysinternals/downloads/handle
}