forked from Github/Axter-Stash
first upload
This commit is contained in:
14
plugins/RenameFilename/README.md
Normal file
14
plugins/RenameFilename/README.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# RenameFileName:
|
||||
|
||||
### Requirements
|
||||
|
||||
`pip install stashapp-tools`
|
||||
`pip install pyYAML`
|
||||
|
||||
### Using RenameFileName
|
||||
`*Note: Changes are made when a scene edit is saved.`
|
||||
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.When you have installed the `RenameFileName` plugin, hop into your plugins directory, RenameFileName folder > open renamefilename_settings.py with your favorite code/text editor and you'll see this:
|
||||
Features are configurable using the renamefilename_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.
|
||||
362
plugins/RenameFilename/renamefilename.py
Normal file
362
plugins/RenameFilename/renamefilename.py
Normal file
@@ -0,0 +1,362 @@
|
||||
import requests
|
||||
import os
|
||||
import logging
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
import hashlib
|
||||
|
||||
# Importing stashapi.log as log for critical events
|
||||
import stashapi.log as log
|
||||
|
||||
# Import settings from renamefilename_settings.py
|
||||
from renamefilename_settings import config
|
||||
|
||||
# Get the directory of the script
|
||||
script_dir = Path(__file__).resolve().parent
|
||||
|
||||
# Configure logging for your script
|
||||
log_file_path = script_dir / 'renamefilename.log'
|
||||
logging.basicConfig(filename=log_file_path, level=logging.INFO, format='%(asctime)s - %(message)s')
|
||||
logger = logging.getLogger('renamefilename')
|
||||
|
||||
endpoint = config.get("graphql_endpoint") # GraphQL endpoint; Update via renamefilename_settings.py
|
||||
|
||||
# GraphQL query to fetch all scenes
|
||||
query_all_scenes = """
|
||||
query AllScenes {
|
||||
allScenes {
|
||||
id
|
||||
updated_at
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
# Function to make GraphQL requests
|
||||
def graphql_request(query, variables=None):
|
||||
data = {'query': query}
|
||||
if variables:
|
||||
data['variables'] = variables
|
||||
response = requests.post(endpoint, json=data)
|
||||
return response.json()
|
||||
|
||||
# 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, exclude_paths):
|
||||
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
|
||||
|
||||
# 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):
|
||||
filename_parts = []
|
||||
tag_keys_added = 0
|
||||
default_title = ''
|
||||
if_notitle_use_org_filename = config["if_notitle_use_org_filename"]
|
||||
add_tag_if_not_in_name = config["add_tag_if_not_in_name"]
|
||||
if if_notitle_use_org_filename:
|
||||
default_title = original_file_stem
|
||||
|
||||
# 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):
|
||||
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]}")
|
||||
else:
|
||||
filename_parts.append(tag_name)
|
||||
tag_keys_added += 1
|
||||
else:
|
||||
log.info(f"Skipping tag not in whitelist: {tag_name}")
|
||||
logger.info(f"Skipping tag not in whitelist: {tag_name}")
|
||||
|
||||
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':
|
||||
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':
|
||||
tags = [tag.get('name', '') for tag in scene_details.get('tags', [])]
|
||||
for tag_name in tags:
|
||||
if not add_tag_if_not_in_name or tag_name.lower() not in original_file_stem.lower():
|
||||
add_tag(tag_name)
|
||||
|
||||
new_filename = separator.join(filename_parts).replace('--', '-')
|
||||
|
||||
# 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.")
|
||||
logger.info(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 find_scene_by_id(scene_id):
|
||||
query_find_scene = """
|
||||
query FindScene($scene_id: ID!) {
|
||||
findScene(id: $scene_id) {
|
||||
id
|
||||
title
|
||||
date
|
||||
files {
|
||||
path
|
||||
height
|
||||
video_codec
|
||||
frame_rate
|
||||
}
|
||||
studio {
|
||||
name
|
||||
}
|
||||
performers {
|
||||
name
|
||||
}
|
||||
tags {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
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):
|
||||
studio_directory = None
|
||||
for file_info in scene_details['files']:
|
||||
path = file_info['path']
|
||||
original_path = Path(path)
|
||||
|
||||
# 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
|
||||
|
||||
new_path = original_parent_directory if not move_files else original_parent_directory / scene_details['studio']['name']
|
||||
if rename_files:
|
||||
new_path = new_path / (new_filename + original_path.suffix)
|
||||
try:
|
||||
if move_files:
|
||||
if studio_directory is None:
|
||||
studio_directory = original_parent_directory / scene_details['studio']['name']
|
||||
studio_directory.mkdir(parents=True, exist_ok=True)
|
||||
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...")
|
||||
logger.error(f"File not found: {path}. Skipping...")
|
||||
continue
|
||||
except OSError as e:
|
||||
log.error(f"Failed to move or rename file: {path}. Error: {e}")
|
||||
logger.error(f"Failed to move or rename file: {path}. Error: {e}")
|
||||
continue
|
||||
|
||||
return new_path # Return the new_path variable after the loop
|
||||
|
||||
def perform_metadata_scan(metadata_scan_path):
|
||||
metadata_scan_path_windows = metadata_scan_path.resolve().as_posix()
|
||||
mutation_metadata_scan = """
|
||||
mutation {
|
||||
metadataScan(input: { paths: "%s" })
|
||||
}
|
||||
""" % metadata_scan_path_windows
|
||||
logger.info(f"Attempting metadata scan mutation with path: {metadata_scan_path_windows}")
|
||||
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):
|
||||
scene_details = find_scene_by_id(scene_id)
|
||||
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"]
|
||||
|
||||
original_file_path = scene_details['files'][0]['path']
|
||||
original_parent_directory = Path(original_file_path).parent
|
||||
|
||||
# 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
|
||||
|
||||
original_path_info = {'original_file_path': original_file_path,
|
||||
'original_parent_directory': original_parent_directory}
|
||||
|
||||
new_path_info = 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, 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 "
|
||||
|
||||
if rename_files:
|
||||
new_path = original_parent_directory / (new_filename + Path(original_file_path).suffix)
|
||||
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}'")
|
||||
logger.info(f"{dry_run_prefix}Moved to directory: '{new_path}'")
|
||||
|
||||
# If rename_files is True, attempt renaming even if move_files is False
|
||||
if rename_files:
|
||||
new_file_path = original_parent_directory / (new_filename + Path(original_file_name).suffix)
|
||||
if original_file_name != new_filename:
|
||||
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}")
|
||||
logger.error(f"Failed to rename file: {original_file_path}. Error: {e}")
|
||||
|
||||
metadata_scan_path = original_parent_directory
|
||||
perform_metadata_scan(metadata_scan_path)
|
||||
|
||||
# Current DB schema allows file folder max length to be 255, and max base filename to be 255
|
||||
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
|
||||
|
||||
return new_filename, original_path_info, new_path_info
|
||||
|
||||
|
||||
# Execute the GraphQL query to fetch all scenes
|
||||
scene_result = graphql_request(query_all_scenes)
|
||||
all_scenes = scene_result.get('data', {}).get('allScenes', [])
|
||||
if not all_scenes:
|
||||
log.error("No scenes found.")
|
||||
logger.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')
|
||||
|
||||
# Extract dry_run setting from settings
|
||||
dry_run = config["dry_run"]
|
||||
|
||||
# Extract wrapper styles, separator, and key order from settings
|
||||
wrapper_styles = config["wrapper_styles"]
|
||||
separator = config["separator"]
|
||||
key_order = config["key_order"]
|
||||
|
||||
# Read stash directory from renamefilename_settings.py
|
||||
stash_directory = config.get('stash_directory', '')
|
||||
|
||||
# Extract rename_files and move_files settings from renamefilename_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 = ""
|
||||
|
||||
# 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"))
|
||||
|
||||
# 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.")
|
||||
22
plugins/RenameFilename/renamefilename.yml
Normal file
22
plugins/RenameFilename/renamefilename.yml
Normal file
@@ -0,0 +1,22 @@
|
||||
name: RenameFileName
|
||||
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 renamefilename_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
|
||||
exec:
|
||||
- python
|
||||
- "{pluginDir}/renamefilename.py"
|
||||
interface: raw
|
||||
hooks:
|
||||
- name: RenameFiles
|
||||
description: Renames scene files.
|
||||
triggeredBy:
|
||||
- Scene.Update.Post
|
||||
tasks:
|
||||
- name: Rename Files Task
|
||||
description: Renames scene files.
|
||||
defaultArgs:
|
||||
mode: rename_files_task
|
||||
58
plugins/RenameFilename/renamefilename_settings.py
Normal file
58
plugins/RenameFilename/renamefilename_settings.py
Normal file
@@ -0,0 +1,58 @@
|
||||
# Importing config dictionary
|
||||
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.
|
||||
"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.
|
||||
"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"
|
||||
],
|
||||
# Define keys to exclude from the formed filename
|
||||
# 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 add tag only if tag is not in file name
|
||||
"add_tag_if_not_in_name": True,
|
||||
# Define whether to rename all files missing tag names, or only the latest scene having an update
|
||||
# "rename_all_files": True,
|
||||
# Define the maximum number of tag keys to include in the filename (None for no limit)
|
||||
"max_tag_keys": 12,
|
||||
# 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"]
|
||||
}
|
||||
1
plugins/RenameFilename/requirements.txt
Normal file
1
plugins/RenameFilename/requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
stashapp-tools
|
||||
Reference in New Issue
Block a user