diff --git a/plugins/.gitignore b/plugins/.gitignore index 14e9df5..7d66bf7 100644 --- a/plugins/.gitignore +++ b/plugins/.gitignore @@ -34,6 +34,8 @@ renamefile_settings.cpython-310.pyc /DeleteMe /ATestPlugin /FileMonitor/working +test_script_hello_world.py +MyDummyFileFrom_test_script_hello_world.txt ## Ignore Visual Studio temporary files, build results, and ## files generated by popular Visual Studio add-ons. diff --git a/plugins/FileMonitor/README.md b/plugins/FileMonitor/README.md index aab9960..dee3c21 100644 --- a/plugins/FileMonitor/README.md +++ b/plugins/FileMonitor/README.md @@ -1,4 +1,4 @@ -# FileMonitor: Ver 0.7.7 (By David Maisonave) +# FileMonitor: Ver 0.7.8 (By David Maisonave) FileMonitor is a [Stash](https://github.com/stashapp/stash) plugin with the following two main features: - Updates Stash when any file changes occurs in the Stash library. - **Task Scheduler**: Runs scheduled task based on the scheduler configuration in filemonitor_config.py. diff --git a/plugins/FileMonitor/StashPluginHelper.py b/plugins/FileMonitor/StashPluginHelper.py index 08c9058..237b691 100644 --- a/plugins/FileMonitor/StashPluginHelper.py +++ b/plugins/FileMonitor/StashPluginHelper.py @@ -1,12 +1,7 @@ import stashapi.log as stashLog # stashapi.log by default for error and critical logging from stashapi.stashapp import StashInterface from logging.handlers import RotatingFileHandler -import inspect -import sys -import os -import pathlib -import logging -import json +import inspect, sys, os, pathlib, logging, json import __main__ # StashPluginHelper (By David Maisonave aka Axter) @@ -61,7 +56,7 @@ class StashPluginHelper: STDIN_READ = None FRAGMENT_SERVER = None logger = None - traceOncePreviousHits = [] + logLinePreviousHits = [] # Prefix message value LEV_TRACE = "TRACE: " @@ -218,7 +213,7 @@ class StashPluginHelper: def Trace(self, logMsg = "", printTo = 0, logAlways = False, lineNo = -1): if printTo == 0: printTo = self.LOG_TO_FILE if lineNo == -1: - lineNo = lineNo = inspect.currentframe().f_back.f_lineno + lineNo = inspect.currentframe().f_back.f_lineno logLev = logging.INFO if logAlways else logging.DEBUG if self.DEBUG_TRACING or logAlways: if logMsg == "": @@ -227,28 +222,24 @@ class StashPluginHelper: # Log once per session. Only logs the first time called from a particular line number in the code. def TraceOnce(self, logMsg = "", printTo = 0, logAlways = False): - if printTo == 0: printTo = self.LOG_TO_FILE lineNo = inspect.currentframe().f_back.f_lineno - logLev = logging.INFO if logAlways else logging.DEBUG if self.DEBUG_TRACING or logAlways: FuncAndLineNo = f"{inspect.currentframe().f_back.f_code.co_name}:{lineNo}" - if FuncAndLineNo in self.traceOncePreviousHits: + if FuncAndLineNo in self.logLinePreviousHits: return - self.traceOncePreviousHits.append(FuncAndLineNo) - if logMsg == "": - logMsg = f"Line number {lineNo}..." - self.Log(logMsg, printTo, logLev, lineNo) + self.logLinePreviousHits.append(FuncAndLineNo) + self.Trace(logMsg, printTo, logAlways, lineNo) # Log INFO on first call, then do Trace on remaining calls. def LogOnce(self, logMsg = "", printTo = 0, logAlways = False, traceOnRemainingCalls = True): if printTo == 0: printTo = self.LOG_TO_FILE lineNo = inspect.currentframe().f_back.f_lineno FuncAndLineNo = f"{inspect.currentframe().f_back.f_code.co_name}:{lineNo}" - if FuncAndLineNo in self.traceOncePreviousHits: + if FuncAndLineNo in self.logLinePreviousHits: if traceOnRemainingCalls: self.Trace(logMsg, printTo, logAlways, lineNo) else: - self.traceOncePreviousHits.append(FuncAndLineNo) + self.logLinePreviousHits.append(FuncAndLineNo) self.Log(logMsg, printTo, logging.INFO, lineNo) def Warn(self, logMsg, printTo = 0): @@ -268,6 +259,26 @@ class StashPluginHelper: self.Log(f"StashPluginHelper Status: (CALLED_AS_STASH_PLUGIN={self.CALLED_AS_STASH_PLUGIN}), (RUNNING_IN_COMMAND_LINE_MODE={self.RUNNING_IN_COMMAND_LINE_MODE}), (DEBUG_TRACING={self.DEBUG_TRACING}), (DRY_RUN={self.DRY_RUN}), (PLUGIN_ID={self.PLUGIN_ID}), (PLUGIN_TASK_NAME={self.PLUGIN_TASK_NAME}), (STASH_URL={self.STASH_URL}), (MAIN_SCRIPT_NAME={self.MAIN_SCRIPT_NAME})", printTo, logLevel, lineNo) + def ExecuteProcess(self, args): + import platform, subprocess + is_windows = any(platform.win32_ver()) + pid = None + self.Trace(f"is_windows={is_windows} args={args}") + if is_windows: + self.Trace("Executing process using Windows DETACHED_PROCESS") + DETACHED_PROCESS = 0x00000008 + pid = subprocess.Popen(args,creationflags=DETACHED_PROCESS, shell=True).pid + else: + self.Trace("Executing process using normal Popen") + pid = subprocess.Popen(args).pid + self.Trace(f"pid={pid}") + return pid + + def ExecutePythonScript(self, args): + PythonExe = f"{sys.executable}" + argsWithPython = [f"{PythonExe}"] + args + return self.ExecuteProcess(argsWithPython) + # Extends class StashInterface with functions which are not yet in the class class ExtendStashInterface(StashInterface): def metadata_autotag(self, paths:list=[], dry_run=False): diff --git a/plugins/FileMonitor/filemonitor.py b/plugins/FileMonitor/filemonitor.py index efd6bd4..d22183d 100644 --- a/plugins/FileMonitor/filemonitor.py +++ b/plugins/FileMonitor/filemonitor.py @@ -31,8 +31,8 @@ if parse_args.quit: settings = { "recursiveDisabled": False, "turnOnScheduler": False, - "zzdebugTracing": False, - "zzdryRun": False, + "zmaximumBackups": 0, + "zzdebugTracing": False } plugin = StashPluginHelper( stash_url=parse_args.stash_url, @@ -79,7 +79,7 @@ if plugin.DRY_RUN: plugin.Log("Dry run mode is enabled.") plugin.Trace(f"(SCAN_MODIFIED={SCAN_MODIFIED}) (SCAN_ON_ANY_EVENT={SCAN_ON_ANY_EVENT}) (RECURSIVE={RECURSIVE})") -StartFileMonitorAsAPluginTaskName = "Run as a Plugin" +StartFileMonitorAsAPluginTaskName = "Monitor as a Plugin" StartFileMonitorAsAServiceTaskName = "Start Library Monitor Service" FileMonitorPluginIsOnTaskQue = plugin.CALLED_AS_STASH_PLUGIN StopLibraryMonitorWaitingInTaskQueue = False @@ -105,88 +105,105 @@ def isJobWaitingToRun(): if plugin.CALLED_AS_STASH_PLUGIN: plugin.Trace(f"isJobWaitingToRun() = {isJobWaitingToRun()})") - -def trimDbFiles(dbPath, maxFiles): - if not os.path.exists(dbPath) or len(dbPath) < 5: # For safety and security, short path not supported. - return - dbFiles = sorted(os.listdir(dbPath)) - n = len(dbFiles) - for i in range(0, n-maxFiles): - dbFilePath = f"{dbPath}{os.sep}{dbFiles[i]}" - plugin.Log(f"Removing file {dbFilePath}") - os.remove(dbFilePath) -# Reoccurring scheduler code -# ToDo: Change the following functions into a class called reoccurringScheduler -def runTask(task): - import datetime - plugin.Trace(f"Running task {task}") - if 'monthly' in task: - dayOfTheMonth = datetime.datetime.today().day - FirstAllowedDate = ((task['monthly'] - 1) * 7) + 1 - LastAllowedDate = task['monthly'] * 7 - if dayOfTheMonth < FirstAllowedDate or dayOfTheMonth > LastAllowedDate: - plugin.Log(f"Skipping task {task['task']} because today is not the right {task['weekday']} of the month. Target range is between {FirstAllowedDate} and {LastAllowedDate}.") +class StashScheduler: # Stash Scheduler + def __init__(self): + import schedule # pip install schedule # https://github.com/dbader/schedule + dayOfTheWeek = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"] + for task in plugin.pluginConfig['task_reoccurring_scheduler']: + if 'hours' in task and task['hours'] > 0: + plugin.Log(f"Adding to reoccurring scheduler task '{task['task']}' at {task['hours']} hours interval") + schedule.every(task['hours']).hours.do(self.runTask, task) + elif 'minutes' in task and task['minutes'] > 0: + plugin.Log(f"Adding to reoccurring scheduler task '{task['task']}' at {task['minutes']} minutes interval") + schedule.every(task['minutes']).minutes.do(self.runTask, task) + elif 'days' in task and task['days'] > 0: # Left here for backward compatibility, but should use weekday logic instead. + plugin.Log(f"Adding to reoccurring scheduler task '{task['task']}' at {task['days']} days interval") + schedule.every(task['days']).days.do(self.runTask, task) + elif 'weekday' in task and task['weekday'].lower() in dayOfTheWeek and 'time' in task: + if 'monthly' in task: + plugin.Log(f"Adding to reoccurring scheduler task '{task['task']}' monthly on number {task['monthly']} {task['weekday']} at {task['time']}") + else: + plugin.Log(f"Adding to reoccurring scheduler task '{task['task']}' (weekly) every {task['weekday']} at {task['time']}") + if task['weekday'].lower() == "monday": + schedule.every().monday.at(task['time']).do(self.runTask, task) + elif task['weekday'].lower() == "tuesday": + schedule.every().tuesday.at(task['time']).do(self.runTask, task) + elif task['weekday'].lower() == "wednesday": + schedule.every().wednesday.at(task['time']).do(self.runTask, task) + elif task['weekday'].lower() == "thursday": + schedule.every().thursday.at(task['time']).do(self.runTask, task) + elif task['weekday'].lower() == "friday": + schedule.every().friday.at(task['time']).do(self.runTask, task) + elif task['weekday'].lower() == "saturday": + schedule.every().saturday.at(task['time']).do(self.runTask, task) + elif task['weekday'].lower() == "sunday": + schedule.every().sunday.at(task['time']).do(self.runTask, task) + self.checkSchedulePending() + + def runTask(self, task): + import datetime + plugin.Trace(f"Running task {task}") + if 'monthly' in task: + dayOfTheMonth = datetime.datetime.today().day + FirstAllowedDate = ((task['monthly'] - 1) * 7) + 1 + LastAllowedDate = task['monthly'] * 7 + if dayOfTheMonth < FirstAllowedDate or dayOfTheMonth > LastAllowedDate: + plugin.Log(f"Skipping task {task['task']} because today is not the right {task['weekday']} of the month. Target range is between {FirstAllowedDate} and {LastAllowedDate}.") + return + if task['task'] == "Clean": + plugin.STASH_INTERFACE.metadata_clean(paths=stashPaths, dry_run=plugin.DRY_RUN) + elif task['task'] == "Generate": + plugin.STASH_INTERFACE.metadata_generate() + elif task['task'] == "Backup": + plugin.LogOnce("Note: Backup task does not get listed in the Task Queue, but user can verify that it started by looking in the Stash log file as an INFO level log line.") + plugin.STASH_INTERFACE.backup_database() + if plugin.pluginSettings['zmaximumBackups'] > 1 and 'backupDirectoryPath' in plugin.STASH_CONFIGURATION: + if len(plugin.STASH_CONFIGURATION['backupDirectoryPath']) > 4 and os.path.exists(plugin.STASH_CONFIGURATION['backupDirectoryPath']): + plugin.LogOnce(f"Checking quantity of DB backups if path {plugin.STASH_CONFIGURATION['backupDirectoryPath']} exceeds {plugin.pluginSettings['zmaximumBackups']} backup files.") + self.trimDbFiles(plugin.STASH_CONFIGURATION['backupDirectoryPath'], plugin.pluginSettings['zmaximumBackups']) + elif task['task'] == "Scan": + plugin.STASH_INTERFACE.metadata_scan(paths=stashPaths) + elif task['task'] == "Auto Tag": + plugin.STASH_INTERFACE.metadata_autotag(paths=stashPaths, dry_run=plugin.DRY_RUN) + elif task['task'] == "Optimise Database": + plugin.STASH_INTERFACE.optimise_database() + elif task['task'] == "python": + script = task['script'].replace("", f"{pathlib.Path(__file__).resolve().parent}{os.sep}") + plugin.Log(f"Executing python script {script}.") + args = [script] + if len(task['args']) > 0: + args = args + [task['args']] + plugin.ExecutePythonScript(args) + elif task['task'] == "execute": + cmd = task['command'].replace("", f"{pathlib.Path(__file__).resolve().parent}{os.sep}") + plugin.Log(f"Executing command {cmd}.") + args = [cmd] + if len(task['args']) > 0: + args = args + [task['args']] + plugin.ExecuteProcess(args) + else: + # ToDo: Add code to check if plugin is installed. + plugin.Trace(f"Running plugin task pluginID={task['pluginId']}, task name = {task['task']}") + plugin.STASH_INTERFACE.run_plugin_task(plugin_id=task['pluginId'], task_name=task['task']) + + def trimDbFiles(self, dbPath, maxFiles): + if not os.path.exists(dbPath): + plugin.LogOnce(f"Exiting trimDbFiles, because path {dbPath} does not exists.") return - if task['task'] == "Clean": - plugin.STASH_INTERFACE.metadata_clean(paths=stashPaths, dry_run=plugin.DRY_RUN) - elif task['task'] == "Generate": - plugin.STASH_INTERFACE.metadata_generate() - elif task['task'] == "Backup": - plugin.LogOnce("Note: Backup task does not get listed in the Task Queue, but user can verify that it started by looking in the Stash log file as an INFO level log line.") - plugin.STASH_INTERFACE.backup_database() - if plugin.pluginConfig['BackupsMax'] > 0 and plugin.pluginConfig['BackupDatabasePath'] != "" and os.path.exists(plugin.pluginConfig['BackupDatabasePath']): - plugin.Log("Checking quantity of DB backups.") - trimDbFiles(plugin.pluginConfig['BackupDatabasePath'], plugin.pluginConfig['BackupsMax']) - elif task['task'] == "Scan": - plugin.STASH_INTERFACE.metadata_scan(paths=stashPaths) - elif task['task'] == "Auto Tag": - plugin.STASH_INTERFACE.metadata_autotag(paths=stashPaths, dry_run=plugin.DRY_RUN) - elif task['task'] == "Optimise Database": - plugin.STASH_INTERFACE.optimise_database() - else: - # ToDo: Add code to check if plugin is installed. - plugin.Trace(f"Running plugin task pluginID={task['pluginId']}, task name = {task['task']}") - plugin.STASH_INTERFACE.run_plugin_task(plugin_id=task['pluginId'], task_name=task['task']) -def reoccurringScheduler(): - import schedule # pip install schedule # https://github.com/dbader/schedule - # ToDo: Extend schedule class so it works persistently (remember schedule between restarts) - # Or replace schedule with apscheduler https://github.com/agronholm/apscheduler - dayOfTheWeek = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"] - for task in plugin.pluginConfig['task_reoccurring_scheduler']: - if 'hours' in task and task['hours'] > 0: - plugin.Log(f"Adding to reoccurring scheduler task '{task['task']}' at {task['hours']} hours interval") - schedule.every(task['hours']).hours.do(runTask, task) - elif 'minutes' in task and task['minutes'] > 0: - plugin.Log(f"Adding to reoccurring scheduler task '{task['task']}' at {task['minutes']} minutes interval") - schedule.every(task['minutes']).minutes.do(runTask, task) - elif 'days' in task and task['days'] > 0: - plugin.Log(f"Adding to reoccurring scheduler task '{task['task']}' at {task['days']} days interval") - schedule.every(task['days']).days.do(runTask, task) - elif 'weekday' in task and task['weekday'].lower() in dayOfTheWeek and 'time' in task: - if 'monthly' in task: - plugin.Log(f"Adding to reoccurring scheduler task '{task['task']}' monthly on number {task['monthly']} {task['weekday']} at {task['time']}") - else: - plugin.Log(f"Adding to reoccurring scheduler task '{task['task']}' (weekly) every {task['weekday']} at {task['time']}") - if task['weekday'].lower() == "monday": - schedule.every().monday.at(task['time']).do(runTask, task) - elif task['weekday'].lower() == "tuesday": - schedule.every().tuesday.at(task['time']).do(runTask, task) - elif task['weekday'].lower() == "wednesday": - schedule.every().wednesday.at(task['time']).do(runTask, task) - elif task['weekday'].lower() == "thursday": - schedule.every().thursday.at(task['time']).do(runTask, task) - elif task['weekday'].lower() == "friday": - schedule.every().friday.at(task['time']).do(runTask, task) - elif task['weekday'].lower() == "saturday": - schedule.every().saturday.at(task['time']).do(runTask, task) - elif task['weekday'].lower() == "sunday": - schedule.every().sunday.at(task['time']).do(runTask, task) -def checkSchedulePending(): - import schedule # pip install schedule # https://github.com/dbader/schedule - schedule.run_pending() -if plugin.pluginSettings['turnOnScheduler']: - reoccurringScheduler() + if len(dbPath) < 5: # For safety and security, short path not supported. + plugin.LogOnce(f"Exiting trimDbFiles, because path {dbPath} is to short. Len={len(dbPath)}. Path string must be at least 5 characters in length.") + return + dbFiles = sorted(os.listdir(dbPath)) + n = len(dbFiles) + for i in range(0, n-maxFiles): + dbFilePath = f"{dbPath}{os.sep}{dbFiles[i]}" + plugin.Warn(f"Deleting file {dbFilePath}") + os.remove(dbFilePath) + + def checkSchedulePending(self): + import schedule # pip install schedule # https://github.com/dbader/schedule + schedule.run_pending() def start_library_monitor(): global shouldUpdate @@ -203,7 +220,7 @@ def start_library_monitor(): shm_buffer[0] = CONTINUE_RUNNING_SIG plugin.Trace(f"Shared memory map opended, and flag set to {shm_buffer[0]}") RunCleanMetadata = False - + stashScheduler = StashScheduler() if plugin.pluginSettings['turnOnScheduler'] else None event_handler = watchdog.events.FileSystemEventHandler() def on_created(event): global shouldUpdate @@ -294,7 +311,7 @@ def start_library_monitor(): plugin.Log(f"Breaking out of loop. (shm_buffer[0]={shm_buffer[0]})") break if plugin.pluginSettings['turnOnScheduler']: - checkSchedulePending() + stashScheduler.checkSchedulePending() plugin.LogOnce("Waiting for a file change-trigger.") signal.wait(timeout=SIGNAL_TIMEOUT) if plugin.pluginSettings['turnOnScheduler'] and not shouldUpdate: @@ -376,8 +393,6 @@ def stop_library_monitor(): shm_a.unlink() # Call unlink only once to release the shared memory def start_library_monitor_service(): - import subprocess - import platform # First check if FileMonitor is already running try: shm_a = shared_memory.SharedMemory(name=SHAREDMEMORY_NAME, create=False, size=4) @@ -387,20 +402,9 @@ def start_library_monitor_service(): return except: pass - plugin.Trace("FileMonitor is not running, so safe to start it as a service.") - is_windows = any(platform.win32_ver()) - PythonExe = f"{sys.executable}" - # PythonExe = PythonExe.replace("python.exe", "pythonw.exe") - args = [f"{PythonExe}", f"{pathlib.Path(__file__).resolve().parent}{os.sep}filemonitor.py", '--url', f"{plugin.STASH_URL}"] - plugin.Trace(f"args={args}") - if is_windows: - plugin.Trace("Executing process using Windows DETACHED_PROCESS") - DETACHED_PROCESS = 0x00000008 - pid = subprocess.Popen(args,creationflags=DETACHED_PROCESS, shell=True).pid - else: - plugin.Trace("Executing process using normal Popen") - pid = subprocess.Popen(args).pid - plugin.Trace(f"pid={pid}") + plugin.Trace("FileMonitor is not running, so it's safe to start it as a service.") + args = [f"{pathlib.Path(__file__).resolve().parent}{os.sep}filemonitor.py", '--url', f"{plugin.STASH_URL}"] + plugin.ExecutePythonScript(args) if parse_args.stop or parse_args.restart or plugin.PLUGIN_TASK_NAME == "stop_library_monitor": stop_library_monitor() diff --git a/plugins/FileMonitor/filemonitor.yml b/plugins/FileMonitor/filemonitor.yml index ac432c4..7a06bd5 100644 --- a/plugins/FileMonitor/filemonitor.yml +++ b/plugins/FileMonitor/filemonitor.yml @@ -1,6 +1,6 @@ name: FileMonitor description: Monitors the Stash library folders, and updates Stash if any changes occurs in the Stash library paths. -version: 0.7.7 +version: 0.7.8 url: https://github.com/David-Maisonave/Axter-Stash/tree/main/plugins/FileMonitor settings: recursiveDisabled: @@ -11,13 +11,13 @@ settings: displayName: Scheduler description: Enable to turn on the scheduler. See filemonitor_config.py for more details. type: BOOLEAN + zmaximumBackups: + displayName: Max DB Backups + description: When value greater than 1, will trim the number of database backup files to set value. Requires [Scheduler] enabled and backupDirectoryPath populated with path length longer than 4. + type: NUMBER zzdebugTracing: displayName: Debug Tracing - description: (Default=false) [***For Advanced Users***] Enable debug tracing. When enabled, additional tracing logging is added to Stash\plugins\FileMonitor\filemonitor.log - type: BOOLEAN - zzdryRun: - displayName: Dry Run - description: Enable to run script in [Dry Run] mode. In this mode, Stash does NOT call meta_scan, and only logs the action it would have taken. + description: Enable debug tracing. When enabled, additional tracing logging is added to Stash\plugins\FileMonitor\filemonitor.log type: BOOLEAN exec: - python @@ -25,14 +25,14 @@ exec: interface: raw tasks: - name: Start Library Monitor Service - description: Run as a SERVICE to monitors paths in Stash library for media file changes, and updates Stash. Recommended start method. + description: Run [Library Monitor] as a SERVICE to update Stash with any media file changes. defaultArgs: mode: start_library_monitor_service - name: Stop Library Monitor description: Stops library monitoring within 2 minute. defaultArgs: mode: stop_library_monitor - - name: Run as a Plugin + - name: Monitor as a Plugin description: Run [Library Monitor] as a plugin (*not recommended method*) defaultArgs: mode: start_library_monitor diff --git a/plugins/FileMonitor/filemonitor_config.py b/plugins/FileMonitor/filemonitor_config.py index 30f590c..5aebe15 100644 --- a/plugins/FileMonitor/filemonitor_config.py +++ b/plugins/FileMonitor/filemonitor_config.py @@ -42,16 +42,17 @@ config = { # Example monthly method. {"task" : "Backup", "weekday" : "sunday", "time" : "01:00", "monthly" : 2}, # Backup -> [Backup] 2nd sunday of the month at 1AM (01:00) - # The following is a place holder for a plugin. + # Example task for calling another Stash plugin, which needs plugin name and plugin ID. {"task" : "PluginButtonName_Here", "pluginId" : "PluginId_Here", "hours" : 0}, # The zero frequency value makes this task disabled. # Add additional plugin task here. + + # Example task to call a python script + {"task" : "python", "script" : "test_script_hello_world.py", "args" : "--MyArguments Hello", "minutes" : 0}, + + # Example task to execute a command + {"task" : "execute", "command" : "C:\\MyPath\\HelloWorld.bat", "args" : "", "hours" : 0}, ], - # Maximum backups to keep. When scheduler is enabled, and the Backup runs, delete older backups after reaching maximum backups. - "BackupsMax" : 12, # Only works if BackupDatabasePath is properly populated. - # The BACKUP database path. ToDo: Implement code to automate fetching this value - "BackupDatabasePath" : "C:\\Users\\admin3\\.stash\\DbBackup", # Example populated path - # When enabled, if CREATE flag is triggered, DupFileManager task is called if the plugin is installed. "onCreateCallDupFileManager": False, # Not yet implemented!!!!