From 785fa73263577081115c7c3baeeb30acc718f2b9 Mon Sep 17 00:00:00 2001 From: lasagna Date: Sat, 27 Jun 2026 15:48:07 -0300 Subject: [PATCH 1/6] fix(RenameFile): prevent scene title freezing on Next navigation The scene title is rendered by a React-controlled TruncatedText component. The plugin replaced its contents with an element (innerHTML = ''), which detached the text node React updates. Navigating to another scene with Next then left the previous title displayed while every other field updated. Switch to event delegation: a single document-level click listener reads the title at click time, so React's DOM is never restructured. The styling class and tooltip are now applied non-destructively (attributes only). Co-Authored-By: Claude Opus 4.8 (1M context) --- plugins/RenameFile/renamefile.css | 4 +-- plugins/RenameFile/renamefile.js | 54 ++++++++++++++++++++----------- plugins/RenameFile/renamefile.yml | 2 +- 3 files changed, 38 insertions(+), 22 deletions(-) diff --git a/plugins/RenameFile/renamefile.css b/plugins/RenameFile/renamefile.css index e50d7828..b1289d2a 100644 --- a/plugins/RenameFile/renamefile.css +++ b/plugins/RenameFile/renamefile.css @@ -1,7 +1,7 @@ .renamefile { - color: unset; + cursor: pointer; &:hover { - text-decoration: unset; + text-decoration: underline; } &:active { color: white; diff --git a/plugins/RenameFile/renamefile.js b/plugins/RenameFile/renamefile.js index cc485b2f..2a438ac8 100644 --- a/plugins/RenameFile/renamefile.js +++ b/plugins/RenameFile/renamefile.js @@ -72,30 +72,46 @@ } } - function wrapElement(element) { - var text = element.textContent.trim(); - var anchor = document.createElement('a'); - anchor.href = '#'; - anchor.textContent = text; - anchor.classList.add('renamefile'); - anchor.title = 'Click to append title to [Title] input field; OR ctrl-key & mouse click to copy title to clipboard; OR shift-key click to copy to [Title] input field; OR alt-key click to copy file URI to clipboard.'; - anchor.addEventListener('click', function(event) { - event.preventDefault(); - AppendTitleField(text, event); - }); - element.innerHTML = ''; - element.appendChild(anchor); + var TITLE_SELECTOR = '.scene-header div.TruncatedText'; + var TOOLTIP = 'Click to append title to [Title] input field; OR ctrl-key & mouse click to copy title to clipboard; OR shift-key click to copy to [Title] input field; OR alt-key click to copy file URI to clipboard.'; + + // Use event delegation instead of replacing the title element. The scene + // title is a React-controlled component; restructuring its DOM (e.g. + // wrapping it in an ) detaches the text node React updates, so the title + // freezes on the previous scene when navigating with Next. By listening on + // document and reading the title at click time, we never touch React's DOM. + document.addEventListener('click', function(event) { + var title = event.target.closest(TITLE_SELECTOR); + if (!title) return; + event.preventDefault(); + AppendTitleField(title.textContent.trim(), event); + }); + + // Non-destructive affordance: add the styling class and tooltip without + // touching the element's children, so React's text node stays intact. + function decorate(element) { + if (!element.classList.contains('renamefile')) { + element.classList.add('renamefile'); + } + if (element.getAttribute('title') !== TOOLTIP) { + element.setAttribute('title', TOOLTIP); + } } - function handleMutations(mutationsList, observer) { - for(const mutation of mutationsList) { - for(const addedNode of mutation.addedNodes) { - if (addedNode.nodeType === Node.ELEMENT_NODE && addedNode.querySelector('.scene-header div.TruncatedText')) { - wrapElement(addedNode.querySelector('.scene-header div.TruncatedText')); - } + function handleMutations(mutationsList) { + for (const mutation of mutationsList) { + for (const addedNode of mutation.addedNodes) { + if (addedNode.nodeType !== Node.ELEMENT_NODE) continue; + var title = addedNode.matches && addedNode.matches(TITLE_SELECTOR) + ? addedNode + : addedNode.querySelector && addedNode.querySelector(TITLE_SELECTOR); + if (title) decorate(title); } } } const observer = new MutationObserver(handleMutations); observer.observe(document.body, { childList: true, subtree: true }); + + var existing = document.querySelector(TITLE_SELECTOR); + if (existing) decorate(existing); })(); diff --git a/plugins/RenameFile/renamefile.yml b/plugins/RenameFile/renamefile.yml index 70ec5e24..15ab39b4 100644 --- a/plugins/RenameFile/renamefile.yml +++ b/plugins/RenameFile/renamefile.yml @@ -1,6 +1,6 @@ name: RenameFile description: Renames video (scene) file names when the user edits the [Title] field located in the scene [Edit] tab. -version: 1.0.0 +version: 1.0.1 url: https://discourse.stashapp.cc/t/renamefile/1334 ui: css: From a50935d957dd6738fb71fb9693f8f0fea1e25bbe Mon Sep 17 00:00:00 2001 From: lasagna Date: Sun, 28 Jun 2026 01:18:21 -0300 Subject: [PATCH 2/6] feat: add StashMetadataMapper plugin (Phase 1) Exports Stash scene metadata into MP4 atoms using mutagen. Writes title, description, date, studio, cast, rating, artwork, tags as keywords, and Stash/StashDB IDs as custom com.stash atoms. Supports batch export task, dry-run preview task, and hook-based auto-export on Scene.Update.Post. Auto-installs missing Python dependencies (mutagen, requests) on first run. Co-Authored-By: Claude Sonnet 4.6 --- .../StashMetadataMapper.py | 325 ++++++++++++++++++ .../StashMetadataMapper.yml | 68 ++++ plugins/StashMetadataMapper/requirements.txt | 2 + .../StashMetadataMapper/stash_interface.py | 87 +++++ 4 files changed, 482 insertions(+) create mode 100644 plugins/StashMetadataMapper/StashMetadataMapper.py create mode 100644 plugins/StashMetadataMapper/StashMetadataMapper.yml create mode 100644 plugins/StashMetadataMapper/requirements.txt create mode 100644 plugins/StashMetadataMapper/stash_interface.py diff --git a/plugins/StashMetadataMapper/StashMetadataMapper.py b/plugins/StashMetadataMapper/StashMetadataMapper.py new file mode 100644 index 00000000..632e4821 --- /dev/null +++ b/plugins/StashMetadataMapper/StashMetadataMapper.py @@ -0,0 +1,325 @@ +#!/usr/bin/env python3 +"""Stash MP4 Metadata Mapper — Phase 1. + +Reads scene metadata from Stash via GraphQL and writes it into MP4 +metadata atoms using mutagen. Supports batch export and hook-based +auto-export on scene update. +""" + +import json +import os +import re +import sys + +PLUGIN_ID = "StashMetadataMapper" + + +def _ensure_deps(): + import subprocess + needed = [] + try: + from mutagen.mp4 import MP4 # noqa: F401 + except ImportError: + needed.append("mutagen") + try: + import requests # noqa: F401 + except ImportError: + needed.append("requests") + if not needed: + return + print(f"[{PLUGIN_ID}] Installing missing deps: {needed}", file=sys.stderr, flush=True) + # Try install strategies in order; stop at the first one that works. + for flags in [["--user"], ["--break-system-packages"], ["--user", "--break-system-packages"], []]: + try: + subprocess.run( + [sys.executable, "-m", "pip", "install", "--quiet"] + flags + needed, + check=True, capture_output=True, + ) + return + except subprocess.CalledProcessError: + continue + print( + f"[{PLUGIN_ID}] ERROR: Could not auto-install deps. " + f"Run manually: {sys.executable} -m pip install {' '.join(needed)}", + file=sys.stderr, flush=True, + ) + sys.exit(1) + +_ensure_deps() + +from mutagen.mp4 import MP4, MP4Cover, MP4FreeForm, AtomDataType # noqa: E402 + +from stash_interface import StashInterface + +MP4_EXTENSIONS = {".mp4", ".m4v"} + +# Maps StashDB endpoint URLs to human-readable atom key suffixes +ENDPOINT_ATOM_KEYS = { + "https://stashdb.org/graphql": "stashdb_id", + "https://theporndb.net/graphql": "tpdb_id", +} + +ATOM_LABELS = { + "©nam": "title", + "©cmt": "description", + "©day": "date", + "aART": "studio (album artist)", + "©ART": "cast (artist)", + "covr": "artwork", + "keyw": "keywords", +} + +DEFAULT_SETTINGS = { + "exportTitle": True, + "exportDescription": True, + "exportDate": True, + "exportStudio": True, + "exportCast": True, + "exportRating": True, + "exportArtwork": True, + "exportTagsAsKeywords": True, + "exportStashIds": True, +} + + +def log(msg, level="INFO"): + print(f"[{PLUGIN_ID}] [{level}] {msg}", file=sys.stderr, flush=True) + + +def eprint(msg): + """Write a line directly to stderr (used for dry-run preview blocks).""" + print(msg, file=sys.stderr, flush=True) + + +def get_settings(stash): + config = stash.get_plugin_config() + settings = dict(DEFAULT_SETTINGS) + for key, value in config.items(): + if key in settings: + settings[key] = bool(value) + return settings + + +def _endpoint_to_key(endpoint): + key = re.sub(r"https?://", "", endpoint) + key = re.sub(r"[^a-zA-Z0-9]", "_", key).strip("_") + return key[:40] + + +def fetch_artwork(stash, scene): + screenshot_url = (scene.get("paths") or {}).get("screenshot") + if not screenshot_url: + return None, None + try: + data = stash.fetch_bytes(screenshot_url) + fmt = MP4Cover.FORMAT_PNG if data[:8] == b"\x89PNG\r\n\x1a\n" else MP4Cover.FORMAT_JPEG + return data, fmt + except Exception as exc: + log(f"Could not fetch artwork: {exc}", "WARNING") + return None, None + + +def build_metadata(scene, settings, stash): + """Build a dict of MP4 atom key → value for the scene.""" + meta = {} + + if settings["exportTitle"] and scene.get("title"): + meta["©nam"] = [scene["title"]] + + if settings["exportDescription"] and scene.get("details"): + meta["©cmt"] = [scene["details"]] + + if settings["exportDate"] and scene.get("date"): + meta["©day"] = [scene["date"]] + + if settings["exportStudio"] and scene.get("studio"): + name = scene["studio"]["name"] + meta["aART"] = [name] + meta["----:com.stash:studio"] = [MP4FreeForm(name.encode("utf-8"), AtomDataType.UTF8)] + + if settings["exportCast"] and scene.get("performers"): + meta["©ART"] = [", ".join(p["name"] for p in scene["performers"])] + + if settings["exportRating"] and scene.get("rating100") is not None: + meta["----:com.stash:rating100"] = [ + MP4FreeForm(str(scene["rating100"]).encode("utf-8"), AtomDataType.UTF8) + ] + + if settings["exportTagsAsKeywords"] and scene.get("tags"): + meta["keyw"] = ["; ".join(t["name"] for t in scene["tags"])] + + if settings["exportStashIds"]: + meta["----:com.stash:id"] = [ + MP4FreeForm(str(scene["id"]).encode("utf-8"), AtomDataType.UTF8) + ] + for sid in scene.get("stash_ids") or []: + endpoint = sid.get("endpoint", "") + stash_id = sid.get("stash_id", "") + suffix = ENDPOINT_ATOM_KEYS.get(endpoint, _endpoint_to_key(endpoint)) + meta[f"----:com.stash:{suffix}"] = [ + MP4FreeForm(stash_id.encode("utf-8"), AtomDataType.UTF8) + ] + + if settings["exportArtwork"]: + artwork_data, artwork_fmt = fetch_artwork(stash, scene) + if artwork_data: + meta["covr"] = [MP4Cover(artwork_data, imageformat=artwork_fmt)] + + return meta + + +def _format_val(val): + if val is None: + return "" + if isinstance(val, list) and not val: + return "" + item = val[0] if isinstance(val, list) else val + if isinstance(item, MP4Cover): + fmt = "JPEG" if item.imageformat == MP4Cover.FORMAT_JPEG else "PNG" + return f"[{fmt}, {len(item):,} bytes]" + if isinstance(item, MP4FreeForm): + return repr(bytes(item).decode("utf-8", errors="replace")) + return repr(str(item)) + + +def _atom_label(key): + if key.startswith("----:com.stash:"): + return key.split(":", 2)[-1] + return ATOM_LABELS.get(key, key) + + +def process_scene(stash, scene, settings, dry_run): + scene_id = scene.get("id", "?") + files = scene.get("files") or [] + + if not files: + log(f"Scene {scene_id}: no files, skipping") + return + + new_meta = build_metadata(scene, settings, stash) + + if not new_meta: + log(f"Scene {scene_id}: nothing to write (all fields disabled or empty)") + return + + for f in files: + path = f.get("path", "") + ext = os.path.splitext(path)[1].lower() + if ext not in MP4_EXTENSIONS: + log(f"Scene {scene_id}: skipping non-MP4 file: {path}") + continue + if not os.path.exists(path): + log(f"Scene {scene_id}: file not found: {path}", "WARNING") + continue + + if dry_run: + _preview(path, scene_id, new_meta) + else: + _write(path, scene_id, new_meta) + + +def _preview(path, scene_id, new_meta): + try: + mp4 = MP4(path) + current = mp4.tags or {} + except Exception: + current = {} + + eprint(f"\n[DRY RUN] Scene {scene_id}: {path}") + changed = 0 + for key, new_val in new_meta.items(): + old_val = current.get(key) + old_str = _format_val(old_val) + new_str = _format_val(new_val) + if old_str != new_str: + label = _atom_label(key) + eprint(f" {key} ({label})") + eprint(f" before: {old_str}") + eprint(f" after: {new_str}") + changed += 1 + + if changed == 0: + eprint(" [no changes]") + else: + eprint(f" [WOULD WRITE {changed} atom(s)]") + + +def _write(path, scene_id, new_meta): + try: + mp4 = MP4(path) + if mp4.tags is None: + mp4.add_tags() + for key, val in new_meta.items(): + mp4.tags[key] = val + mp4.save() + log(f"Scene {scene_id}: wrote {len(new_meta)} atom(s) → {path}") + except Exception as exc: + log(f"Scene {scene_id}: failed writing {path}: {exc}", "ERROR") + + +def handle_hook(stash, args, settings, dry_run): + hook_ctx = args.get("hookContext") or {} + scene_id = hook_ctx.get("id") + if not scene_id: + log("Hook fired with no scene ID in hookContext", "WARNING") + return "no scene id" + scene = stash.find_scene(scene_id) + if not scene: + log(f"Scene {scene_id} not found", "WARNING") + return f"scene {scene_id} not found" + process_scene(stash, scene, settings, dry_run) + return f"processed scene {scene_id}" + + +def handle_export(stash, settings, dry_run): + page = 1 + per_page = 100 + total = 0 + + while True: + result = stash.find_scenes(page=page, per_page=per_page) + if not result: + break + scenes = result.get("scenes") or [] + count = result.get("count", 0) + if not scenes: + break + for scene in scenes: + process_scene(stash, scene, settings, dry_run) + total += 1 + log(f"Progress: {total}/{count}") + if total >= count: + break + page += 1 + + mode_label = "previewed" if dry_run else "processed" + return f"{mode_label} {total} scene(s)" + + +def main(): + raw = sys.stdin.read() + plugin_input = json.loads(raw) + + conn = plugin_input.get("server_connection") or {} + args = plugin_input.get("args") or {} + mode = args.get("mode", "export") + dry_run = str(args.get("dryRun", "false")).lower() == "true" + + stash = StashInterface(conn) + settings = get_settings(stash) + + log(f"Starting — mode={mode} dry_run={dry_run}") + + if mode == "hook": + result = handle_hook(stash, args, settings, dry_run) + elif mode == "export": + result = handle_export(stash, settings, dry_run) + else: + result = f"unknown mode: {mode}" + log(result, "ERROR") + + print(json.dumps({"output": result})) + + +if __name__ == "__main__": + main() diff --git a/plugins/StashMetadataMapper/StashMetadataMapper.yml b/plugins/StashMetadataMapper/StashMetadataMapper.yml new file mode 100644 index 00000000..db363a40 --- /dev/null +++ b/plugins/StashMetadataMapper/StashMetadataMapper.yml @@ -0,0 +1,68 @@ +name: StashMetadataMapper +description: Export scene metadata into MP4 metadata atoms for portable, self-describing media files +version: 0.1.0 +url: https://github.com/stashapp/CommunityScripts +exec: + - python + - "{pluginDir}/StashMetadataMapper.py" +interface: raw +errLog: warning + +settings: + exportTitle: + displayName: Export Title + description: Write scene title to the MP4 title atom (©nam) + type: BOOLEAN + exportDescription: + displayName: Export Description + description: Write scene details to the MP4 comment atom (©cmt) + type: BOOLEAN + exportDate: + displayName: Export Date + description: Write scene date to the MP4 release date atom (©day) + type: BOOLEAN + exportStudio: + displayName: Export Studio + description: Write studio name to album artist (aART) and a custom com.stash atom + type: BOOLEAN + exportCast: + displayName: Export Cast (Performers) + description: Write performer names to the MP4 artist atom (©ART), comma-separated + type: BOOLEAN + exportRating: + displayName: Export Rating + description: Write the 0–100 scene rating to a custom com.stash atom + type: BOOLEAN + exportArtwork: + displayName: Export Artwork + description: Embed the scene cover image into the MP4 artwork atom (covr) + type: BOOLEAN + exportTagsAsKeywords: + displayName: Export Tags as Keywords + description: Write all scene tags to the MP4 keywords atom (keyw) + type: BOOLEAN + exportStashIds: + displayName: Export Stash IDs + description: Write the internal Stash ID and any StashDB/TPDB IDs as custom com.stash atoms + type: BOOLEAN + +tasks: + - name: Export Metadata + description: Write scene metadata to all MP4 files in the library + defaultArgs: + mode: export + dryRun: "false" + - name: Export Metadata (Dry Run) + description: Preview what metadata would be written without modifying any files + defaultArgs: + mode: export + dryRun: "true" + +hooks: + - name: Auto-export on scene update + description: Automatically write metadata to the MP4 file when a scene is saved in Stash + triggeredBy: + - Scene.Update.Post + defaultArgs: + mode: hook + dryRun: "false" diff --git a/plugins/StashMetadataMapper/requirements.txt b/plugins/StashMetadataMapper/requirements.txt new file mode 100644 index 00000000..e1ea0205 --- /dev/null +++ b/plugins/StashMetadataMapper/requirements.txt @@ -0,0 +1,2 @@ +mutagen>=1.47.0 +requests>=2.28.0 diff --git a/plugins/StashMetadataMapper/stash_interface.py b/plugins/StashMetadataMapper/stash_interface.py new file mode 100644 index 00000000..b5008713 --- /dev/null +++ b/plugins/StashMetadataMapper/stash_interface.py @@ -0,0 +1,87 @@ +import requests + + +class StashInterface: + def __init__(self, conn): + scheme = conn.get("Scheme", "http") + host = conn.get("Host", "localhost") + port = conn.get("Port", 9999) + self.base_url = f"{scheme}://{host}:{port}" + self.graphql_url = f"{self.base_url}/graphql" + self.session = requests.Session() + cookie = conn.get("SessionCookie") or {} + if cookie.get("Name") and cookie.get("Value"): + self.session.cookies.set(cookie["Name"], cookie["Value"]) + api_key = conn.get("ApiKey") or "" + if api_key: + self.session.headers["ApiKey"] = api_key + + def call_gql(self, query, variables=None): + payload = {"query": query} + if variables: + payload["variables"] = variables + resp = self.session.post(self.graphql_url, json=payload) + resp.raise_for_status() + data = resp.json() + if "errors" in data: + raise Exception(f"GraphQL errors: {data['errors']}") + return data.get("data") + + def fetch_bytes(self, url): + if url.startswith("/"): + url = self.base_url + url + resp = self.session.get(url) + resp.raise_for_status() + return resp.content + + def get_plugin_config(self): + query = "query { configuration { plugins } }" + result = self.call_gql(query) + if result: + return result.get("configuration", {}).get("plugins", {}).get("StashMetadataMapper", {}) + return {} + + def find_scene(self, scene_id): + query = """ + query FindScene($id: ID!) { + findScene(id: $id) { + id title details date rating100 + files { path } + studio { name } + performers { name } + tags { name } + paths { screenshot } + stash_ids { endpoint stash_id } + } + } + """ + result = self.call_gql(query, {"id": str(scene_id)}) + return result.get("findScene") if result else None + + def find_scenes(self, page=1, per_page=100): + query = """ + query FindScenes($filter: FindFilterType) { + findScenes(filter: $filter) { + count + scenes { + id title details date rating100 + files { path } + studio { name } + performers { name } + tags { name } + paths { screenshot } + stash_ids { endpoint stash_id } + } + } + } + """ + variables = { + "filter": { + "page": page, + "per_page": per_page, + "sort": "id", + "direction": "ASC", + } + } + result = self.call_gql(query, variables) + return result.get("findScenes") if result else None From a48b2ea8de9099c0d59972aa217fe57c95750ef4 Mon Sep 17 00:00:00 2001 From: lasagna Date: Sun, 28 Jun 2026 01:29:23 -0300 Subject: [PATCH 3/6] fix: use proper video metadata atoms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - stik=9 (Movie) so players know to render video metadata fields - ldes (long description) instead of ©cmt (music comment) for details Co-Authored-By: Claude Sonnet 4.6 --- plugins/StashMetadataMapper/StashMetadataMapper.py | 7 +++++-- plugins/StashMetadataMapper/StashMetadataMapper.yml | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/plugins/StashMetadataMapper/StashMetadataMapper.py b/plugins/StashMetadataMapper/StashMetadataMapper.py index 632e4821..cfbaa0cc 100644 --- a/plugins/StashMetadataMapper/StashMetadataMapper.py +++ b/plugins/StashMetadataMapper/StashMetadataMapper.py @@ -60,8 +60,9 @@ def _ensure_deps(): } ATOM_LABELS = { + "stik": "media kind (movie=9)", "©nam": "title", - "©cmt": "description", + "ldes": "long description", "©day": "date", "aART": "studio (album artist)", "©ART": "cast (artist)", @@ -123,11 +124,13 @@ def build_metadata(scene, settings, stash): """Build a dict of MP4 atom key → value for the scene.""" meta = {} + meta["stik"] = [9] # Media kind: Movie — tells players to use video metadata fields + if settings["exportTitle"] and scene.get("title"): meta["©nam"] = [scene["title"]] if settings["exportDescription"] and scene.get("details"): - meta["©cmt"] = [scene["details"]] + meta["ldes"] = [scene["details"]] if settings["exportDate"] and scene.get("date"): meta["©day"] = [scene["date"]] diff --git a/plugins/StashMetadataMapper/StashMetadataMapper.yml b/plugins/StashMetadataMapper/StashMetadataMapper.yml index db363a40..7c3d6359 100644 --- a/plugins/StashMetadataMapper/StashMetadataMapper.yml +++ b/plugins/StashMetadataMapper/StashMetadataMapper.yml @@ -15,7 +15,7 @@ settings: type: BOOLEAN exportDescription: displayName: Export Description - description: Write scene details to the MP4 comment atom (©cmt) + description: Write scene details to the MP4 long description atom (ldes) type: BOOLEAN exportDate: displayName: Export Date From b7d22914744c0377ef45507d329e27ea930ca776 Mon Sep 17 00:00:00 2001 From: lasagna Date: Sun, 28 Jun 2026 02:36:21 -0300 Subject: [PATCH 4/6] chore: add .gitignore for plugin directory Co-Authored-By: Claude Sonnet 4.6 --- plugins/StashMetadataMapper/.gitignore | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 plugins/StashMetadataMapper/.gitignore diff --git a/plugins/StashMetadataMapper/.gitignore b/plugins/StashMetadataMapper/.gitignore new file mode 100644 index 00000000..7a60b85e --- /dev/null +++ b/plugins/StashMetadataMapper/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc From a8081e6d27ec903c933c964bccc50bd1db9f4365 Mon Sep 17 00:00:00 2001 From: lasagna Date: Sun, 28 Jun 2026 02:38:23 -0300 Subject: [PATCH 5/6] =?UTF-8?q?feat:=20Phase=202=20=E2=80=94=20configurabl?= =?UTF-8?q?e=20tag=20routing=20(genre,=20keywords,=20ignore)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two new STRING plugin settings: genreTagNames — comma-separated Stash tag names to write to ©gen (genre) instead of keyw. Case-insensitive, preserves original case. ignoreTagNames — comma-separated tag names to exclude from all export. Logic: - Tags in ignoreTagNames are always skipped. - Tags in genreTagNames go to ©gen (comma-joined). - Remaining tags go to keyw (semi-colon-joined) when exportTagsAsKeywords=true. - Genre routing is independent of exportTagsAsKeywords; setting genreTagNames alone (with exportTagsAsKeywords=false) still writes ©gen. - Empty genreTagNames/ignoreTagNames = previous behaviour (all tags → keyw). Co-Authored-By: Claude Sonnet 4.6 --- .../StashMetadataMapper.py | 40 +++++++++++++++++-- .../StashMetadataMapper.yml | 14 ++++++- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/plugins/StashMetadataMapper/StashMetadataMapper.py b/plugins/StashMetadataMapper/StashMetadataMapper.py index cfbaa0cc..8e726ae3 100644 --- a/plugins/StashMetadataMapper/StashMetadataMapper.py +++ b/plugins/StashMetadataMapper/StashMetadataMapper.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Stash MP4 Metadata Mapper — Phase 1. +"""Stash MP4 Metadata Mapper — Phase 2. Reads scene metadata from Stash via GraphQL and writes it into MP4 metadata atoms using mutagen. Supports batch export and hook-based @@ -67,6 +67,7 @@ def _ensure_deps(): "aART": "studio (album artist)", "©ART": "cast (artist)", "covr": "artwork", + "©gen": "genre", "keyw": "keywords", } @@ -79,9 +80,14 @@ def _ensure_deps(): "exportRating": True, "exportArtwork": True, "exportTagsAsKeywords": True, + "genreTagNames": "", + "ignoreTagNames": "", "exportStashIds": True, } +# Settings that are strings, not booleans +_STRING_SETTINGS = {"genreTagNames", "ignoreTagNames"} + def log(msg, level="INFO"): print(f"[{PLUGIN_ID}] [{level}] {msg}", file=sys.stderr, flush=True) @@ -96,11 +102,22 @@ def get_settings(stash): config = stash.get_plugin_config() settings = dict(DEFAULT_SETTINGS) for key, value in config.items(): - if key in settings: + if key not in settings: + continue + if key in _STRING_SETTINGS: + settings[key] = str(value) if value is not None else "" + else: settings[key] = bool(value) return settings +def _parse_tag_set(s): + """Parse a comma-separated setting into a lowercase set of tag names.""" + if not s: + return set() + return {t.strip().lower() for t in s.split(",") if t.strip()} + + def _endpoint_to_key(endpoint): key = re.sub(r"https?://", "", endpoint) key = re.sub(r"[^a-zA-Z0-9]", "_", key).strip("_") @@ -148,8 +165,23 @@ def build_metadata(scene, settings, stash): MP4FreeForm(str(scene["rating100"]).encode("utf-8"), AtomDataType.UTF8) ] - if settings["exportTagsAsKeywords"] and scene.get("tags"): - meta["keyw"] = ["; ".join(t["name"] for t in scene["tags"])] + tags = scene.get("tags") or [] + if tags and (settings["exportTagsAsKeywords"] or settings["genreTagNames"]): + genre_set = _parse_tag_set(settings["genreTagNames"]) + ignore_set = _parse_tag_set(settings["ignoreTagNames"]) + genres, keywords = [], [] + for tag in tags: + name = tag["name"] + if name.lower() in ignore_set: + continue + if name.lower() in genre_set: + genres.append(name) + elif settings["exportTagsAsKeywords"]: + keywords.append(name) + if genres: + meta["©gen"] = [", ".join(genres)] + if keywords: + meta["keyw"] = ["; ".join(keywords)] if settings["exportStashIds"]: meta["----:com.stash:id"] = [ diff --git a/plugins/StashMetadataMapper/StashMetadataMapper.yml b/plugins/StashMetadataMapper/StashMetadataMapper.yml index 7c3d6359..bd518454 100644 --- a/plugins/StashMetadataMapper/StashMetadataMapper.yml +++ b/plugins/StashMetadataMapper/StashMetadataMapper.yml @@ -39,8 +39,20 @@ settings: type: BOOLEAN exportTagsAsKeywords: displayName: Export Tags as Keywords - description: Write all scene tags to the MP4 keywords atom (keyw) + description: Write non-genre scene tags to the MP4 keywords atom (keyw) type: BOOLEAN + genreTagNames: + displayName: Genre Tag Names + description: > + Comma-separated Stash tag names to route into the MP4 genre atom (©gen) + instead of keywords. Case-insensitive. Example: Amateur, Teen, MILF + type: STRING + ignoreTagNames: + displayName: Ignored Tag Names + description: > + Comma-separated Stash tag names to exclude from all metadata export. + Case-insensitive. Example: HD, 4K, VR + type: STRING exportStashIds: displayName: Export Stash IDs description: Write the internal Stash ID and any StashDB/TPDB IDs as custom com.stash atoms From 55cdc4ffa2a6f384d2d66f3832941ce86520214b Mon Sep 17 00:00:00 2001 From: lasagna Date: Sun, 28 Jun 2026 13:17:19 -0300 Subject: [PATCH 6/6] feat: switch tag matching to case-insensitive substring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit genreTagNames and ignoreTagNames now match any tag whose name *contains* the pattern, not just exact matches. This lets a single entry like 'º' match '180º', '190º', '360º' etc., and 'HD' match 'HD', 'HD 4K', etc. Co-Authored-By: Claude Sonnet 4.6 --- .../StashMetadataMapper.py | 22 ++++++++++++------- .../StashMetadataMapper.yml | 10 +++++---- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/plugins/StashMetadataMapper/StashMetadataMapper.py b/plugins/StashMetadataMapper/StashMetadataMapper.py index 8e726ae3..a29d9379 100644 --- a/plugins/StashMetadataMapper/StashMetadataMapper.py +++ b/plugins/StashMetadataMapper/StashMetadataMapper.py @@ -111,11 +111,16 @@ def get_settings(stash): return settings -def _parse_tag_set(s): - """Parse a comma-separated setting into a lowercase set of tag names.""" +def _parse_tag_patterns(s): + """Parse a comma-separated setting into a list of lowercase substrings to match against tag names.""" if not s: - return set() - return {t.strip().lower() for t in s.split(",") if t.strip()} + return [] + return [t.strip().lower() for t in s.split(",") if t.strip()] + + +def _tag_matches(name_lower, patterns): + """Return True if name_lower contains any of the patterns as a substring.""" + return any(p in name_lower for p in patterns) def _endpoint_to_key(endpoint): @@ -167,14 +172,15 @@ def build_metadata(scene, settings, stash): tags = scene.get("tags") or [] if tags and (settings["exportTagsAsKeywords"] or settings["genreTagNames"]): - genre_set = _parse_tag_set(settings["genreTagNames"]) - ignore_set = _parse_tag_set(settings["ignoreTagNames"]) + genre_patterns = _parse_tag_patterns(settings["genreTagNames"]) + ignore_patterns = _parse_tag_patterns(settings["ignoreTagNames"]) genres, keywords = [], [] for tag in tags: name = tag["name"] - if name.lower() in ignore_set: + nl = name.lower() + if ignore_patterns and _tag_matches(nl, ignore_patterns): continue - if name.lower() in genre_set: + if genre_patterns and _tag_matches(nl, genre_patterns): genres.append(name) elif settings["exportTagsAsKeywords"]: keywords.append(name) diff --git a/plugins/StashMetadataMapper/StashMetadataMapper.yml b/plugins/StashMetadataMapper/StashMetadataMapper.yml index bd518454..e0f50f1f 100644 --- a/plugins/StashMetadataMapper/StashMetadataMapper.yml +++ b/plugins/StashMetadataMapper/StashMetadataMapper.yml @@ -44,14 +44,16 @@ settings: genreTagNames: displayName: Genre Tag Names description: > - Comma-separated Stash tag names to route into the MP4 genre atom (©gen) - instead of keywords. Case-insensitive. Example: Amateur, Teen, MILF + Comma-separated substrings — any tag whose name contains one of these + (case-insensitive) goes to the MP4 genre atom (©gen) instead of keywords. + Example: Amateur, Teen, MILF type: STRING ignoreTagNames: displayName: Ignored Tag Names description: > - Comma-separated Stash tag names to exclude from all metadata export. - Case-insensitive. Example: HD, 4K, VR + Comma-separated substrings — any tag whose name contains one of these + (case-insensitive) is excluded from all metadata export. + Example: HD, 4K, º type: STRING exportStashIds: displayName: Export Stash IDs