diff --git a/plugins/RenameFile/.gitignore b/plugins/RenameFile/.gitignore new file mode 100644 index 0000000..7511ba8 --- /dev/null +++ b/plugins/RenameFile/.gitignore @@ -0,0 +1,523 @@ +$ cat .gitignore + +# Ignore these patterns +desktop.ini +~AutoRecover*.* +*.aps +*.exe +*.idb +*.ipch +*.lib +*.log +*.manifest +*.obj +*.pch +*.pdb +*.sdf +*.suo +*.tlog +*.user +*.7z +*.swp +*.zip +data.csv +/boost +/scintilla +/bin +/SQL +/__pycache__ +__pycache__/ +renamefile_settings.cpython-310.pyc + +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# Tye +.tye/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*_i.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +node_modules/ +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# JetBrains Rider +.idea/ +*.sln.iml + +# CodeRush +.cr/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +## +## Visual studio for Mac +## + + +# globs +Makefile.in +*.userprefs +*.usertasks +config.make +config.status +aclocal.m4 +install-sh +autom4te.cache/ +*.tar.gz +tarballs/ +test-results/ + +# Mac bundle stuff +*.dmg +*.app + +# content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# JetBrains Rider +.idea/ +*.sln.iml + +## +## Visual Studio Code +## +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# Other miscellaneous folders +zzMiscellaneous/ +zzExcludeFromGithub/ +FromAnotherLanuageKit/ +_BadLanguages/ + +# Exclude test data and temp files +Test_Data/ +*__ExcludeFromRepo__*.* +*__DoNotAddToRepo__*.* +deleteme/ +RelatedProjects/ +obj/ + +# Exclude temp and backup files +*.bak + +# ########################################### +# Unique to this project +# ########################################### +# Exclude reparsepoint files which are used to help view file using VS +*.xaml.xml +gitignore.txt + +GTranslate/obj/ diff --git a/plugins/RenameFile/__pycache__/renamefile_settings.cpython-310.pyc b/plugins/RenameFile/__pycache__/renamefile_settings.cpython-310.pyc index 09e634e..62934f2 100644 Binary files a/plugins/RenameFile/__pycache__/renamefile_settings.cpython-310.pyc and b/plugins/RenameFile/__pycache__/renamefile_settings.cpython-310.pyc differ diff --git a/plugins/RenameFile/renamefile.py b/plugins/RenameFile/renamefile.py index 7cd0f10..3c6d560 100644 --- a/plugins/RenameFile/renamefile.py +++ b/plugins/RenameFile/renamefile.py @@ -4,6 +4,9 @@ import logging import shutil from pathlib import Path import hashlib +import json +import sys +from stashapi.stashapp import StashInterface # 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. @@ -18,10 +21,81 @@ script_dir = Path(__file__).resolve().parent # Configure logging for your script log_file_path = script_dir / 'renamefile.log' -logging.basicConfig(filename=log_file_path, level=logging.INFO, format='%(asctime)s - %(message)s') +FORMAT = "[%(asctime)s - LN:%(lineno)s - %(funcName)20s()] %(message)s" +logging.basicConfig(filename=log_file_path, level=logging.INFO, format=FORMAT) logger = logging.getLogger('renamefile') +DEFAULT_ENDPOINT = "http://localhost:9999/graphql" # Default GraphQL endpoint +DEFAULT_FIELD_KEY_LIST = "title, performers, tags" # Default Field Key List with the desired order +DEFAULT_SEPERATOR = "-" + +# ------------------------------------------ +# ------------------------------------------ +# Code to fetch variables from Plugin UI +json_input = json.loads(sys.stdin.read()) +FRAGMENT_SERVER = json_input["server_connection"] +stash = StashInterface(FRAGMENT_SERVER) +pluginConfiguration = stash.get_configuration()["plugins"] +settings = { + "dryRun": False, + "fileRenameViaMove": False, + "performerAppend": False, + "performerIncludeInFileName": False, + "tagAppend": False, + "tagIncludeInFileName": False, + "zFieldKeyList": DEFAULT_FIELD_KEY_LIST, + "zgraphqlEndpoint": DEFAULT_ENDPOINT, + "zmaximumTagKeys": 12, + "zpathToExclude": "", + "zseparators": DEFAULT_SEPERATOR, + "ztagWhitelist": "", + "zzdebugTracing": False, +} +if "renamefile" in pluginConfiguration: + settings.update(pluginConfiguration["renamefile"]) +logger.info("settings: %s " % (settings,)) +# ------------------------------------------ +debugTracing = settings["zzdebugTracing"] +logger.info(f"\n\nStarting (debugTracing={debugTracing})************************************************\n") + +# Extract dry_run setting from settings +dry_run = settings["dryRun"] +dry_run_prefix = '' +if debugTracing: logger.info("Debug Tracing................") +if dry_run: + logger.info("Dry run mode is enabled.") + dry_run_prefix = "Would've " +if debugTracing: logger.info("Debug Tracing................") +max_tag_keys = settings["zmaximumTagKeys"] if settings["zmaximumTagKeys"] != 0 else 12 # Need this incase use explicitly sets value to zero in UI +if debugTracing: logger.info("Debug Tracing................") +# ToDo: Add split logic here to slpit possible string array into an array +exclude_paths = settings["zpathToExclude"] +exclude_paths = exclude_paths.split() +if debugTracing: logger.info(f"Debug Tracing (exclude_paths={exclude_paths})................") +# Extract tag whitelist from settings +tag_whitelist = settings["ztagWhitelist"] +if debugTracing: logger.info("Debug Tracing................") +if not tag_whitelist: + tag_whitelist = "" +endpoint = settings["zgraphqlEndpoint"] # GraphQL endpoint +if debugTracing: logger.info("Debug Tracing................") +if not endpoint or endpoint == "": + endpoint = DEFAULT_ENDPOINT +# Extract rename_files and move_files settings from renamefile_settings.py +rename_files = config["rename_files"] +move_files = settings["fileRenameViaMove"] +if debugTracing: logger.info("Debug Tracing................") +fieldKeyList = settings["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(",") +if debugTracing: logger.info(f"Debug Tracing (fieldKeyList={fieldKeyList})................") +separator = settings["zseparators"] +# ------------------------------------------ +# ------------------------------------------ +double_separator = separator + separator -endpoint = config.get("graphql_endpoint") # GraphQL endpoint; Update via renamefile_settings.py # GraphQL query to fetch all scenes query_all_scenes = """ @@ -32,13 +106,18 @@ query_all_scenes = """ } } """ +if debugTracing: logger.info("Debug Tracing................") # Function to make GraphQL requests def graphql_request(query, variables=None): + if debugTracing: logger.info("Debug Tracing................%s", query) data = {'query': query} if variables: data['variables'] = variables + if debugTracing: logger.info("Debug Tracing................") + if debugTracing: logger.info("Debug Tracing................") response = requests.post(endpoint, json=data) + if debugTracing: logger.info("Debug Tracing................") return response.json() # Function to replace illegal characters in filenames @@ -48,7 +127,7 @@ def replace_illegal_characters(filename): filename = filename.replace(char, '-') return filename -def should_exclude_path(scene_details, exclude_paths): +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): @@ -56,98 +135,115 @@ def should_exclude_path(scene_details, exclude_paths): return False # Function to form the new filename based on scene details and user settings -def form_filename(original_file_stem, scene_details, wrapper_styles, separator, key_order, exclude_keys, max_tag_keys=None, tag_whitelist=None, dry_run=None, exclude_paths=None): +def form_filename(original_file_stem, scene_details, wrapper_styles): + if debugTracing: logger.info("Debug Tracing................") filename_parts = [] tag_keys_added = 0 default_title = '' if_notitle_use_org_filename = config["if_notitle_use_org_filename"] - exclude_tag_if_in_name = config["exclude_tag_if_in_name"] + include_tag_if_in_name = settings["tagIncludeInFileName"] + include_performer_if_in_name = settings["performerIncludeInFileName"] 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 + # ................... + + if debugTracing: logger.info("Debug Tracing................") # Function to add tag to filename def add_tag(tag_name): nonlocal tag_keys_added - if max_tag_keys is not None and tag_keys_added >= int(max_tag_keys): + nonlocal filename_parts + nonlocal wrapper_styles + if debugTracing: logger.info(f"Debug Tracing (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 # 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]}") + if debugTracing: logger.info("Debug Tracing................") else: filename_parts.append(tag_name) + if debugTracing: logger.info("Debug Tracing................") tag_keys_added += 1 + if debugTracing: logger.info("Debug Tracing................") else: - log.info(f"Skipping tag not in whitelist: {tag_name}") logger.info(f"Skipping tag not in whitelist: {tag_name}") + if debugTracing: logger.info(f"Debug Tracing (tag_keys_added={tag_keys_added})................") - for key in key_order: - if not exclude_keys or key not in exclude_keys: - if key == 'studio': - studio_name = scene_details.get('studio', {}).get('name', '') - if studio_name: - 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) - elif key == 'title': - title = scene_details.get('title', default_title) - if not title: - if if_notitle_use_org_filename: - title = default_title - if 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': + for key in fieldKeyList: + if key == 'studio': + studio_name = scene_details.get('studio', {}).get('name', '') + if studio_name: + 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) + 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 + 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 settings["performerAppend"]: performers = '-'.join([performer.get('name', '') for performer in scene_details.get('performers', [])]) if 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: - if wrapper_styles.get('date'): - filename_parts.append(f"{wrapper_styles['date'][0]}{scene_date}{wrapper_styles['date'][1]}") - else: - filename_parts.append(scene_date) - elif key == 'height': - height = str(scene_details.get('files', [{}])[0].get('height', '')) # Convert height to string - if height: - height += 'p' - if wrapper_styles.get('height'): - filename_parts.append(f"{wrapper_styles['height'][0]}{height}{wrapper_styles['height'][1]}") - else: - filename_parts.append(height) - elif key == 'video_codec': - video_codec = scene_details.get('files', [{}])[0].get('video_codec', '').upper() # Convert to uppercase - if video_codec: - if wrapper_styles.get('video_codec'): - filename_parts.append(f"{wrapper_styles['video_codec'][0]}{video_codec}{wrapper_styles['video_codec'][1]}") - else: - filename_parts.append(video_codec) - elif key == 'frame_rate': - frame_rate = str(scene_details.get('files', [{}])[0].get('frame_rate', '')) + ' FPS' # Convert to string and append ' FPS' - if frame_rate: - if wrapper_styles.get('frame_rate'): - filename_parts.append(f"{wrapper_styles['frame_rate'][0]}{frame_rate}{wrapper_styles['frame_rate'][1]}") - else: - filename_parts.append(frame_rate) - elif key == 'tags': + if not include_performer_if_in_name or performers.lower() not in title.lower(): + 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: + if wrapper_styles.get('date'): + filename_parts.append(f"{wrapper_styles['date'][0]}{scene_date}{wrapper_styles['date'][1]}") + else: + filename_parts.append(scene_date) + elif key == 'height': + height = str(scene_details.get('files', [{}])[0].get('height', '')) # Convert height to string + if height: + height += 'p' + if wrapper_styles.get('height'): + filename_parts.append(f"{wrapper_styles['height'][0]}{height}{wrapper_styles['height'][1]}") + else: + filename_parts.append(height) + elif key == 'video_codec': + video_codec = scene_details.get('files', [{}])[0].get('video_codec', '').upper() # Convert to uppercase + if video_codec: + if wrapper_styles.get('video_codec'): + filename_parts.append(f"{wrapper_styles['video_codec'][0]}{video_codec}{wrapper_styles['video_codec'][1]}") + else: + filename_parts.append(video_codec) + elif key == 'frame_rate': + frame_rate = str(scene_details.get('files', [{}])[0].get('frame_rate', '')) + ' FPS' # Convert to string and append ' FPS' + if frame_rate: + if wrapper_styles.get('frame_rate'): + filename_parts.append(f"{wrapper_styles['frame_rate'][0]}{frame_rate}{wrapper_styles['frame_rate'][1]}") + else: + filename_parts.append(frame_rate) + elif key == 'tags': + if settings["tagAppend"]: tags = [tag.get('name', '') for tag in scene_details.get('tags', [])] + if debugTracing: logger.info("Debug Tracing................") for tag_name in tags: - if not exclude_tag_if_in_name or tag_name.lower() not in original_file_stem.lower(): + if debugTracing: logger.info(f"Debug Tracing (include_tag_if_in_name={include_tag_if_in_name})................") + if include_tag_if_in_name or tag_name.lower() not in title.lower(): add_tag(tag_name) + if debugTracing: logger.info("Debug Tracing................") - new_filename = separator.join(filename_parts).replace('--', '-') + new_filename = separator.join(filename_parts).replace(double_separator, separator) # Check if the scene's path matches any of the excluded paths - if exclude_paths and should_exclude_path(scene_details, exclude_paths): - log.info(f"Scene belongs to an excluded path. Skipping filename modification.") + if exclude_paths and should_exclude_path(scene_details): logger.info(f"Scene belongs to an excluded path. Skipping filename modification.") return Path(scene_details['files'][0]['path']).name # Return the original filename @@ -181,7 +277,7 @@ def find_scene_by_id(scene_id): scene_result = graphql_request(query_find_scene, variables={"scene_id": scene_id}) return scene_result.get('data', {}).get('findScene') -def move_or_rename_files(scene_details, new_filename, original_parent_directory, move_files, rename_files, dry_run, dry_run_prefix, exclude_paths=None): +def move_or_rename_files(scene_details, new_filename, original_parent_directory): studio_directory = None for file_info in scene_details['files']: path = file_info['path'] @@ -189,7 +285,6 @@ def move_or_rename_files(scene_details, new_filename, original_parent_directory, # Check if the file's path matches any of the excluded paths if exclude_paths and any(original_path.match(exclude_path) for exclude_path in exclude_paths): - log.info(f"File {path} belongs to an excluded path. Skipping modification.") logger.info(f"File {path} belongs to an excluded path. Skipping modification.") continue @@ -204,23 +299,19 @@ def move_or_rename_files(scene_details, new_filename, original_parent_directory, if rename_files: # Check if rename_files is True if not dry_run: shutil.move(original_path, new_path) - log.info(f"{dry_run_prefix}Moved and renamed file: {path} -> {new_path}") logger.info(f"{dry_run_prefix}Moved and renamed file: {path} -> {new_path}") else: if not dry_run: shutil.move(original_path, new_path) - log.info(f"{dry_run_prefix}Moved file: {path} -> {new_path}") logger.info(f"{dry_run_prefix}Moved file: {path} -> {new_path}") else: if rename_files: # Check if rename_files is True if not dry_run: original_path.rename(new_path) - log.info(f"{dry_run_prefix}Renamed file: {path} -> {new_path}") logger.info(f"{dry_run_prefix}Renamed file: {path} -> {new_path}") else: if not dry_run: shutil.move(original_path, new_path) - log.info(f"{dry_run_prefix}Moved file: {path} -> {new_path}") logger.info(f"{dry_run_prefix}Moved file: {path} -> {new_path}") except FileNotFoundError: log.error(f"File not found: {path}. Skipping...") @@ -244,24 +335,26 @@ def perform_metadata_scan(metadata_scan_path): logger.info(f"Mutation string: {mutation_metadata_scan}") graphql_request(mutation_metadata_scan) -def rename_scene(scene_id, wrapper_styles, separator, key_order, stash_directory, rename_files, move_files, dry_run, max_tag_keys=None, tag_whitelist=None, exclude_paths=None): +def rename_scene(scene_id, wrapper_styles, stash_directory): scene_details = find_scene_by_id(scene_id) + if debugTracing: logger.info(f"Debug Tracing (scene_details={scene_details})................") if not scene_details: log.error(f"Scene with ID {scene_id} not found.") logger.error(f"Scene with ID {scene_id} not found.") return - exclude_keys = config["exclude_keys"] - + if debugTracing: logger.info(f"Debug Tracing................") + original_file_path = scene_details['files'][0]['path'] original_parent_directory = Path(original_file_path).parent + if debugTracing: logger.info(f"Debug Tracing (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): - log.info(f"Scene with ID {scene_id} belongs to an excluded path. Skipping modifications.") logger.info(f"Scene with ID {scene_id} belongs to an excluded path. Skipping modifications.") return + if debugTracing: logger.info(f"Debug Tracing................") original_path_info = {'original_file_path': original_file_path, 'original_parent_directory': original_parent_directory} @@ -269,25 +362,23 @@ def rename_scene(scene_id, wrapper_styles, separator, key_order, stash_directory 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, wrapper_styles, separator, key_order, exclude_keys, max_tag_keys=max_tag_keys, tag_whitelist=tag_whitelist, dry_run=dry_run, exclude_paths=exclude_paths) - - dry_run_prefix = '' - if dry_run: - log.info("Dry run mode is enabled.") - logger.info("Dry run mode is enabled.") - dry_run_prefix = "Would've " + new_filename = form_filename(original_file_stem, scene_details, wrapper_styles) + newFilenameWithExt = new_filename + Path(original_file_path).suffix + if debugTracing: logger.info(f"Debug Tracing (original_file_name={original_file_name})(newFilenameWithExt={newFilenameWithExt})................") + if original_file_name == newFilenameWithExt: + logger.info(f"Nothing to do, because new file name matches original file name: (newFilenameWithExt={newFilenameWithExt})") + return + if debugTracing: logger.info(f"Debug Tracing................") if rename_files: - new_path = original_parent_directory / (new_filename + Path(original_file_path).suffix) + new_path = original_parent_directory / (newFilenameWithExt) new_path_info = {'new_file_path': new_path} - log.info(f"{dry_run_prefix}New filename: {new_path}") logger.info(f"{dry_run_prefix}New filename: {new_path}") if move_files and original_parent_directory.name != scene_details['studio']['name']: new_path = original_parent_directory / scene_details['studio']['name'] / (new_filename + Path(original_file_path).suffix) new_path_info = {'new_file_path': new_path} - move_or_rename_files(scene_details, new_filename, original_parent_directory, move_files, rename_files, dry_run, dry_run_prefix) - log.info(f"{dry_run_prefix}Moved to directory: '{new_path}'") + move_or_rename_files(scene_details, new_filename, original_parent_directory) logger.info(f"{dry_run_prefix}Moved to directory: '{new_path}'") # If rename_files is True, attempt renaming even if move_files is False @@ -297,7 +388,6 @@ def rename_scene(scene_id, wrapper_styles, separator, key_order, stash_directory try: if not dry_run: os.rename(original_file_path, new_file_path) - log.info(f"{dry_run_prefix}Renamed file: {original_file_path} -> {new_file_path}") logger.info(f"{dry_run_prefix}Renamed file: {original_file_path} -> {new_file_path}") except Exception as e: log.error(f"Failed to rename file: {original_file_path}. Error: {e}") @@ -318,14 +408,18 @@ def rename_scene(scene_id, wrapper_styles, separator, key_order, stash_directory return new_filename, original_path_info, new_path_info - +if debugTracing: logger.info("Debug Tracing................") # Execute the GraphQL query to fetch all scenes scene_result = graphql_request(query_all_scenes) +if debugTracing: logger.info("Debug Tracing................") all_scenes = scene_result.get('data', {}).get('allScenes', []) +if debugTracing: logger.info("Debug Tracing................") if not all_scenes: + if debugTracing: logger.info("Debug Tracing................") log.error("No scenes found.") logger.error("No scenes found.") exit() +if debugTracing: logger.info("Debug Tracing................") # Find the scene with the latest updated_at timestamp latest_scene = max(all_scenes, key=lambda scene: scene['updated_at']) @@ -333,33 +427,30 @@ 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') -# Extract dry_run setting from settings -dry_run = config["dry_run"] -# Extract wrapper styles, separator, and key order from settings +# Extract wrapper styles wrapper_styles = config["wrapper_styles"] -separator = config["separator"] -key_order = config["key_order"] # Read stash directory from renamefile_settings.py stash_directory = config.get('stash_directory', '') +if debugTracing: logger.info("Debug Tracing................") -# Extract rename_files and move_files settings from renamefile_settings.py -rename_files_setting = config["rename_files"] -move_files_setting = config["move_files"] - -# Extract tag whitelist from settings -tag_whitelist = config.get("tag_whitelist") -if not tag_whitelist: - tag_whitelist = "" +if debugTracing: logger.info("Debug Tracing................") # Rename the latest scene and trigger metadata scan -new_filename = rename_scene(latest_scene_id, wrapper_styles, separator, key_order, stash_directory, rename_files_setting, move_files_setting, dry_run, max_tag_keys=config["max_tag_keys"], tag_whitelist=tag_whitelist, exclude_paths=config.get("exclude_paths")) +new_filename = rename_scene(latest_scene_id, wrapper_styles, stash_directory) +if debugTracing: logger.info("Debug Tracing................") # Log dry run state and indicate if no changes were made if dry_run: log.info("Dry run: Script executed in dry run mode. No changes were made.") logger.info("Dry run: Script executed in dry run mode. No changes were made.") elif not new_filename: - log.info("No changes were made.") logger.info("No changes were made.") +else: + logger.info("Change success!") +logger.info("*********************************\nEXITING*********************************\n*********************************\n") +# ToDo List + # Add logic to max_filename_length code so it checks base file length and checks folder length, instead of lumping them altogether. + # Add logic to update Sqlite DB on file name change, instead of perform_metadata_scan. + # Get variables from the Plugins Settings UI instead of from renamefile_settings.py \ No newline at end of file diff --git a/plugins/RenameFile/renamefile.yml b/plugins/RenameFile/renamefile.yml index 2e19e45..5494093 100644 --- a/plugins/RenameFile/renamefile.yml +++ b/plugins/RenameFile/renamefile.yml @@ -1,11 +1,65 @@ name: RenameFile description: "Renames video (scene) file names when the user edits the [Title] field located in the scene [Edit] tab. - The file is renamed after user clicks save button. - Tags are appended to the file name if the tag does not already exist in the original file name. - Features are configurable using the renamefile_settings.py. + The file is renamed to the [Title] field value after user clicks save button. + Optionally, tags and performers can be appended to the file name when rename occurs. + Additional options available in file renamefile_settings.py. Note: On Windows OS, the file can not be renamed while it's playing. Refresh the URL to allow file release and rename." -version: 0.1 -url: https://github.com/David-Maisonave/Axter-Stash +version: 0.2.3 +url: https://github.com/David-Maisonave/Axter-Stash/tree/main/plugins/RenameFile +ui: +settings: + dryRun: + displayName: Dry Run + description: Enable to run script in [Dry Run] mode. In dry run mode, files are NOT renamed, and only logging is performed. Use the logging to determine if rename will occur as expected. This should always be enabled on the first run after renamefile_settings.py has been modified. + type: BOOLEAN + fileRenameViaMove: + displayName: Rename Using Move + description: Enable to have file moved when renaming file. + type: BOOLEAN + performerAppend: + displayName: Append Performers + description: Enable to append performers name to file name when renaming a file. Requires performers to be included in [Key Fields] list, which by default it is included. + type: BOOLEAN + performerIncludeInFileName: + displayName: Include Existing Performers + description: Enable to append performer even if performers name already exists in the original file name. + type: BOOLEAN + tagAppend: + displayName: Append Tags + description: Enable to append tag names to file name when renaming a file. Requires tags to be included in [Key Fields] list, which by default it is included. + type: BOOLEAN + tagIncludeInFileName: + displayName: Include Existing Tags + description: Enable to append tag name even if tag already exists in original file name. + type: BOOLEAN + zFieldKeyList: + displayName: Key Fields + description: "(Default=title,performers,tags) Define key fields to use to format the file name. This is a comma seperated list, and the list should be in the desired format order. For example, if the user wants the performers name before the title, set the performers name first. Example:\"performers,title,tags\". This is an example of user adding height:\"title,performers,tags,height\" Here's an example using all of the supported fields: \"title,performers,tags,studio,date,height,video_codec,frame_rate\"." + type: STRING + zgraphqlEndpoint: # Prefixing z_ to variable names so that the GUI will place these fields after above fields (alphabatically listed) + displayName: GraphQL Endpoint + description: (Default=http://localhost:9999/graphql). Update with your endpoint, or leave blank to use default. + type: STRING + zmaximumTagKeys: + displayName: Max Tag Keys + description: (Default=12) Maximum quantity of tag keys to append to file name. 0=Default(12); -1=No tags appended. + type: NUMBER + zpathToExclude: + displayName: Exclude Path + description: "Add path(s) to exclude from RenameFile. Example Usage: r\"/path/to/exclude1\" When entering multiple paths, use space. Example: r\"/path_1_to/exclude\" r\"/someOtherPath2Exclude\" r\"/yetAnotherPath\"" + type: STRING + zseparators: + displayName: Separator + description: "(Default=-) Define the separator to use between different parts of the filename. Example Usage: \",\"" + type: STRING + ztagWhitelist: + displayName: Tag Whitelist + description: "Define a whitelist of allowed tags or EMPTY to allow all tags. Example Usage: \"tag1\", \"tag2\", \"tag3\"" + type: STRING + zzdebugTracing: + displayName: Debug Tracing + description: (Default=false) [***For Advanced Users***] Enable debug tracing. When enabled, additional tracing logging is added to Stash\plugins\RenameFile\renamefile.log + type: BOOLEAN exec: - python - "{pluginDir}/renamefile.py" diff --git a/plugins/RenameFile/renamefile_settings.py b/plugins/RenameFile/renamefile_settings.py index 1a44f3f..a027bd0 100644 --- a/plugins/RenameFile/renamefile_settings.py +++ b/plugins/RenameFile/renamefile_settings.py @@ -1,55 +1,30 @@ # Importing config dictionary +# RenameFile plugin main configuration options are available on the Stash GUI under Settings->Plugins->Plugins->[RenameFile]. +# Most users should only use the GUI options. +# The configuration options in this file are for advanced users ONLY!!! + # After making any changes in this file, it's highly recommended that it be first tested with Dry-Run enabled. See Settings->Plugins->Plugins->[RenameFile]-[Dry Run] + # When dry-run is enabled, only logging occurs and files do NOT get renamed. User can review the renamefile.log file to verify renaming will occur as expected. + # See (Stash\plugins\RenameFile\renamefile.log) + # To activate changes to this file, click the [Reload plugins] button. Settings->Plugins->Plugins->[Reload plugins] config = { # Define wrapper styles for different parts of the filename. # Use '[]' for square brackets, '{}' for curly brackets, '()' for parentheses, or an empty string for None. "wrapper_styles": { "studio": '[]', # Modify these values to change how each part of the filename is wrapped. "title": '', # Use '[]' for square brackets, '{}' for curly brackets, '()' for parentheses, or an empty string for None. - "performers": '[]', # Modify these values to change how each part of the filename is wrapped. + "performers": '()', # Modify these values to change how each part of the filename is wrapped. "date": '[]', # Use '[]' for square brackets, '{}' for curly brackets, '()' for parentheses, or an empty string for None. - "height": '[]', # Modify these values to change how each part of the filename is wrapped. + "height": '()', # Modify these values to change how each part of the filename is wrapped. "video_codec": '[]', # Use '[]' for square brackets, '{}' for curly brackets, '()' for parentheses, or an empty string for None. "frame_rate": '[]', # Modify these values to change how each part of the filename is wrapped. "tag": '[]' # Modify these values to change how each tag part of the filename is wrapped. }, - # Define the separator to use between different parts of the filename. - # Use '-' for hyphen, '_' for underscore, or ' ' for space. - "separator": '-', - # Define the order of keys in the filename. - # Use a list to specify the order of keys. - # Valid keys are 'studio', 'title', 'performers', 'date', 'height', 'video_codec', 'frame_rate', and 'tags'. - "key_order": [ - "studio", - "title", - "performers", - "date", - "height", - "video_codec", - "frame_rate", - "tags" - ], - # Specify keys to exclude from the filename formation process. (ie. "exclude_keys": ["studio", "date"],) - "exclude_keys": ["studio", "performers", "date", "height", "video_codec", "frame_rate"], - # Define whether files should be moved when renaming - "move_files": False, # Define whether files should be renamed when moved "rename_files": True, - # Define whether the script should run in dry run mode - "dry_run": False, # Define whether the original file name should be used if title is empty - "if_notitle_use_org_filename": True, - # Define whether to exclude the tag if the tag is already in the file name - "exclude_tag_if_in_name": True, # Warning: If set to False, could end up having duplicate tags in file name - # Define the maximum number of tag keys to include in the filename (None for no limit) - "max_tag_keys": 12, + "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, # "max_filefolder_length": 255, # For future useage # "max_filebase_length": 255, # For future useage - # GraphQL endpoint - "graphql_endpoint": "http://localhost:9999/graphql", # Update with your endpoint - # Define a whitelist of allowed tags or (None to allow all tags) - "tag_whitelist": [], #Example: "tag_whitelist": ["tag1", "tag2", "tag3"] - # Define paths to exclude from modifications - "exclude_paths": [] #Example: "exclude_paths": [r"/path/to/exclude1"] }