diff --git a/plugins/sceneRename/README.md b/plugins/sceneRename/README.md new file mode 100644 index 00000000..ab18e940 --- /dev/null +++ b/plugins/sceneRename/README.md @@ -0,0 +1,68 @@ +# Scene Rename: File Organizer Plugin + +Simple plugin to help organize scene files into a clean, consistent format. It includes debug tracing that integrates with the `Logs` view, supports dry runs, and provides a clear description in the Plugins UI. + + +## Features + +* Dry run support +* Debug output in the `Logs` view +* Graceful handling of already-renamed files +* Does not fail if Scene ID or resolution are missing +* Requires a Studio and Title to proceed + +The code is simple and the plugin UI includes clear usage instructions. + + +## Screenshot + +![Screenshot 2026-02-15 at 10.18.08 AM|690x364](screenshots/Screenshot.png) + +--- + +## What It Does + +The `Scene Rename` plugin renames scene files using the following format: + +``` +Studio #StudioID [Resolution] - Title.mp4 +``` + +For example, a file in my library that still has its default name: + +``` +wodhhd_06_1080p.mp4 +``` + +Is renamed to: + +``` +TitanMen #395 [1080p] - Coyote Point, Dakota Rivers.mp4 +``` + +This format keeps filenames consistent and easy to scan. It also makes it simple to group files by studio if desired, or keep everything in a single directory while maintaining a clean, uniform structure. + +I’ve found it very effective for keeping my library organized, and others may find it useful as well. + +--- + +## Installation Example (Docker) + +I run Stash in Docker. My folder structure looks like this: + +``` +docker/ +└── docker-compose.yml +└── plugins/ + └── scenerename/ + ├── scenerename.py + └── scenerename.yml +``` + +In `docker-compose.yml`, add the following under `volumes`: + +``` +- ./plugins/scenerename:/root/.stash/plugins/scenerename +``` + +That’s all that’s required. The plugin loads normally, outputs to the Logs window, and supports dry runs. \ No newline at end of file diff --git a/plugins/sceneRename/scenerename.py b/plugins/sceneRename/scenerename.py new file mode 100644 index 00000000..f5d6c5e0 --- /dev/null +++ b/plugins/sceneRename/scenerename.py @@ -0,0 +1,257 @@ +import os, sys, json, logging, traceback +from pathlib import Path +from logging.handlers import RotatingFileHandler + +# Setup file logging +log_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "scenerename.log") +file_logger = logging.getLogger("scenerename") +file_logger.setLevel(logging.DEBUG) +fh = RotatingFileHandler(log_file, maxBytes=2*1024*1024, backupCount=2) +fh.setFormatter(logging.Formatter("%(asctime)s %(levelname)s: %(message)s")) +file_logger.addHandler(fh) + +try: + import stashapi.log as log + from stashapi.stashapp import StashInterface +except ModuleNotFoundError: + print("stashapi not found", file=sys.stderr) + sys.exit(1) + +SCENE_FRAGMENT = "id title code studio {name} files {id path width height} date" + + +def get_json_input(): + return json.loads(sys.stdin.read()) + + +def get_stash(json_input): + conn = json_input["server_connection"] + host = conn["Host"] + if host == "0.0.0.0": + host = "localhost" + stash_conn = { + "Scheme": conn["Scheme"], + "Host": host, + "Port": conn["Port"], + } + if conn.get("SessionCookie"): + stash_conn["SessionCookie"] = conn["SessionCookie"] + if conn.get("ApiKey"): + stash_conn["ApiKey"] = conn["ApiKey"] + return StashInterface(stash_conn) + + +def get_settings(json_input, stash): + settings = {"dryRun": False, "debugTracing": False} + try: + config = stash.call_GQL("query Configuration { configuration { plugins }}") + plugins_config = config.get("configuration", {}).get("plugins", {}) + if "scenerename" in plugins_config: + s = plugins_config["scenerename"] + settings["dryRun"] = s.get("dryRun", False) + settings["debugTracing"] = s.get("debugTracing", False) + except Exception: + pass + return settings + + +def replace_illegal_chars(filename): + for ch in ["<", ">", '"', "/", "\\", "|", "?", "*"]: + filename = filename.replace(ch, "-") + return filename + + +def get_resolution_label(height): + h = int(height) + if h >= 2160: + return "4K" + elif h >= 1440: + return "1440p" + elif h >= 1080: + return "1080p" + elif h >= 720: + return "720p" + elif h >= 480: + return "480p" + return str(h) + "p" + + + +def clean_title(title): + """Replace colons with commas in the title.""" + if not title: + return "" + return title.replace(":", ",") + + +def form_filename(scene): + """Build filename: Studio #Code - Title [Resolution]""" + # Studio Name + studio = scene.get("studio") + studio_name = "" + if studio: + studio_name = studio.get("name", "") + + # Studio Code / Sequence + code = scene.get("code") or "" + + # Resolution + resolution = "" + files = scene.get("files", []) + if files: + height = files[0].get("height") + if height: + resolution = get_resolution_label(height) + + # Full title with colons replaced by commas + title = clean_title(scene.get("title", "")) + + # Skip files without a studio name + if not studio_name: + return None + + # Build: "Studio #Code - Title [Resolution]" + # Start with studio name + new_name = studio_name + + # Add code if present + if code: + new_name = "{} #{}".format(new_name, code) + + # Add title if present + if title: + new_name = "{} - {}".format(new_name, title) + + # Add resolution at the end in brackets + if resolution: + new_name = "{} [{}]".format(new_name, resolution) + + new_name = replace_illegal_chars(new_name) + + if len(new_name) > 240: + new_name = new_name[:240] + + return new_name + + +def rename_scene(stash, scene_id, dry_run=False, debug=False): + scene = stash.find_scene(scene_id, SCENE_FRAGMENT) + if not scene: + log.error("Scene {} not found".format(scene_id)) + return None + + files = scene.get("files", []) + if not files: + log.error("Scene {} has no files".format(scene_id)) + return None + + original_path = files[0]["path"] + if not os.path.isfile(original_path): + log.error("File does not exist: {}".format(original_path)) + return None + + original_name = Path(original_path).name + ext = Path(original_path).suffix + parent = Path(original_path).parent + + new_stem = form_filename(scene) + if not new_stem: + msg = "Could not form new filename - missing metadata (need at least one of: studio, code, title)" + log.info(msg) + file_logger.info(msg) + return None + + new_name = new_stem + ext + new_path = str(parent / new_name) + + if original_name == new_name: + msg = "No change needed: {}".format(original_name) + log.info(msg) + file_logger.info(msg) + return None + + # Handle duplicates - append (2), (3), etc. if target already exists + if os.path.isfile(new_path) and new_path != original_path: + counter = 2 + while True: + dup_name = "{} ({}){}".format(new_stem, counter, ext) + dup_path = str(parent / dup_name) + if dup_path == original_path: + # Already at the correct duplicate counter - no rename needed + msg = "Already correctly named: {}".format(original_name) + log.info(msg) + file_logger.info(msg) + return None + if not os.path.isfile(dup_path): + new_name = dup_name + new_path = dup_path + file_logger.warning("Duplicate detected, using: {}".format(new_name)) + break + counter += 1 + + prefix = "[DRY RUN] " if dry_run else "" + msg = "{}Changing from '{}' to '{}'".format(prefix, original_name, new_name) + log.info(msg) + file_logger.info(msg) + + if debug: + studio = scene.get("studio") + studio_name = studio.get("name") if studio else "N/A" + file_logger.debug(" Studio: {}".format(studio_name)) + file_logger.debug(" Code: {}".format(scene.get("code", "N/A"))) + file_logger.debug(" Title: {}".format(scene.get("title", "N/A"))) + file_logger.debug(" Height: {}".format(files[0].get("height", "N/A"))) + file_logger.debug(" Full path: {} -> {}".format(original_path, new_path)) + + if dry_run: + return new_stem + + try: + os.rename(original_path, new_path) + msg = "Renamed successfully: {}".format(new_path) + log.info(msg) + file_logger.info(msg) + stash.metadata_scan(paths=[str(parent)]) + except OSError as e: + msg = "Failed to rename: {}".format(e) + log.error(msg) + file_logger.error(msg) + return None + + return new_stem + + +def main(): + json_input = get_json_input() + stash = get_stash(json_input) + settings = get_settings(json_input, stash) + dry_run = settings["dryRun"] + debug = settings["debugTracing"] + + mode = json_input.get("args", {}).get("mode", "") + + # Force dry run for the dry run task + if mode == "dry_run_last": + dry_run = True + + if mode in ("rename_last", "dry_run_last"): + result = stash.call_GQL("query { allScenes { id updated_at } }") + all_scenes = result.get("allScenes", []) + if not all_scenes: + log.info("No scenes found") + return + latest = max(all_scenes, key=lambda s: s["updated_at"]) + rename_scene(stash, latest["id"], dry_run=dry_run, debug=debug) + else: + # Hook mode - Scene.Update.Post + try: + hook_context = json_input["args"]["hookContext"] + scene_id = hook_context["id"] + rename_scene(stash, scene_id, dry_run=dry_run, debug=debug) + except (KeyError, TypeError) as e: + file_logger.error("Could not get scene ID from hook: {}".format(e)) + log.error("Could not get scene ID from hook: {}".format(e)) + + +if __name__ == "__main__": + main() diff --git a/plugins/sceneRename/scenerename.yml b/plugins/sceneRename/scenerename.yml new file mode 100644 index 00000000..8db77dd0 --- /dev/null +++ b/plugins/sceneRename/scenerename.yml @@ -0,0 +1,31 @@ +name: SceneRename +description: "Renames scene files to 'Studio #Code [Resolution] - Title.ext'. Studio name is required (files without one are skipped). Code and resolution are optional. Colons in titles become commas. Triggers on scene update or via manual task. Enable Dry Run to preview changes in scenerename.log before renaming." +version: 1.0.1 +url: https://discourse.stashapp.cc/t/scenerename/5795 +settings: + dryRun: + displayName: Dry Run + description: "Enable to preview renames in the log without actually renaming files." + type: BOOLEAN + debugTracing: + displayName: Debug Tracing + description: "Enable verbose debug logging to scenerename.log" + type: BOOLEAN +exec: + - python + - "{pluginDir}/scenerename.py" +interface: raw +hooks: + - name: SceneRenameHook + description: Renames scene file on update. + triggeredBy: + - Scene.Update.Post +tasks: + - name: Rename Last Updated Scene + description: Renames the most recently updated scene file. + defaultArgs: + mode: rename_last + - name: Dry Run Last Updated Scene + description: Logs what the rename would be without changing files. + defaultArgs: + mode: dry_run_last diff --git a/plugins/sceneRename/screenshots/Screenshot.png b/plugins/sceneRename/screenshots/Screenshot.png new file mode 100644 index 00000000..6c8754dd Binary files /dev/null and b/plugins/sceneRename/screenshots/Screenshot.png differ