') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); feat(sceneRename): rename via moveFiles and record original filename by stashdbcorrode248 · Pull Request #769 · stashapp/CommunityScripts · GitHub
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions plugins/sceneRename/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.

Expand All@@ -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:
Expand All@@ -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.
Expand Down
103 changes: 90 additions & 13 deletions plugins/sceneRename/scenerename.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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():
Expand DownExpand Up@@ -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):
Expand All@@ -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")
Expand All@@ -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]"
Expand All@@ -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:
Expand All@@ -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
Expand DownExpand Up@@ -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)
Expand Down
4 changes: 2 additions & 2 deletions plugins/sceneRename/scenerename.yml
Original file line numberDiff line numberDiff line change
@@ -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:
Expand Down