Skip to content

Commit f926955

Browse files
authored
Add another rename plugin (#668)
* Add another rename plugin * Fix link
1 parent 6416844 commit f926955

4 files changed

Lines changed: 356 additions & 0 deletions

File tree

plugins/sceneRename/README.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# Scene Rename: File Organizer Plugin
2+
3+
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.
4+
5+
6+
## Features
7+
8+
* Dry run support
9+
* Debug output in the `Logs` view
10+
* Graceful handling of already-renamed files
11+
* Does not fail if Scene ID or resolution are missing
12+
* Requires a Studio and Title to proceed
13+
14+
The code is simple and the plugin UI includes clear usage instructions.
15+
16+
17+
## Screenshot
18+
19+
![Screenshot 2026-02-15 at 10.18.08 AM|690x364](screenshots/Screenshot.png)
20+
21+
---
22+
23+
## What It Does
24+
25+
The `Scene Rename` plugin renames scene files using the following format:
26+
27+
```
28+
Studio #StudioID [Resolution] - Title.mp4
29+
```
30+
31+
For example, a file in my library that still has its default name:
32+
33+
```
34+
wodhhd_06_1080p.mp4
35+
```
36+
37+
Is renamed to:
38+
39+
```
40+
TitanMen #395 [1080p] - Coyote Point, Dakota Rivers.mp4
41+
```
42+
43+
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.
44+
45+
I’ve found it very effective for keeping my library organized, and others may find it useful as well.
46+
47+
---
48+
49+
## Installation Example (Docker)
50+
51+
I run Stash in Docker. My folder structure looks like this:
52+
53+
```
54+
docker/
55+
└── docker-compose.yml
56+
└── plugins/
57+
└── scenerename/
58+
├── scenerename.py
59+
└── scenerename.yml
60+
```
61+
62+
In `docker-compose.yml`, add the following under `volumes`:
63+
64+
```
65+
- ./plugins/scenerename:/root/.stash/plugins/scenerename
66+
```
67+
68+
That’s all that’s required. The plugin loads normally, outputs to the Logs window, and supports dry runs.

plugins/sceneRename/scenerename.py

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
import os, sys, json, logging, traceback
2+
from pathlib import Path
3+
from logging.handlers import RotatingFileHandler
4+
5+
# Setup file logging
6+
log_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "scenerename.log")
7+
file_logger = logging.getLogger("scenerename")
8+
file_logger.setLevel(logging.DEBUG)
9+
fh = RotatingFileHandler(log_file, maxBytes=2*1024*1024, backupCount=2)
10+
fh.setFormatter(logging.Formatter("%(asctime)s %(levelname)s: %(message)s"))
11+
file_logger.addHandler(fh)
12+
13+
try:
14+
import stashapi.log as log
15+
from stashapi.stashapp import StashInterface
16+
except ModuleNotFoundError:
17+
print("stashapi not found", file=sys.stderr)
18+
sys.exit(1)
19+
20+
SCENE_FRAGMENT = "id title code studio {name} files {id path width height} date"
21+
22+
23+
def get_json_input():
24+
return json.loads(sys.stdin.read())
25+
26+
27+
def get_stash(json_input):
28+
conn = json_input["server_connection"]
29+
host = conn["Host"]
30+
if host == "0.0.0.0":
31+
host = "localhost"
32+
stash_conn = {
33+
"Scheme": conn["Scheme"],
34+
"Host": host,
35+
"Port": conn["Port"],
36+
}
37+
if conn.get("SessionCookie"):
38+
stash_conn["SessionCookie"] = conn["SessionCookie"]
39+
if conn.get("ApiKey"):
40+
stash_conn["ApiKey"] = conn["ApiKey"]
41+
return StashInterface(stash_conn)
42+
43+
44+
def get_settings(json_input, stash):
45+
settings = {"dryRun": False, "debugTracing": False}
46+
try:
47+
config = stash.call_GQL("query Configuration { configuration { plugins }}")
48+
plugins_config = config.get("configuration", {}).get("plugins", {})
49+
if "scenerename" in plugins_config:
50+
s = plugins_config["scenerename"]
51+
settings["dryRun"] = s.get("dryRun", False)
52+
settings["debugTracing"] = s.get("debugTracing", False)
53+
except Exception:
54+
pass
55+
return settings
56+
57+
58+
def replace_illegal_chars(filename):
59+
for ch in ["<", ">", '"', "/", "\\", "|", "?", "*"]:
60+
filename = filename.replace(ch, "-")
61+
return filename
62+
63+
64+
def get_resolution_label(height):
65+
h = int(height)
66+
if h >= 2160:
67+
return "4K"
68+
elif h >= 1440:
69+
return "1440p"
70+
elif h >= 1080:
71+
return "1080p"
72+
elif h >= 720:
73+
return "720p"
74+
elif h >= 480:
75+
return "480p"
76+
return str(h) + "p"
77+
78+
79+
80+
def clean_title(title):
81+
"""Replace colons with commas in the title."""
82+
if not title:
83+
return ""
84+
return title.replace(":", ",")
85+
86+
87+
def form_filename(scene):
88+
"""Build filename: Studio #Code - Title [Resolution]"""
89+
# Studio Name
90+
studio = scene.get("studio")
91+
studio_name = ""
92+
if studio:
93+
studio_name = studio.get("name", "")
94+
95+
# Studio Code / Sequence
96+
code = scene.get("code") or ""
97+
98+
# Resolution
99+
resolution = ""
100+
files = scene.get("files", [])
101+
if files:
102+
height = files[0].get("height")
103+
if height:
104+
resolution = get_resolution_label(height)
105+
106+
# Full title with colons replaced by commas
107+
title = clean_title(scene.get("title", ""))
108+
109+
# Skip files without a studio name
110+
if not studio_name:
111+
return None
112+
113+
# Build: "Studio #Code - Title [Resolution]"
114+
# Start with studio name
115+
new_name = studio_name
116+
117+
# Add code if present
118+
if code:
119+
new_name = "{} #{}".format(new_name, code)
120+
121+
# Add title if present
122+
if title:
123+
new_name = "{} - {}".format(new_name, title)
124+
125+
# Add resolution at the end in brackets
126+
if resolution:
127+
new_name = "{} [{}]".format(new_name, resolution)
128+
129+
new_name = replace_illegal_chars(new_name)
130+
131+
if len(new_name) > 240:
132+
new_name = new_name[:240]
133+
134+
return new_name
135+
136+
137+
def rename_scene(stash, scene_id, dry_run=False, debug=False):
138+
scene = stash.find_scene(scene_id, SCENE_FRAGMENT)
139+
if not scene:
140+
log.error("Scene {} not found".format(scene_id))
141+
return None
142+
143+
files = scene.get("files", [])
144+
if not files:
145+
log.error("Scene {} has no files".format(scene_id))
146+
return None
147+
148+
original_path = files[0]["path"]
149+
if not os.path.isfile(original_path):
150+
log.error("File does not exist: {}".format(original_path))
151+
return None
152+
153+
original_name = Path(original_path).name
154+
ext = Path(original_path).suffix
155+
parent = Path(original_path).parent
156+
157+
new_stem = form_filename(scene)
158+
if not new_stem:
159+
msg = "Could not form new filename - missing metadata (need at least one of: studio, code, title)"
160+
log.info(msg)
161+
file_logger.info(msg)
162+
return None
163+
164+
new_name = new_stem + ext
165+
new_path = str(parent / new_name)
166+
167+
if original_name == new_name:
168+
msg = "No change needed: {}".format(original_name)
169+
log.info(msg)
170+
file_logger.info(msg)
171+
return None
172+
173+
# Handle duplicates - append (2), (3), etc. if target already exists
174+
if os.path.isfile(new_path) and new_path != original_path:
175+
counter = 2
176+
while True:
177+
dup_name = "{} ({}){}".format(new_stem, counter, ext)
178+
dup_path = str(parent / dup_name)
179+
if dup_path == original_path:
180+
# Already at the correct duplicate counter - no rename needed
181+
msg = "Already correctly named: {}".format(original_name)
182+
log.info(msg)
183+
file_logger.info(msg)
184+
return None
185+
if not os.path.isfile(dup_path):
186+
new_name = dup_name
187+
new_path = dup_path
188+
file_logger.warning("Duplicate detected, using: {}".format(new_name))
189+
break
190+
counter += 1
191+
192+
prefix = "[DRY RUN] " if dry_run else ""
193+
msg = "{}Changing from '{}' to '{}'".format(prefix, original_name, new_name)
194+
log.info(msg)
195+
file_logger.info(msg)
196+
197+
if debug:
198+
studio = scene.get("studio")
199+
studio_name = studio.get("name") if studio else "N/A"
200+
file_logger.debug(" Studio: {}".format(studio_name))
201+
file_logger.debug(" Code: {}".format(scene.get("code", "N/A")))
202+
file_logger.debug(" Title: {}".format(scene.get("title", "N/A")))
203+
file_logger.debug(" Height: {}".format(files[0].get("height", "N/A")))
204+
file_logger.debug(" Full path: {} -> {}".format(original_path, new_path))
205+
206+
if dry_run:
207+
return new_stem
208+
209+
try:
210+
os.rename(original_path, new_path)
211+
msg = "Renamed successfully: {}".format(new_path)
212+
log.info(msg)
213+
file_logger.info(msg)
214+
stash.metadata_scan(paths=[str(parent)])
215+
except OSError as e:
216+
msg = "Failed to rename: {}".format(e)
217+
log.error(msg)
218+
file_logger.error(msg)
219+
return None
220+
221+
return new_stem
222+
223+
224+
def main():
225+
json_input = get_json_input()
226+
stash = get_stash(json_input)
227+
settings = get_settings(json_input, stash)
228+
dry_run = settings["dryRun"]
229+
debug = settings["debugTracing"]
230+
231+
mode = json_input.get("args", {}).get("mode", "")
232+
233+
# Force dry run for the dry run task
234+
if mode == "dry_run_last":
235+
dry_run = True
236+
237+
if mode in ("rename_last", "dry_run_last"):
238+
result = stash.call_GQL("query { allScenes { id updated_at } }")
239+
all_scenes = result.get("allScenes", [])
240+
if not all_scenes:
241+
log.info("No scenes found")
242+
return
243+
latest = max(all_scenes, key=lambda s: s["updated_at"])
244+
rename_scene(stash, latest["id"], dry_run=dry_run, debug=debug)
245+
else:
246+
# Hook mode - Scene.Update.Post
247+
try:
248+
hook_context = json_input["args"]["hookContext"]
249+
scene_id = hook_context["id"]
250+
rename_scene(stash, scene_id, dry_run=dry_run, debug=debug)
251+
except (KeyError, TypeError) as e:
252+
file_logger.error("Could not get scene ID from hook: {}".format(e))
253+
log.error("Could not get scene ID from hook: {}".format(e))
254+
255+
256+
if __name__ == "__main__":
257+
main()
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
name: SceneRename
2+
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."
3+
version: 1.0.1
4+
url: https://discourse.stashapp.cc/t/scenerename/5795
5+
settings:
6+
dryRun:
7+
displayName: Dry Run
8+
description: "Enable to preview renames in the log without actually renaming files."
9+
type: BOOLEAN
10+
debugTracing:
11+
displayName: Debug Tracing
12+
description: "Enable verbose debug logging to scenerename.log"
13+
type: BOOLEAN
14+
exec:
15+
- python
16+
- "{pluginDir}/scenerename.py"
17+
interface: raw
18+
hooks:
19+
- name: SceneRenameHook
20+
description: Renames scene file on update.
21+
triggeredBy:
22+
- Scene.Update.Post
23+
tasks:
24+
- name: Rename Last Updated Scene
25+
description: Renames the most recently updated scene file.
26+
defaultArgs:
27+
mode: rename_last
28+
- name: Dry Run Last Updated Scene
29+
description: Logs what the rename would be without changing files.
30+
defaultArgs:
31+
mode: dry_run_last
43.7 KB
Loading

0 commit comments

Comments
 (0)