mirror of
https://github.com/stashapp/CommunityScripts.git
synced 2025-12-11 14:55:10 -06:00
My bad, when I merged this into the repo on Oct 19th, I missed this deprecated stashapi call, and the stashapi change had taken effect on Oct 11th, so... bad timing. Luckily it's a one line fix.
241 lines
8.6 KiB
Python
241 lines
8.6 KiB
Python
#!/usr/bin/python -w
|
|
import argparse
|
|
import configparser
|
|
import time
|
|
import os
|
|
from threading import Lock, Condition
|
|
from watchdog.observers import Observer
|
|
from watchdog.observers.polling import PollingObserver
|
|
from watchdog.events import PatternMatchingEventHandler
|
|
from stashapi.stashapp import StashInterface
|
|
import logging
|
|
import sys
|
|
from enum import Enum
|
|
|
|
#the type of watcher being used; controls how to interpret the events
|
|
WatcherType = Enum('WatcherType', ['INOTIFY', 'WINDOWS', 'POLLING', 'KQUEUE'])
|
|
|
|
#Setup logger
|
|
logger = logging.getLogger("stash-watcher")
|
|
logger.setLevel(logging.INFO)
|
|
ch = logging.StreamHandler()
|
|
ch.setLevel(logging.INFO)
|
|
ch.setFormatter(logging.Formatter("%(asctime)s %(message)s"))
|
|
logger.addHandler(ch)
|
|
|
|
#This signals that we should
|
|
shouldUpdate = False
|
|
mutex = Lock()
|
|
signal = Condition(mutex)
|
|
|
|
modifiedFiles = {}
|
|
|
|
|
|
currentWatcherType = None
|
|
|
|
|
|
def log(msg):
|
|
logger.info(msg)
|
|
|
|
def debug(msg):
|
|
logger.debug(msg)
|
|
|
|
def handleEvent(event):
|
|
global shouldUpdate
|
|
global currentWatcherType
|
|
debug("========EVENT========")
|
|
debug(str(event))
|
|
#log(modifiedFiles)
|
|
#Record if the file was modified. When a file is closed, see if it was modified. If so, trigger
|
|
shouldTrigger = False
|
|
|
|
if event.is_directory == True:
|
|
return
|
|
#Depending on the watcher type, we have to handle these events differently
|
|
if currentWatcherType == WatcherType.WINDOWS:
|
|
#On windows here's what happens:
|
|
# File moved into a watched directory - Created Event
|
|
# File moved out of a watched directory - Deleted Event
|
|
# Moved within a watched directory (src and dst in watched directory) - Moved event
|
|
|
|
# echo blah > foo.mp4 - Created then Modified
|
|
# copying a small file - Created then Modified
|
|
# copying a large file - Created then two (or more) Modified events (appears to be one when the file is created and another when it's finished)
|
|
|
|
#It looks like you can get an optional Created Event and then
|
|
#either one or two Modified events. You can also get Moved events
|
|
|
|
#For local files on Windows, they can't be opened if they're currently
|
|
#being written to. Therefore, every time we get an event, attempt to
|
|
#open the file. If we're successful, assume the write is finished and
|
|
#trigger the update. Otherwise wait until the next event and try again
|
|
if event.event_type == "created" or event.event_type == "modified":
|
|
try:
|
|
with open(event.src_path) as file:
|
|
debug("Successfully opened file; triggering")
|
|
shouldTrigger = True
|
|
except:
|
|
pass
|
|
|
|
if event.event_type == "moved":
|
|
shouldTrigger = True
|
|
elif currentWatcherType == WatcherType.POLLING:
|
|
#Every interval you get 1 event per changed file
|
|
# - If the file was not present in the previous poll, then Created
|
|
# - If the file was present and has a new size, then Modified
|
|
# - If the file was moved within the directory, then Moved
|
|
# - If the file is gone, then deleted
|
|
#
|
|
# For now, just trigger on the created event. In the future, create
|
|
# a timer at 2x polling interval. Reschedule the timer on each event
|
|
# when it fires, trigger the update.
|
|
if event.event_type == "moved" or event.event_type == "created":
|
|
shouldTrigger = True
|
|
#Until someone tests this on mac, just do what INOTIFY does
|
|
elif currentWatcherType == WatcherType.INOTIFY or currentWatcherType == WatcherType.KQUEUE:
|
|
if event.event_type == "modified":
|
|
modifiedFiles[event.src_path] = 1
|
|
#These are for files being copied into the target
|
|
elif event.event_type == "closed":
|
|
if event.src_path in modifiedFiles:
|
|
del modifiedFiles[event.src_path]
|
|
shouldTrigger = True
|
|
#For download managers and the like that write to a temporary file and then move to the destination (real)
|
|
#path. Note that this actually triggers if the destination is in the watched location, and not just if it's
|
|
#moved out of a watched directory
|
|
elif event.event_type == "moved":
|
|
shouldTrigger = True
|
|
else:
|
|
print("Unknown watcher type " + str(currentWatcherType))
|
|
sys.exit(1)
|
|
|
|
#Trigger the update
|
|
if shouldTrigger:
|
|
debug("Triggering updates")
|
|
with mutex:
|
|
shouldUpdate = True
|
|
signal.notify()
|
|
|
|
|
|
def main(stash, scanFlags, paths, extensions, timeout, pollInterval):
|
|
global shouldUpdate
|
|
global currentWatcherType
|
|
|
|
if len(extensions) == 1 and extensions[0] == "*":
|
|
patterns = ["*"]
|
|
else:
|
|
patterns = list(map(lambda x : "*." + x, extensions))
|
|
eventHandler = PatternMatchingEventHandler(patterns, None, False, True)
|
|
eventHandler.on_any_event = handleEvent
|
|
observer = Observer()
|
|
observerName = type(observer).__name__
|
|
if pollInterval != None and pollInterval > 0:
|
|
currentWatcherType = WatcherType.POLLING
|
|
observer = PollingObserver()
|
|
elif observerName == "WindowsApiObserver":
|
|
currentWatcherType = WatcherType.WINDOWS
|
|
elif observerName == "KqueueObserver":
|
|
currentWatcherType = WatcherType.KQUEUE
|
|
elif observerName == "InotifyObserver":
|
|
currentWatcherType = WatcherType.INOTIFY
|
|
else:
|
|
print("Unknown watcher type " + str(observer))
|
|
sys.exit(1)
|
|
|
|
debug(str(observer))
|
|
for path in paths:
|
|
observer.schedule(eventHandler, path, recursive=True)
|
|
observer.start()
|
|
try:
|
|
while True:
|
|
with mutex:
|
|
while not shouldUpdate:
|
|
signal.wait()
|
|
shouldUpdate = False
|
|
log("Triggering stash scan")
|
|
stash.metadata_scan(flags = scanFlags)
|
|
log("Sleeping for " + str(timeout) + " seconds")
|
|
time.sleep(timeout)
|
|
except KeyboardInterrupt:
|
|
observer.stop()
|
|
observer.join()
|
|
|
|
def listConverter(item):
|
|
debug("listConverter(" + str(item) + ")")
|
|
if not item:
|
|
return None
|
|
listItems = [i.strip() for i in item.split(',')]
|
|
if not listItems or (len(listItems) == 1 and not listItems[0]):
|
|
return None
|
|
return listItems
|
|
|
|
def makeArgParser():
|
|
parser = argparse.ArgumentParser(description='Stash file watcher')
|
|
parser.add_argument('config_path', nargs=1, help='Config file path (toml)')
|
|
return parser
|
|
|
|
def parseConfig(path):
|
|
config = configparser.ConfigParser(converters={'list': listConverter })
|
|
|
|
|
|
#Load the defaults first
|
|
defaults_path = os.path.join(os.path.dirname('__file__'), 'defaults.toml')
|
|
config.read(defaults_path)
|
|
|
|
#Now read the user config
|
|
config.read(path)
|
|
|
|
return config
|
|
|
|
if __name__ == '__main__':
|
|
#Parse the arguments
|
|
parser = makeArgParser()
|
|
args = parser.parse_args()
|
|
configPath = args.config_path
|
|
config = parseConfig(configPath)
|
|
|
|
#Set up Stash
|
|
stashArgs = {
|
|
"scheme": config["Host"]["Scheme"],
|
|
"host": config["Host"]["Host"],
|
|
"port": config["Host"]["Port"]
|
|
}
|
|
|
|
if config["Host"]["ApiKey"]:
|
|
stashArgs["ApiKey"] = config["Host"]["ApiKey"]
|
|
|
|
stash = StashInterface(stashArgs)
|
|
|
|
#And now the flags for the scan
|
|
scanFlags = {
|
|
"scanGenerateCovers": config["ScanOptions"].getboolean("Covers"),
|
|
"scanGeneratePreviews": config["ScanOptions"].getboolean("Previews"),
|
|
"scanGenerateImagePreviews": config["ScanOptions"].getboolean("ImagePreviews"),
|
|
"scanGenerateSprites": config["ScanOptions"].getboolean("Sprites"),
|
|
"scanGeneratePhashes": config["ScanOptions"].getboolean("Phashes"),
|
|
"scanGenerateThumbnails": config["ScanOptions"].getboolean("Thumbnails"),
|
|
"scanGenerateClipPreviews": config["ScanOptions"].getboolean("ClipPreviews")
|
|
}
|
|
|
|
paths = config.getlist("Config", "Paths")
|
|
timeout = config["Config"].getint("Cooldown")
|
|
|
|
#If the extensions are in the config, use them. Otherwise pull them from stash.
|
|
extensions = config.getlist('Config', 'Extensions')
|
|
if not extensions:
|
|
stashConfig = stash.get_configuration()
|
|
extensions = stashConfig['general']['videoExtensions'] + stashConfig['general']['imageExtensions'] + stashConfig['general']['galleryExtensions']
|
|
|
|
pollIntervalStr = config.get('Config', 'PollInterval')
|
|
if pollIntervalStr:
|
|
pollInterval = int(pollIntervalStr)
|
|
else:
|
|
pollInterval = None
|
|
|
|
if config.get('Config', 'Debug') == "true":
|
|
logger.setLevel(logging.DEBUG)
|
|
ch.setLevel(logging.DEBUG)
|
|
|
|
|
|
main(stash, scanFlags, paths, extensions, timeout, pollInterval)
|