From 6a422ac539737631540597556df3799040d13595 Mon Sep 17 00:00:00 2001 From: stashdbcorrode248 Date: Sun, 30 Aug 2026 12:56:16 -0600 Subject: [PATCH] feat(sceneRename): rename via moveFiles and record original filename Renaming previously called os.rename() behind Stash's back and then kicked off metadata_scan() on the parent folder to repair the database. That left the file record pointing at a stale path until the scan finished, and the scan itself is a full job for what is a single-file change. Rename through the moveFiles mutation instead: Stash moves the file on disk and updates the file record in one transaction, so no rescan is needed. A destination folder is required even for an in-place rename, so the file's current parent_folder id is passed (falling back to its path). Also in this change: - Require a title, not just a studio. Without one the name collapsed to the studio plus resolution, which is less useful than the original filename. The README already documented this requirement; the code now matches it. The skip message names the missing field instead of listing every field. - Record the pre-rename basename in the scene's original_filename custom field, written once so the earliest known name survives later renames. A failed write is logged as a warning and does not fail the rename. - Harden filename sanitising. ":" is now stripped from studio names and codes, which never passed through clean_title(); control characters are collapsed to spaces; leading dots and trailing dots and spaces are trimmed. - Truncate to a byte budget rather than a character count. Filesystem name limits are in bytes (255 on ext4/btrfs), so a 240-character non-ASCII title could produce a name well over the limit and fail to rename. The budget also reserves room for the extension and a duplicate suffix. - Correct the README's filename format and worked example, which showed the resolution before the title while form_filename() emits it after. --- plugins/sceneRename/README.md | 7 +- plugins/sceneRename/scenerename.py | 103 ++++++++++++++++++++++++---- plugins/sceneRename/scenerename.yml | 4 +- 3 files changed, 97 insertions(+), 17 deletions(-) diff --git a/plugins/sceneRename/README.md b/plugins/sceneRename/README.md index ab18e940..8988db4b 100644 --- a/plugins/sceneRename/README.md +++ b/plugins/sceneRename/README.md @@ -10,6 +10,9 @@ Simple plugin to help organize scene files into a clean, consistent format. It i * Graceful handling of already-renamed files * Does not fail if Scene ID or resolution are missing * Requires a Studio and Title to proceed +* Renames through Stash's `moveFiles` API, so the database stays in sync without a rescan +* Records the pre-rename filename in the scene's `original_filename` custom field +* Strips illegal and control characters, and truncates to the filesystem's byte limit The code is simple and the plugin UI includes clear usage instructions. @@ -25,7 +28,7 @@ The code is simple and the plugin UI includes clear usage instructions. The `Scene Rename` plugin renames scene files using the following format: ``` -Studio #StudioID [Resolution] - Title.mp4 +Studio #Code - Title [Resolution].mp4 ``` For example, a file in my library that still has its default name: @@ -37,7 +40,7 @@ wodhhd_06_1080p.mp4 Is renamed to: ``` -TitanMen #395 [1080p] - Coyote Point, Dakota Rivers.mp4 +TitanMen #395 - Coyote Point, Dakota Rivers [1080p].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. diff --git a/plugins/sceneRename/scenerename.py b/plugins/sceneRename/scenerename.py index f5d6c5e0..3dd39e05 100644 --- a/plugins/sceneRename/scenerename.py +++ b/plugins/sceneRename/scenerename.py @@ -17,7 +17,16 @@ print("stashapi not found", file=sys.stderr) sys.exit(1) -SCENE_FRAGMENT = "id title code studio {name} files {id path width height} date" +SCENE_FRAGMENT = "id title code studio {name} files {id path width height parent_folder {id}} date custom_fields" + +ORIGINAL_NAME_FIELD = "original_filename" + +# Filesystem name limits are in bytes, not characters (255 on ext4/btrfs). +NAME_MAX_BYTES = 255 + +# Illegal or troublesome in filenames. ":" is here because clean_title() only +# strips it from titles - studio names and codes reach the filename untouched. +ILLEGAL_CHARS = ["<", ">", '"', "/", "\\", "|", "?", "*", ":"] def get_json_input(): @@ -56,9 +65,28 @@ def get_settings(json_input, stash): def replace_illegal_chars(filename): - for ch in ["<", ">", '"', "/", "\\", "|", "?", "*"]: + for ch in ILLEGAL_CHARS: filename = filename.replace(ch, "-") - return filename + + # Control characters (NUL, newline, tab) are legal on Linux but make the + # file miserable to handle in a shell, over SMB, or on any other platform. + filename = "".join( + " " if ord(c) < 32 or ord(c) == 127 else c for c in filename + ) + + # Tidy up the whitespace those substitutions can leave behind. + filename = " ".join(filename.split()) + + # A leading dot hides the file; trailing dots and spaces break elsewhere. + return filename.strip(" .") + + +def truncate_to_bytes(name, max_bytes): + """Trim to a byte budget without splitting a multi-byte character.""" + if len(name.encode("utf-8")) <= max_bytes: + return name + trimmed = name.encode("utf-8")[:max_bytes].decode("utf-8", "ignore") + return trimmed.strip(" .") def get_resolution_label(height): @@ -84,7 +112,7 @@ def clean_title(title): return title.replace(":", ",") -def form_filename(scene): +def form_filename(scene, max_stem_bytes=NAME_MAX_BYTES): """Build filename: Studio #Code - Title [Resolution]""" # Studio Name studio = scene.get("studio") @@ -106,8 +134,9 @@ def form_filename(scene): # Full title with colons replaced by commas title = clean_title(scene.get("title", "")) - # Skip files without a studio name - if not studio_name: + # Studio and title are both required. Without a title the name collapses to + # just the studio (plus resolution), which is worse than the original. + if not studio_name or not title.strip(): return None # Build: "Studio #Code - Title [Resolution]" @@ -127,13 +156,38 @@ def form_filename(scene): new_name = "{} [{}]".format(new_name, resolution) new_name = replace_illegal_chars(new_name) + new_name = truncate_to_bytes(new_name, max_stem_bytes) - if len(new_name) > 240: - new_name = new_name[:240] + # Sanitising can empty the stem, e.g. a studio and title of only dots. + if not new_name: + return None return new_name +def record_original_name(stash, scene, original_name): + """Save the pre-rename basename to the scene's custom fields. + + Only written once, so the earliest known filename survives later renames. + Uses a partial update so any other custom fields are left alone. + """ + existing = scene.get("custom_fields") or {} + if existing.get(ORIGINAL_NAME_FIELD): + return + + try: + stash.update_scene({ + "id": scene["id"], + "custom_fields": {"partial": {ORIGINAL_NAME_FIELD: original_name}}, + }) + file_logger.info(" Recorded {} = {}".format(ORIGINAL_NAME_FIELD, original_name)) + except Exception as e: + # Bookkeeping failure must not be reported as a failed rename. + msg = "Renamed, but could not record original filename: {}".format(e) + log.warning(msg) + file_logger.warning(msg) + + def rename_scene(stash, scene_id, dry_run=False, debug=False): scene = stash.find_scene(scene_id, SCENE_FRAGMENT) if not scene: @@ -154,9 +208,18 @@ def rename_scene(stash, scene_id, dry_run=False, debug=False): ext = Path(original_path).suffix parent = Path(original_path).parent - new_stem = form_filename(scene) + # Budget the stem in bytes, leaving room for the extension and a possible + # " (2)" duplicate suffix. + max_stem_bytes = NAME_MAX_BYTES - len(ext.encode("utf-8")) - len(" (999)") + new_stem = form_filename(scene, max_stem_bytes) if not new_stem: - msg = "Could not form new filename - missing metadata (need at least one of: studio, code, title)" + missing = [] + if not (scene.get("studio") or {}).get("name"): + missing.append("studio") + if not clean_title(scene.get("title", "")).strip(): + missing.append("title") + msg = "Skipping '{}' - missing required metadata: {}".format( + original_name, ", ".join(missing)) log.info(msg) file_logger.info(msg) return None @@ -206,13 +269,27 @@ def rename_scene(stash, scene_id, dry_run=False, debug=False): if dry_run: return new_stem + # Let Stash do the rename via moveFiles: it renames on disk and updates the + # file record in one transaction, so no rescan is needed and the DB never + # points at a stale path. A destination folder is required even for an + # in-place rename, so pass the file's current one. + move_input = { + "ids": [files[0]["id"]], + "destination_basename": new_name, + } + parent_folder = files[0].get("parent_folder") or {} + if parent_folder.get("id"): + move_input["destination_folder_id"] = parent_folder["id"] + else: + move_input["destination_folder"] = str(parent) + try: - os.rename(original_path, new_path) + stash.move_files(move_input) msg = "Renamed successfully: {}".format(new_path) log.info(msg) file_logger.info(msg) - stash.metadata_scan(paths=[str(parent)]) - except OSError as e: + record_original_name(stash, scene, original_name) + except Exception as e: msg = "Failed to rename: {}".format(e) log.error(msg) file_logger.error(msg) diff --git a/plugins/sceneRename/scenerename.yml b/plugins/sceneRename/scenerename.yml index 8db77dd0..37992061 100644 --- a/plugins/sceneRename/scenerename.yml +++ b/plugins/sceneRename/scenerename.yml @@ -1,6 +1,6 @@ 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 +description: "Renames scene files to 'Studio #Code - Title [Resolution].ext'. Studio and title are both required (scenes missing either 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.2.0 url: https://discourse.stashapp.cc/t/scenerename/5795 settings: dryRun: