From f5c9fa93ac622c41e08a49439357de6cd4064763 Mon Sep 17 00:00:00 2001 From: unknown Date: Thu, 28 May 2026 22:46:11 -0400 Subject: [PATCH 1/3] Significant performance improvements across all taggers --- .../tagGalleriesFromImages.py | 197 ++++++++----- .../tagGalleriesFromImages.yml | 2 +- .../tagImagesFromGalleries/requirements.txt | 1 + .../tagImagesFromGalleries.py | 174 +++++++++++ .../tagImagesFromGalleries.yml | 34 +++ plugins/tagImagesWithPerfTags/manifest | 13 + .../tagImagesWithPerfTags.py | 278 +++++++++++------- .../tagImagesWithPerfTags.yml | 2 +- plugins/tagScenesWithPerfTags/manifest | 13 + .../tagScenesWithPerfTags.py | 152 +++++----- .../tagScenesWithPerfTags.yml | 2 +- 11 files changed, 622 insertions(+), 246 deletions(-) create mode 100644 plugins/tagImagesFromGalleries/requirements.txt create mode 100644 plugins/tagImagesFromGalleries/tagImagesFromGalleries.py create mode 100644 plugins/tagImagesFromGalleries/tagImagesFromGalleries.yml create mode 100644 plugins/tagImagesWithPerfTags/manifest create mode 100644 plugins/tagScenesWithPerfTags/manifest diff --git a/plugins/tagGalleriesFromImages/tagGalleriesFromImages.py b/plugins/tagGalleriesFromImages/tagGalleriesFromImages.py index bc5e5a39..ecdd8033 100755 --- a/plugins/tagGalleriesFromImages/tagGalleriesFromImages.py +++ b/plugins/tagGalleriesFromImages/tagGalleriesFromImages.py @@ -2,13 +2,22 @@ from stashapi.stashapp import StashInterface import sys import json +import time + +GALLERY_PAGE_SIZE = 150 +tag_cache = {} + def processAll(): exclusion_marker_tag_id = None - if settings["excludeWithTag"] != "": - exclussion_marker_tag = stash.find_tag(settings["excludeWithTag"]) - if exclussion_marker_tag is not None: - exclusion_marker_tag_id = exclussion_marker_tag['id'] + if settings["excludeWithTag"]: + if settings["excludeWithTag"] in tag_cache: + exclusion_marker_tag_id = tag_cache[settings["excludeWithTag"]]['id'] + else: + exclusion_marker_tag = stash.find_tag(settings["excludeWithTag"]) + if exclusion_marker_tag: + exclusion_marker_tag_id = exclusion_marker_tag['id'] + tag_cache[settings["excludeWithTag"]] = exclusion_marker_tag query = { "image_count": { @@ -18,95 +27,137 @@ def processAll(): } if settings['excludeOrganized']: query["organized"] = False - if exclusion_marker_tag_id is not None: + if exclusion_marker_tag_id: query["tags"] = { "value": [exclusion_marker_tag_id], "modifier": "EXCLUDES" } - total_count = stash.find_galleries(f=query, filter={"page": 0, "per_page": 0}, get_count=True)[0] - i = 0 - while i < total_count: - log.progress((i / total_count)) + try: + total_count = stash.find_galleries(f=query, filter={"page": 1, "per_page": 1}, get_count=True)[0] + except Exception: + total_count = 0 + + processed = 0 + page = 1 + + while True: + if total_count > 0: + log.progress(min(processed / total_count, 1.0)) + + galleries = stash.find_galleries( + f=query, + filter={"page": page, "per_page": GALLERY_PAGE_SIZE}, + fragment="id title code organized tags { id name } performers { id } studio { id }" + ) - galleries = stash.find_galleries(f=query, filter={"page": i, "per_page": 1}) - if len(galleries) == 0: + if not galleries: + log.info("Finished processing all galleries.") break - gallery = galleries[0] - processGallery(gallery) + for gallery in galleries: + processGallery(gallery) + processed += 1 + + page += 1 + + tag_cache.clear() + + +def should_exclude_gallery(gallery, exclusion_tag_name): + if exclusion_tag_name: + for tag in gallery.get("tags", []): + if tag["name"] == exclusion_tag_name: + return True + + if settings['excludeOrganized'] and gallery.get('organized'): + return True + + return False - i = i + 1 +def processGallery(gallery: dict): + if should_exclude_gallery(gallery, settings["excludeWithTag"]): + return -def processGallery(gallery : dict): - tags = [] - performersIds = [] - should_tag = True - if settings["excludeWithTag"] != "": - for tag in gallery["tags"]: - if tag["name"] == settings["excludeWithTag"]: - should_tag = False - break + gallery_id = gallery['id'] + + images = stash.find_gallery_images( + gallery_id, + fragment='tags { id } performers { id } studio { id }' + ) - if settings['excludeOrganized']: - if gallery['organized']: - should_tag = False - - if should_tag: - existing_tag_ids = {t['id'] for t in gallery['tags']} - existing_performer_ids = {p['id'] for p in gallery['performers']} - - images = stash.find_gallery_images(gallery['id'], fragment='tags { id name } performers { id name }') - if len(images) > 0: - tag_ids = set() - tag_names = set() - - performer_ids = set() - performer_names = set() - - for image in images: - image_tag_ids = [tag['id'] for tag in image['tags']] - image_tag_names = [tag['name'] for tag in image['tags']] - tag_ids.update(image_tag_ids) - tag_names.update(image_tag_names) - - image_performer_ids = [performer['id'] for performer in image['performers']] - image_performer_names = [performer['name'] for performer in image['performers']] - performer_ids.update(image_performer_ids) - performer_names.update(image_performer_names) - - new_tags_ids = tag_ids - existing_tag_ids - new_performer_ids = performer_ids - existing_performer_ids - - if len(new_tags_ids) > 0 or len(new_performer_ids) > 0: - log.info(f"updating gallery {gallery['id']} from {len(images)} images with tags {tag_names} ({len(new_tags_ids)} new) and performers {performer_names} ({len(new_performer_ids)} new)") - stash.update_galleries({"ids": gallery['id'], "tag_ids": {"mode": "ADD", "ids": list(new_tags_ids)}, "performer_ids": {"mode": "ADD", "ids": list(new_performer_ids)}}) + if not images: + return + + all_found_tag_ids = {t['id'] for img in images for t in img.get('tags', []) if 'id' in t} + all_found_performer_ids = {p['id'] for img in images for p in img.get('performers', []) if 'id' in p} + all_found_studio_ids = {img['studio']['id'] for img in images if img.get('studio') and img['studio'].get('id')} + + if not all_found_tag_ids and not all_found_performer_ids and not all_found_studio_ids: + return + + existing_tag_ids = {t['id'] for t in gallery.get('tags', [])} + existing_perf_ids = {p['id'] for p in gallery.get('performers', [])} + existing_studio_id = gallery.get('studio', {}).get('id') if gallery.get('studio') else None + + missing_tags = all_found_tag_ids - existing_tag_ids + missing_perfs = all_found_performer_ids - existing_perf_ids + + target_studio_id = list(all_found_studio_ids)[0] if all_found_studio_ids else None + studio_missing = bool(target_studio_id and (target_studio_id != existing_studio_id)) + + if not missing_tags and not missing_perfs and not studio_missing: + return + + update_data = {"ids": [gallery_id]} + if missing_tags and all_found_tag_ids: + update_data["tag_ids"] = {"mode": "ADD", "ids": list(all_found_tag_ids)} + if missing_perfs and all_found_performer_ids: + update_data["performer_ids"] = {"mode": "ADD", "ids": list(all_found_performer_ids)} + if studio_missing: + update_data["studio_id"] = target_studio_id + + gallery_name = gallery.get("title") or gallery.get("code") or f"ID {gallery_id}" + log.info(f"Syncing gallery '{gallery_name}' upward with structural image metadata updates.") + + try: + stash.update_galleries(update_data) + except Exception as e: + log.error(f"Error updating gallery {gallery_id}: {str(e)}") json_input = json.loads(sys.stdin.read()) FRAGMENT_SERVER = json_input["server_connection"] stash = StashInterface(FRAGMENT_SERVER) + config = stash.get_configuration() -settings = { - "excludeWithTag": "", - "excludeOrganized": False -} -if "tagGalleriesFromImages" in config["plugins"]: +settings = {"excludeWithTag": "", "excludeOrganized": False} + +if config and "plugins" in config and "tagGalleriesFromImages" in config["plugins"]: settings.update(config["plugins"]["tagGalleriesFromImages"]) if "mode" in json_input["args"]: - PLUGIN_ARGS = json_input["args"]["mode"] - if "processAll" in PLUGIN_ARGS: + if "processAll" in json_input["args"]["mode"]: processAll() elif "hookContext" in json_input["args"]: - id = json_input["args"]["hookContext"]['id'] - if ( - ( - json_input["args"]["hookContext"]["type"] == "Gallery.Update.Post" - or json_input["args"]["hookContext"]["type"] == "Gallery.Create.Post" - ) and "inputFields" in json_input["args"]["hookContext"] - and len(json_input["args"]["hookContext"]["inputFields"]) > 2 - ): - gallery = stash.find_gallery(id) - processGallery(gallery) + time.sleep(0.05) + hook = json_input["args"]["hookContext"] + hook_type = hook.get("type", "") + + # --- GALLERY HOOK HANDLER --- + if hook_type in ["Gallery.Update.Post", "Gallery.Create.Post"]: + gallery = stash.find_gallery(hook['id'], fragment="id title code organized tags { id name } performers { id } studio { id }") + if gallery: + processGallery(gallery) + + # --- IMAGE HOOK HANDLER --- + elif hook_type in ["Image.Update.Post", "Image.Create.Post"]: + image_id = hook['id'] + image_data = stash.find_image(image_id, fragment="gallery { id }") + + if image_data and image_data.get("gallery"): + gallery_id = image_data["gallery"]["id"] + gallery = stash.find_gallery(gallery_id, fragment="id title code organized tags { id name } performers { id } studio { id }") + if gallery: + processGallery(gallery) \ No newline at end of file diff --git a/plugins/tagGalleriesFromImages/tagGalleriesFromImages.yml b/plugins/tagGalleriesFromImages/tagGalleriesFromImages.yml index 9a88b12f..277f4900 100755 --- a/plugins/tagGalleriesFromImages/tagGalleriesFromImages.yml +++ b/plugins/tagGalleriesFromImages/tagGalleriesFromImages.yml @@ -1,6 +1,6 @@ name: Tag galleries from images description: tags galleries with tags of contained images. -version: 0.1 +version: 1.0 url: https://discourse.stashapp.cc/t/tag-galleries-from-images/3904 exec: - python diff --git a/plugins/tagImagesFromGalleries/requirements.txt b/plugins/tagImagesFromGalleries/requirements.txt new file mode 100644 index 00000000..e0fcf029 --- /dev/null +++ b/plugins/tagImagesFromGalleries/requirements.txt @@ -0,0 +1 @@ +stashapp-tools diff --git a/plugins/tagImagesFromGalleries/tagImagesFromGalleries.py b/plugins/tagImagesFromGalleries/tagImagesFromGalleries.py new file mode 100644 index 00000000..74782341 --- /dev/null +++ b/plugins/tagImagesFromGalleries/tagImagesFromGalleries.py @@ -0,0 +1,174 @@ +import stashapi.log as log +from stashapi.stashapp import StashInterface +import sys +import json + +GALLERY_PAGE_SIZE = 50 +IMAGE_UPDATE_BATCH = 1000 + + +def processAll(): + exclusion_marker_tag_id = None + + if settings["excludeWithTag"]: + exclusion_marker_tag = stash.find_tag(settings["excludeWithTag"]) + if exclusion_marker_tag: + exclusion_marker_tag_id = exclusion_marker_tag["id"] + + query = { + "image_count": { + "modifier": "NOT_EQUALS", + "value": 0, + } + } + + if settings["excludeOrganized"]: + query["organized"] = False + + if exclusion_marker_tag_id: + query["tags"] = { + "value": [exclusion_marker_tag_id], + "modifier": "EXCLUDES", + } + + try: + total_count = stash.find_galleries(f=query, filter={"page": 1, "per_page": 1}, get_count=True)[0] + except Exception: + total_count = 0 + + processed = 0 + page = 1 + + while True: + if total_count > 0: + log.progress(min(processed / total_count, 1.0)) + + galleries = stash.find_galleries( + f=query, + filter={"page": page, "per_page": GALLERY_PAGE_SIZE}, + fragment="id title code organized tags { id name } performers { id } studio { id }" + ) + + if not galleries: + log.info("Finished processing all galleries.") + break + + for gallery in galleries: + processGallery(gallery) + processed += 1 + + page += 1 + + +def processGallery(gallery: dict): + if settings["excludeWithTag"]: + for tag in gallery.get("tags", []): + if tag["name"] == settings["excludeWithTag"]: + return + + if settings["excludeOrganized"] and gallery.get("organized"): + return + + gallery_tag_ids = [t["id"] for t in gallery.get("tags", [])] + gallery_performer_ids = [p["id"] for p in gallery.get("performers", [])] + + gallery_studio = gallery.get("studio") + gallery_studio_id = gallery_studio["id"] if gallery_studio else None + + # If the gallery holds absolutely no operational metadata, bypass processing + if not gallery_tag_ids and not gallery_performer_ids and not gallery_studio_id: + return + + images = stash.find_gallery_images( + gallery["id"], + fragment="id tags { id } performers { id } studio { id }" + ) + + if not images: + return + + image_ids_to_update = [] + gallery_tags_set = set(gallery_tag_ids) + gallery_perfs_set = set(gallery_performer_ids) + + for img in images: + existing_img_tags = {t['id'] for t in img.get('tags', [])} + existing_img_perfs = {p['id'] for p in img.get('performers', [])} + + img_studio = img.get("studio") + img_studio_id = img_studio["id"] if img_studio else None + + # Determine structural delta discrepancies cleanly + missing_tags = gallery_tags_set - existing_img_tags + missing_perfs = gallery_perfs_set - existing_img_perfs + studio_mismatch = (gallery_studio_id is not None and img_studio_id != gallery_studio_id) + + if missing_tags or missing_perfs or studio_mismatch: + image_ids_to_update.append(img["id"]) + + if not image_ids_to_update: + return + + gallery_name = gallery.get("title") or gallery.get("code") or f"ID {gallery['id']}" + tag_names = [t["name"] for t in gallery.get("tags", [])] + tags_string = ", ".join(tag_names) if tag_names else "None" + context_msg = f"Gallery: '{gallery_name}' | Tags: [{tags_string}]" + + for i in range(0, len(image_ids_to_update), IMAGE_UPDATE_BATCH): + batch = image_ids_to_update[i:i + IMAGE_UPDATE_BATCH] + sendImageBatch(batch, gallery_tag_ids, gallery_performer_ids, gallery_studio_id, context_msg) + + +def sendImageBatch(image_ids, tag_ids, performer_ids, studio_id, context_msg): + update_data = { + "ids": image_ids + } + + if tag_ids: + update_data["tag_ids"] = { + "mode": "ADD", + "ids": tag_ids + } + + if performer_ids: + update_data["performer_ids"] = { + "mode": "ADD", + "ids": performer_ids + } + + if studio_id: + update_data["studio_id"] = studio_id + + log.info(f"Bulk down-updating {len(image_ids)} child images ({context_msg})") + stash.update_images(update_data) + + +json_input = json.loads(sys.stdin.read()) +FRAGMENT_SERVER = json_input["server_connection"] +stash = StashInterface(FRAGMENT_SERVER) + +config = stash.get_configuration() +settings = { + "excludeWithTag": "", + "excludeOrganized": False +} + +if "tagImagesFromGalleries" in config["plugins"]: + settings.update(config["plugins"]["tagImagesFromGalleries"]) + +if "mode" in json_input["args"]: + if "processAll" in json_input["args"]["mode"]: + processAll() + +elif "hookContext" in json_input["args"]: + hook = json_input["args"]["hookContext"] + gallery_id = hook["id"] + + # Safe validation check: handle inputs without breaking on complex sub-object mutations + if ( + hook.get("type") in ["Gallery.Update.Post", "Gallery.Create.Post"] + and hook.get("inputFields") is not None + ): + gallery = stash.find_gallery(gallery_id, fragment="id title code organized tags { id name } performers { id } studio { id }") + if gallery: + processGallery(gallery) \ No newline at end of file diff --git a/plugins/tagImagesFromGalleries/tagImagesFromGalleries.yml b/plugins/tagImagesFromGalleries/tagImagesFromGalleries.yml new file mode 100644 index 00000000..9539b6e5 --- /dev/null +++ b/plugins/tagImagesFromGalleries/tagImagesFromGalleries.yml @@ -0,0 +1,34 @@ +name: Tag images from galleries +description: Tags images with tags of galleries. +version: 1.1 +url: https://discourse.stashapp.cc/t/tag-galleries-from-images/3904 + +exec: + - python + - "{pluginDir}/tagImagesFromGalleries.py" + +interface: raw + +hooks: + - name: Update Gallery + description: Will images with tags of contained galleries + triggeredBy: + - Gallery.Update.Post + - Gallery.Create.Post + +settings: + excludeOrganized: + displayName: Exclude galleries marked as organized + description: Do not automatically tag galleries if it is marked as organized + type: BOOLEAN + + excludeWithTag: + displayName: Exclude galleries with tag from Hook + description: Do not automatically tag galleries if the gallery has this tag + type: STRING + +tasks: + - name: "Tag all images from galleries" + description: Loops through all galleries, and applies the tags of the gallery. Can take a long time on large db's. + defaultArgs: + mode: processAll diff --git a/plugins/tagImagesWithPerfTags/manifest b/plugins/tagImagesWithPerfTags/manifest new file mode 100644 index 00000000..0040ff59 --- /dev/null +++ b/plugins/tagImagesWithPerfTags/manifest @@ -0,0 +1,13 @@ +id: tagImagesWithPerfTags +name: Tag Images From Performer Tags +metadata: + description: tags images with performer tags. +version: 0.1-43feb59 +date: "2025-12-20 01:59:59" +requires: [] +source_repository: https://stashapp.github.io/CommunityScripts/stable/index.yml +files: +- README.md +- tagImagesWithPerfTags.py +- tagImagesWithPerfTags.yml +- requirements.txt diff --git a/plugins/tagImagesWithPerfTags/tagImagesWithPerfTags.py b/plugins/tagImagesWithPerfTags/tagImagesWithPerfTags.py index 123d540a..cb74babe 100755 --- a/plugins/tagImagesWithPerfTags/tagImagesWithPerfTags.py +++ b/plugins/tagImagesWithPerfTags/tagImagesWithPerfTags.py @@ -3,123 +3,193 @@ import sys import json +GALLERY_PAGE_SIZE = 50 +IMAGE_UPDATE_BATCH = 1000 + + def processAll(): exclusion_marker_tag_id = None - if settings["excludeImageWithTag"] != "": - exclussion_marker_tag = stash.find_tag(settings["excludeImageWithTag"]) - if exclussion_marker_tag is not None: - exclusion_marker_tag_id = exclussion_marker_tag['id'] + if settings["excludeWithTag"]: + exclusion_marker_tag = stash.find_tag(settings["excludeWithTag"]) + if exclusion_marker_tag: + exclusion_marker_tag_id = exclusion_marker_tag["id"] + + query = {"image_count": {"modifier": "NOT_EQUALS", "value": 0}} + if settings["excludeOrganized"]: + query["organized"] = False + if exclusion_marker_tag_id: + query["tags"] = {"value": [exclusion_marker_tag_id], "modifier": "EXCLUDES"} + + try: + total_count = stash.find_galleries(f=query, filter={"page": 1, "per_page": 1}, get_count=True)[0] + except Exception: + total_count = 0 + + log.info(f"Starting Process All: Inspecting {total_count} total galleries.") + processed = 0 + page = 1 + + stats = {"skipped_empty": 0, "skipped_synced": 0, "skipped_excluded": 0} + + while True: + if total_count > 0: + log.progress(min(processed / total_count, 1.0)) + + galleries = stash.find_galleries( + f=query, + filter={"page": page, "per_page": GALLERY_PAGE_SIZE}, + fragment="id title code organized tags { id name } performers { id name } studio { id }" + ) + + if not galleries: + log.info(f"Finished processing all galleries. Summary: " + f"Skipped: {stats['skipped_synced']} already synced, " + f"{stats['skipped_empty']} empty performer/tag metadata, {stats['skipped_excluded']} excluded filter.") + break + + for gallery in galleries: + processGallery(gallery, stats) + processed += 1 + + page += 1 + + +def processGallery(gallery: dict, stats: dict = None): + if stats is None: + stats = {"skipped_empty": 0, "skipped_synced": 0, "skipped_excluded": 0} + + gallery_name = gallery.get("title") or gallery.get("code") or f"ID {gallery['id']}" + + # Excluded via Settings Check + if settings["excludeWithTag"]: + for tag in gallery.get("tags", []): + if tag["name"] == settings["excludeWithTag"]: + stats["skipped_excluded"] += 1 + log.debug(f"Skipping Gallery '{gallery_name}': Has exclusion tag '{settings['excludeWithTag']}'.") + return + + if settings["excludeOrganized"] and gallery.get("organized"): + stats["skipped_excluded"] += 1 + log.debug(f"Skipping Gallery '{gallery_name}': Marked as organized.") + return + + gallery_tag_ids = [t["id"] for t in gallery.get("tags", [])] + gallery_performer_ids = [p["id"] for p in gallery.get("performers", [])] - query = { - "tags": { - "modifier": "NOT_NULL", - }, - "image_count": { - "modifier": "NOT_EQUALS", - "value": 0, - }, - } - performersTotal = stash.find_performers(f=query, filter={"page": 0, "per_page": 0}, get_count=True)[0] - i = 0 - while i < performersTotal: - log.progress((i / performersTotal)) - - perf = stash.find_performers(f=query, filter={"page": i, "per_page": 1}) + # CRITICAL FIX: If this specific script has no performers AND no tags, skip it immediately. + # We do not allow a studio-only gallery to pass through this plugin. + if not gallery_tag_ids and not gallery_performer_ids: + stats["skipped_empty"] += 1 + return - performer_tags_ids = [] - performer_tags_names = [] - for performer_tag in perf[0]["tags"]: - performer_tags_ids.append(performer_tag["id"]) - performer_tags_names.append(performer_tag["name"]) - - image_query = { - "performers": { - "value": [perf[0]["id"]], - "modifier": "INCLUDES_ALL" - } - } - if settings['excludeImageOrganized']: - image_query["organized"] = False - if exclusion_marker_tag_id is not None: - image_query["tags"] = { - "value": [exclusion_marker_tag_id], - "modifier": "EXCLUDES" - } - - performer_image_count = stash.find_images(f=image_query, filter={"page": 0, "per_page": 0}, get_count=True)[0] + gallery_studio = gallery.get("studio") + gallery_studio_id = gallery_studio["id"] if gallery_studio else None + + images = stash.find_gallery_images( + gallery["id"], + fragment="id tags { id } performers { id } studio { id }" + ) + + if not images: + return + + image_ids_to_update = [] + gallery_tags_set = set(gallery_tag_ids) + gallery_perfs_set = set(gallery_performer_ids) + + for img in images: + existing_img_tags = {t['id'] for t in img.get('tags', [])} + existing_img_perfs = {p['id'] for p in img.get('performers', [])} - if performer_image_count > 0: - log.info(f"updating {performer_image_count} images of performer \"{ perf[0]['name']}\" with tags {performer_tags_names}") - - performer_image_page_size = 100 - performer_image_page = 0 - while performer_image_page * performer_image_page_size < performer_image_count: - performer_images = stash.find_images(f=image_query, filter={"page": performer_image_page, "per_page": performer_image_page_size}, fragment='id') - performer_image_ids = [performer_image['id'] for performer_image in performer_images] - - stash.update_images( - { - "ids": performer_image_ids, - "tag_ids": {"mode": "ADD", "ids": performer_tags_ids}, - } - ) - performer_image_page += 1 - - i = i + 1 - - -def processImage(image): - tags = [] - performersIds = [] - should_tag = True - if settings["excludeImageWithTag"] != "": - for tag in image["tags"]: - if tag["name"] == settings["excludeImageWithTag"]: - should_tag = False - break + img_studio = img.get("studio") + img_studio_id = img_studio["id"] if img_studio else None + + missing_tags = gallery_tags_set - existing_img_tags + missing_perfs = gallery_perfs_set - existing_img_perfs + studio_mismatch = (gallery_studio_id is not None and img_studio_id != gallery_studio_id) + + if missing_tags or missing_perfs or studio_mismatch: + image_ids_to_update.append(img["id"]) + + if not image_ids_to_update: + stats["skipped_synced"] += 1 + log.debug(f"Skipping Gallery '{gallery_name}': All child assets match parent metadata.") + return + + # Reconstruct log metadata safely + perf_names = [p["name"] for p in gallery.get("performers", [])] + tag_names = [t["name"] for t in gallery.get("tags", [])] - if settings['excludeImageOrganized']: - if image['organized']: - should_tag = False - - if should_tag: - for perf in image["performers"]: - performersIds.append(perf["id"]) - performers = [] - for perfId in performersIds: - performers.append(stash.find_performer(perfId)) - for perf in performers: - for tag in perf["tags"]: - tags.append(tag["id"]) - stash.update_images({"ids": image["id"], "tag_ids": {"mode": "ADD", "ids": tags}}) - tags = [] - performersIds = [] - performers = [] + perfs_string = ", ".join(perf_names) if perf_names else "None" + tags_string = ", ".join(tag_names) if tag_names else "None" + context_msg = f"Gallery: '{gallery_name}' | Performers: [{perfs_string}] | Tags: [{tags_string}]" + + for i in range(0, len(image_ids_to_update), IMAGE_UPDATE_BATCH): + batch = image_ids_to_update[i:i + IMAGE_UPDATE_BATCH] + sendImageBatch(batch, gallery_tag_ids, gallery_performer_ids, gallery_studio_id, context_msg) + + +def sendImageBatch(image_ids, tag_ids, performer_ids, studio_id, context_msg): + update_data = {"ids": image_ids} + if tag_ids: + update_data["tag_ids"] = {"mode": "ADD", "ids": tag_ids} + if performer_ids: + update_data["performer_ids"] = {"mode": "ADD", "ids": performer_ids} + if studio_id: + update_data["studio_id"] = studio_id + + log.info(f"Bulk updating {len(image_ids)} images ({context_msg})") + stash.update_images(update_data) + + +def processImageHook(image: dict): + target_tag_ids = set() + for perf in image.get("performers", []): + for tag in perf.get("tags", []): + target_tag_ids.add(tag["id"]) + + if not target_tag_ids: + return + + existing_tag_ids = {t["id"] for t in image.get("tags", [])} + missing_tags = target_tag_ids - existing_tag_ids + + if not missing_tags: + return + + log.info(f"Hook Update: Appending missing performer tags to Image ID {image['id']}") + stash.update_images({ + "ids": [image["id"]], + "tag_ids": {"mode": "ADD", "ids": list(missing_tags)} + }) json_input = json.loads(sys.stdin.read()) FRAGMENT_SERVER = json_input["server_connection"] stash = StashInterface(FRAGMENT_SERVER) + config = stash.get_configuration() -settings = { - "excludeImageWithTag": "", - "excludeImageOrganized": False -} -if "tagImagesWithPerfTags" in config["plugins"]: - settings.update(config["plugins"]["tagImagesWithPerfTags"]) +settings = {"excludeWithTag": "", "excludeOrganized": False} + +if "tagImagesFromGalleries" in config["plugins"]: + settings.update(config["plugins"]["tagImagesFromGalleries"]) if "mode" in json_input["args"]: - PLUGIN_ARGS = json_input["args"]["mode"] - if "processAll" in PLUGIN_ARGS: + if "processAll" in json_input["args"]["mode"]: processAll() elif "hookContext" in json_input["args"]: - id = json_input["args"]["hookContext"]["id"] - if ( - ( - json_input["args"]["hookContext"]["type"] == "Image.Update.Post" - or "Image.Create.Post" - ) and "inputFields" in json_input["args"]["hookContext"] - and len(json_input["args"]["hookContext"]["inputFields"]) > 2 - ): - image = stash.find_image(id) - processImage(image) + hook = json_input["args"]["hookContext"] + hook_id = hook["id"] + hook_type = hook.get("type", "") + + if hook_type in ["Gallery.Update.Post", "Gallery.Create.Post"]: + if hook.get("inputFields") is not None: + gallery = stash.find_gallery(hook_id, fragment="id title code organized tags { id name } performers { id name } studio { id }") + if gallery: + processGallery(gallery) + + elif hook_type in ["Image.Update.Post", "Image.Create.Post"]: + if hook.get("inputFields") is not None: + image = stash.find_image(hook_id, fragment="id tags { id } performers { id tags { id } }") + if image: + processImageHook(image) \ No newline at end of file diff --git a/plugins/tagImagesWithPerfTags/tagImagesWithPerfTags.yml b/plugins/tagImagesWithPerfTags/tagImagesWithPerfTags.yml index a7b08170..8c6f3113 100755 --- a/plugins/tagImagesWithPerfTags/tagImagesWithPerfTags.yml +++ b/plugins/tagImagesWithPerfTags/tagImagesWithPerfTags.yml @@ -1,6 +1,6 @@ name: Tag Images From Performer Tags description: tags images with performer tags. -version: 0.1 +version: 1.0 url: https://discourse.stashapp.cc/t/tag-images-from-performer-tags/2059 exec: - python diff --git a/plugins/tagScenesWithPerfTags/manifest b/plugins/tagScenesWithPerfTags/manifest new file mode 100644 index 00000000..5e0d9c9a --- /dev/null +++ b/plugins/tagScenesWithPerfTags/manifest @@ -0,0 +1,13 @@ +id: tagScenesWithPerfTags +name: Tag Scenes From Performer Tags +metadata: + description: tags scenes with performer tags. +version: 0.2.3-43feb59 +date: "2025-12-20 01:59:59" +requires: [] +source_repository: https://stashapp.github.io/CommunityScripts/stable/index.yml +files: +- README.md +- tagScenesWithPerfTags.py +- requirements.txt +- tagScenesWithPerfTags.yml diff --git a/plugins/tagScenesWithPerfTags/tagScenesWithPerfTags.py b/plugins/tagScenesWithPerfTags/tagScenesWithPerfTags.py index 39b307e8..29f4cf36 100644 --- a/plugins/tagScenesWithPerfTags/tagScenesWithPerfTags.py +++ b/plugins/tagScenesWithPerfTags/tagScenesWithPerfTags.py @@ -3,6 +3,10 @@ import sys import json +PERFORMER_PAGE_SIZE = 100 +SCENE_UPDATE_BATCH = 1000 + + def processAll(): exclusion_marker_tag_id = None if settings["excludeSceneWithTag"] != "": @@ -19,88 +23,102 @@ def processAll(): "value": 0, }, } - performersTotal = stash.find_performers(f=query, filter={"page": 0, "per_page": 0}, get_count=True)[0] - i = 0 - while i < performersTotal: - log.progress((i / performersTotal)) + + try: + performersTotal = stash.find_performers(f=query, filter={"page": 0, "per_page": 1}, get_count=True)[0] + except Exception: + performersTotal = 0 - perf = stash.find_performers(f=query, filter={"page": i, "per_page": 1}) + processed = 0 + page = 0 + + while True: + if performersTotal > 0: + log.progress(min(processed / performersTotal, 1.0)) + + performers = stash.find_performers( + f=query, + filter={"page": page, "per_page": PERFORMER_PAGE_SIZE}, + fragment="id name tags { id name }" + ) - performer_tags_ids = [] - performer_tags_names = [] - for performer_tag in perf[0]["tags"]: - performer_tags_ids.append(performer_tag["id"]) - performer_tags_names.append(performer_tag["name"]) + if not performers: + log.info("Finished processing all performers.") + break + + for perf in performers: + performer_tags_ids = [t["id"] for t in perf["tags"]] + performer_tags_names = [t["name"] for t in perf["tags"]] + + if not performer_tags_ids: + processed += 1 + continue - scene_query = { - "performers": { - "value": [perf[0]["id"]], - "modifier": "INCLUDES_ALL" - } - } - if settings['excludeSceneOrganized']: - scene_query["organized"] = False - if exclusion_marker_tag_id is not None: - scene_query["tags"] = { - "value": [exclusion_marker_tag_id], - "modifier": "EXCLUDES" + scene_query = { + "performers": { + "value": [perf["id"]], + "modifier": "INCLUDES_ALL" + } } + if settings['excludeSceneOrganized']: + scene_query["organized"] = False + if exclusion_marker_tag_id is not None: + scene_query["tags"] = { + "value": [exclusion_marker_tag_id], + "modifier": "EXCLUDES" + } - performer_scene_count = stash.find_scenes(f=scene_query, filter={"page": 0, "per_page": 0}, get_count=True)[0] - - if performer_scene_count > 0: - log.info(f"updating {performer_scene_count} scenes of performer \"{ perf[0]['name']}\" with tags {performer_tags_names}") + performer_scenes = stash.find_scenes(f=scene_query, fragment='id') + if not performer_scenes: + processed += 1 + continue - performer_scene_page_size = 100 - performer_scene_page = 0 - while performer_scene_page * performer_scene_page_size < performer_scene_count: - performer_scenes = stash.find_scenes(f=scene_query, filter={"page": performer_scene_page, "per_page": performer_scene_page_size}, fragment='id') - performer_scene_ids = [performer_scene['id'] for performer_scene in performer_scenes] + performer_scene_ids = [scene['id'] for scene in performer_scenes] + tags_string = ", ".join(performer_tags_names) + context_msg = f"Performer: '{perf['name']}' | Tags: [{tags_string}]" + log.info(f"Bulk updating {len(performer_scene_ids)} scenes ({context_msg})") + + for i in range(0, len(performer_scene_ids), SCENE_UPDATE_BATCH): + batch = performer_scene_ids[i:i + SCENE_UPDATE_BATCH] stash.update_scenes( { - "ids": performer_scene_ids, + "ids": batch, "tag_ids": {"mode": "ADD", "ids": performer_tags_ids}, } ) - performer_scene_page += 1 - - i = i + 1 + processed += 1 + page += 1 -def processScene(scene): - tags = [] - performersIds = [] - should_tag = True +def processScene(scene: dict): if settings["excludeSceneWithTag"] != "": - for tag in scene["tags"]: + for tag in scene.get("tags", []): if tag["name"] == settings["excludeSceneWithTag"]: - should_tag = False - break + return - if settings['excludeSceneOrganized']: - if scene['organized']: - should_tag = False - - if should_tag: - for perf in scene["performers"]: - performersIds.append(perf["id"]) - performers = [] - for perfId in performersIds: - performers.append(stash.find_performer(perfId)) - for perf in performers: - for tag in perf["tags"]: - tags.append(tag["id"]) - stash.update_scenes({"ids": scene["id"], "tag_ids": {"mode": "ADD", "ids": tags}}) - tags = [] - performersIds = [] - performers = [] + if settings['excludeSceneOrganized'] and scene.get('organized'): + return + + target_tag_ids = [] + for perf in scene.get("performers", []): + for tag in perf.get("tags", []): + target_tag_ids.append(tag["id"]) + + if not target_tag_ids: + return + + stash.update_scenes({ + "ids": [scene["id"]], + "tag_ids": {"mode": "ADD", "ids": list(set(target_tag_ids))} + }) json_input = json.loads(sys.stdin.read()) FRAGMENT_SERVER = json_input["server_connection"] stash = StashInterface(FRAGMENT_SERVER) config = stash.get_configuration() + settings = { "excludeSceneWithTag": "", "excludeSceneOrganized": False @@ -114,12 +132,14 @@ def processScene(scene): processAll() elif "hookContext" in json_input["args"]: id = json_input["args"]["hookContext"]["id"] + hook_type = json_input["args"]["hookContext"].get("type", "") + if ( - ( - json_input["args"]["hookContext"]["type"] == "Scene.Update.Post" - or "Scene.Create.Post" - ) and "inputFields" in json_input["args"]["hookContext"] - and len(json_input["args"]["hookContext"]["inputFields"]) > 2 + (hook_type == "Scene.Update.Post" or hook_type == "Scene.Create.Post") + and "inputFields" in json_input["args"]["hookContext"] + and len(json_input["args"]["hookContext"]["inputFields"]) > 1 ): - scene = stash.find_scene(id, fragment="id organized tags {name} performers {id}") - processScene(scene) + # Enforce explicit studio object allocation inside our graphQL post-hook criteria + scene = stash.find_scene(id, fragment="id organized tags { name } performers { id tags { id } } studio { id }") + if scene: + processScene(scene) \ No newline at end of file diff --git a/plugins/tagScenesWithPerfTags/tagScenesWithPerfTags.yml b/plugins/tagScenesWithPerfTags/tagScenesWithPerfTags.yml index 640da0f1..395d03b9 100644 --- a/plugins/tagScenesWithPerfTags/tagScenesWithPerfTags.yml +++ b/plugins/tagScenesWithPerfTags/tagScenesWithPerfTags.yml @@ -1,6 +1,6 @@ name: Tag Scenes From Performer Tags description: tags scenes with performer tags. -version: 0.2.3 +version: 1.0 url: https://discourse.stashapp.cc/t/tag-scenes-from-performer-tags/1413 exec: - python From 754fc82622d4785626760b44b5be7ca1c02cb891 Mon Sep 17 00:00:00 2001 From: DogmaDragon <103123951+DogmaDragon@users.noreply.github.com> Date: Sat, 30 May 2026 06:50:29 +0300 Subject: [PATCH 2/3] Update descriptions --- .../tagGalleriesFromImages/tagGalleriesFromImages.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/plugins/tagGalleriesFromImages/tagGalleriesFromImages.yml b/plugins/tagGalleriesFromImages/tagGalleriesFromImages.yml index 277f4900..0500b4ae 100755 --- a/plugins/tagGalleriesFromImages/tagGalleriesFromImages.yml +++ b/plugins/tagGalleriesFromImages/tagGalleriesFromImages.yml @@ -1,5 +1,5 @@ name: Tag galleries from images -description: tags galleries with tags of contained images. +description: Tags galleries with tags of contained images. version: 1.0 url: https://discourse.stashapp.cc/t/tag-galleries-from-images/3904 exec: @@ -8,7 +8,7 @@ exec: interface: raw hooks: - - name: update gallery + - name: Update gallery description: Will tag galleries with tags of contained images triggeredBy: - Gallery.Update.Post @@ -17,15 +17,15 @@ hooks: settings: excludeOrganized: displayName: Exclude galleries marked as organized - description: Do not automatically tag galleries if it is marked as organized + description: Do not automatically tag galleries if it is marked as organized. type: BOOLEAN excludeWithTag: displayName: Exclude galleries with tag from Hook - description: Do not automatically tag galleries if the gallery has this tag + description: Do not automatically tag galleries if the gallery has this tag. type: STRING tasks: - name: "Tag all galleries" - description: Loops through all galleries, and applies the tags of the contained images. Can take a long time on large db's. + description: Loops through all galleries, and applies the tags of the contained images. Can take a long time on large databases. defaultArgs: mode: processAll From d693b2641dad0cb5e36184b1b4421d4dfcda2cd2 Mon Sep 17 00:00:00 2001 From: DogmaDragon <103123951+DogmaDragon@users.noreply.github.com> Date: Sat, 30 May 2026 06:51:39 +0300 Subject: [PATCH 3/3] Revert changes, remove new plugin --- .../tagImagesFromGalleries/requirements.txt | 1 - .../tagImagesFromGalleries.py | 174 ----------- .../tagImagesFromGalleries.yml | 34 --- plugins/tagImagesWithPerfTags/manifest | 13 - .../tagImagesWithPerfTags.py | 278 +++++++----------- .../tagImagesWithPerfTags.yml | 2 +- plugins/tagScenesWithPerfTags/manifest | 13 - .../tagScenesWithPerfTags.py | 152 +++++----- .../tagScenesWithPerfTags.yml | 2 +- 9 files changed, 172 insertions(+), 497 deletions(-) delete mode 100644 plugins/tagImagesFromGalleries/requirements.txt delete mode 100644 plugins/tagImagesFromGalleries/tagImagesFromGalleries.py delete mode 100644 plugins/tagImagesFromGalleries/tagImagesFromGalleries.yml delete mode 100644 plugins/tagImagesWithPerfTags/manifest delete mode 100644 plugins/tagScenesWithPerfTags/manifest diff --git a/plugins/tagImagesFromGalleries/requirements.txt b/plugins/tagImagesFromGalleries/requirements.txt deleted file mode 100644 index e0fcf029..00000000 --- a/plugins/tagImagesFromGalleries/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -stashapp-tools diff --git a/plugins/tagImagesFromGalleries/tagImagesFromGalleries.py b/plugins/tagImagesFromGalleries/tagImagesFromGalleries.py deleted file mode 100644 index 74782341..00000000 --- a/plugins/tagImagesFromGalleries/tagImagesFromGalleries.py +++ /dev/null @@ -1,174 +0,0 @@ -import stashapi.log as log -from stashapi.stashapp import StashInterface -import sys -import json - -GALLERY_PAGE_SIZE = 50 -IMAGE_UPDATE_BATCH = 1000 - - -def processAll(): - exclusion_marker_tag_id = None - - if settings["excludeWithTag"]: - exclusion_marker_tag = stash.find_tag(settings["excludeWithTag"]) - if exclusion_marker_tag: - exclusion_marker_tag_id = exclusion_marker_tag["id"] - - query = { - "image_count": { - "modifier": "NOT_EQUALS", - "value": 0, - } - } - - if settings["excludeOrganized"]: - query["organized"] = False - - if exclusion_marker_tag_id: - query["tags"] = { - "value": [exclusion_marker_tag_id], - "modifier": "EXCLUDES", - } - - try: - total_count = stash.find_galleries(f=query, filter={"page": 1, "per_page": 1}, get_count=True)[0] - except Exception: - total_count = 0 - - processed = 0 - page = 1 - - while True: - if total_count > 0: - log.progress(min(processed / total_count, 1.0)) - - galleries = stash.find_galleries( - f=query, - filter={"page": page, "per_page": GALLERY_PAGE_SIZE}, - fragment="id title code organized tags { id name } performers { id } studio { id }" - ) - - if not galleries: - log.info("Finished processing all galleries.") - break - - for gallery in galleries: - processGallery(gallery) - processed += 1 - - page += 1 - - -def processGallery(gallery: dict): - if settings["excludeWithTag"]: - for tag in gallery.get("tags", []): - if tag["name"] == settings["excludeWithTag"]: - return - - if settings["excludeOrganized"] and gallery.get("organized"): - return - - gallery_tag_ids = [t["id"] for t in gallery.get("tags", [])] - gallery_performer_ids = [p["id"] for p in gallery.get("performers", [])] - - gallery_studio = gallery.get("studio") - gallery_studio_id = gallery_studio["id"] if gallery_studio else None - - # If the gallery holds absolutely no operational metadata, bypass processing - if not gallery_tag_ids and not gallery_performer_ids and not gallery_studio_id: - return - - images = stash.find_gallery_images( - gallery["id"], - fragment="id tags { id } performers { id } studio { id }" - ) - - if not images: - return - - image_ids_to_update = [] - gallery_tags_set = set(gallery_tag_ids) - gallery_perfs_set = set(gallery_performer_ids) - - for img in images: - existing_img_tags = {t['id'] for t in img.get('tags', [])} - existing_img_perfs = {p['id'] for p in img.get('performers', [])} - - img_studio = img.get("studio") - img_studio_id = img_studio["id"] if img_studio else None - - # Determine structural delta discrepancies cleanly - missing_tags = gallery_tags_set - existing_img_tags - missing_perfs = gallery_perfs_set - existing_img_perfs - studio_mismatch = (gallery_studio_id is not None and img_studio_id != gallery_studio_id) - - if missing_tags or missing_perfs or studio_mismatch: - image_ids_to_update.append(img["id"]) - - if not image_ids_to_update: - return - - gallery_name = gallery.get("title") or gallery.get("code") or f"ID {gallery['id']}" - tag_names = [t["name"] for t in gallery.get("tags", [])] - tags_string = ", ".join(tag_names) if tag_names else "None" - context_msg = f"Gallery: '{gallery_name}' | Tags: [{tags_string}]" - - for i in range(0, len(image_ids_to_update), IMAGE_UPDATE_BATCH): - batch = image_ids_to_update[i:i + IMAGE_UPDATE_BATCH] - sendImageBatch(batch, gallery_tag_ids, gallery_performer_ids, gallery_studio_id, context_msg) - - -def sendImageBatch(image_ids, tag_ids, performer_ids, studio_id, context_msg): - update_data = { - "ids": image_ids - } - - if tag_ids: - update_data["tag_ids"] = { - "mode": "ADD", - "ids": tag_ids - } - - if performer_ids: - update_data["performer_ids"] = { - "mode": "ADD", - "ids": performer_ids - } - - if studio_id: - update_data["studio_id"] = studio_id - - log.info(f"Bulk down-updating {len(image_ids)} child images ({context_msg})") - stash.update_images(update_data) - - -json_input = json.loads(sys.stdin.read()) -FRAGMENT_SERVER = json_input["server_connection"] -stash = StashInterface(FRAGMENT_SERVER) - -config = stash.get_configuration() -settings = { - "excludeWithTag": "", - "excludeOrganized": False -} - -if "tagImagesFromGalleries" in config["plugins"]: - settings.update(config["plugins"]["tagImagesFromGalleries"]) - -if "mode" in json_input["args"]: - if "processAll" in json_input["args"]["mode"]: - processAll() - -elif "hookContext" in json_input["args"]: - hook = json_input["args"]["hookContext"] - gallery_id = hook["id"] - - # Safe validation check: handle inputs without breaking on complex sub-object mutations - if ( - hook.get("type") in ["Gallery.Update.Post", "Gallery.Create.Post"] - and hook.get("inputFields") is not None - ): - gallery = stash.find_gallery(gallery_id, fragment="id title code organized tags { id name } performers { id } studio { id }") - if gallery: - processGallery(gallery) \ No newline at end of file diff --git a/plugins/tagImagesFromGalleries/tagImagesFromGalleries.yml b/plugins/tagImagesFromGalleries/tagImagesFromGalleries.yml deleted file mode 100644 index 9539b6e5..00000000 --- a/plugins/tagImagesFromGalleries/tagImagesFromGalleries.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Tag images from galleries -description: Tags images with tags of galleries. -version: 1.1 -url: https://discourse.stashapp.cc/t/tag-galleries-from-images/3904 - -exec: - - python - - "{pluginDir}/tagImagesFromGalleries.py" - -interface: raw - -hooks: - - name: Update Gallery - description: Will images with tags of contained galleries - triggeredBy: - - Gallery.Update.Post - - Gallery.Create.Post - -settings: - excludeOrganized: - displayName: Exclude galleries marked as organized - description: Do not automatically tag galleries if it is marked as organized - type: BOOLEAN - - excludeWithTag: - displayName: Exclude galleries with tag from Hook - description: Do not automatically tag galleries if the gallery has this tag - type: STRING - -tasks: - - name: "Tag all images from galleries" - description: Loops through all galleries, and applies the tags of the gallery. Can take a long time on large db's. - defaultArgs: - mode: processAll diff --git a/plugins/tagImagesWithPerfTags/manifest b/plugins/tagImagesWithPerfTags/manifest deleted file mode 100644 index 0040ff59..00000000 --- a/plugins/tagImagesWithPerfTags/manifest +++ /dev/null @@ -1,13 +0,0 @@ -id: tagImagesWithPerfTags -name: Tag Images From Performer Tags -metadata: - description: tags images with performer tags. -version: 0.1-43feb59 -date: "2025-12-20 01:59:59" -requires: [] -source_repository: https://stashapp.github.io/CommunityScripts/stable/index.yml -files: -- README.md -- tagImagesWithPerfTags.py -- tagImagesWithPerfTags.yml -- requirements.txt diff --git a/plugins/tagImagesWithPerfTags/tagImagesWithPerfTags.py b/plugins/tagImagesWithPerfTags/tagImagesWithPerfTags.py index cb74babe..123d540a 100755 --- a/plugins/tagImagesWithPerfTags/tagImagesWithPerfTags.py +++ b/plugins/tagImagesWithPerfTags/tagImagesWithPerfTags.py @@ -3,193 +3,123 @@ import sys import json -GALLERY_PAGE_SIZE = 50 -IMAGE_UPDATE_BATCH = 1000 - - def processAll(): exclusion_marker_tag_id = None - if settings["excludeWithTag"]: - exclusion_marker_tag = stash.find_tag(settings["excludeWithTag"]) - if exclusion_marker_tag: - exclusion_marker_tag_id = exclusion_marker_tag["id"] - - query = {"image_count": {"modifier": "NOT_EQUALS", "value": 0}} - if settings["excludeOrganized"]: - query["organized"] = False - if exclusion_marker_tag_id: - query["tags"] = {"value": [exclusion_marker_tag_id], "modifier": "EXCLUDES"} - - try: - total_count = stash.find_galleries(f=query, filter={"page": 1, "per_page": 1}, get_count=True)[0] - except Exception: - total_count = 0 - - log.info(f"Starting Process All: Inspecting {total_count} total galleries.") - processed = 0 - page = 1 - - stats = {"skipped_empty": 0, "skipped_synced": 0, "skipped_excluded": 0} - - while True: - if total_count > 0: - log.progress(min(processed / total_count, 1.0)) - - galleries = stash.find_galleries( - f=query, - filter={"page": page, "per_page": GALLERY_PAGE_SIZE}, - fragment="id title code organized tags { id name } performers { id name } studio { id }" - ) - - if not galleries: - log.info(f"Finished processing all galleries. Summary: " - f"Skipped: {stats['skipped_synced']} already synced, " - f"{stats['skipped_empty']} empty performer/tag metadata, {stats['skipped_excluded']} excluded filter.") - break - - for gallery in galleries: - processGallery(gallery, stats) - processed += 1 - - page += 1 - - -def processGallery(gallery: dict, stats: dict = None): - if stats is None: - stats = {"skipped_empty": 0, "skipped_synced": 0, "skipped_excluded": 0} - - gallery_name = gallery.get("title") or gallery.get("code") or f"ID {gallery['id']}" - - # Excluded via Settings Check - if settings["excludeWithTag"]: - for tag in gallery.get("tags", []): - if tag["name"] == settings["excludeWithTag"]: - stats["skipped_excluded"] += 1 - log.debug(f"Skipping Gallery '{gallery_name}': Has exclusion tag '{settings['excludeWithTag']}'.") - return - - if settings["excludeOrganized"] and gallery.get("organized"): - stats["skipped_excluded"] += 1 - log.debug(f"Skipping Gallery '{gallery_name}': Marked as organized.") - return - - gallery_tag_ids = [t["id"] for t in gallery.get("tags", [])] - gallery_performer_ids = [p["id"] for p in gallery.get("performers", [])] + if settings["excludeImageWithTag"] != "": + exclussion_marker_tag = stash.find_tag(settings["excludeImageWithTag"]) + if exclussion_marker_tag is not None: + exclusion_marker_tag_id = exclussion_marker_tag['id'] - # CRITICAL FIX: If this specific script has no performers AND no tags, skip it immediately. - # We do not allow a studio-only gallery to pass through this plugin. - if not gallery_tag_ids and not gallery_performer_ids: - stats["skipped_empty"] += 1 - return - - gallery_studio = gallery.get("studio") - gallery_studio_id = gallery_studio["id"] if gallery_studio else None - - images = stash.find_gallery_images( - gallery["id"], - fragment="id tags { id } performers { id } studio { id }" - ) - - if not images: - return - - image_ids_to_update = [] - gallery_tags_set = set(gallery_tag_ids) - gallery_perfs_set = set(gallery_performer_ids) - - for img in images: - existing_img_tags = {t['id'] for t in img.get('tags', [])} - existing_img_perfs = {p['id'] for p in img.get('performers', [])} + query = { + "tags": { + "modifier": "NOT_NULL", + }, + "image_count": { + "modifier": "NOT_EQUALS", + "value": 0, + }, + } + performersTotal = stash.find_performers(f=query, filter={"page": 0, "per_page": 0}, get_count=True)[0] + i = 0 + while i < performersTotal: + log.progress((i / performersTotal)) - img_studio = img.get("studio") - img_studio_id = img_studio["id"] if img_studio else None - - missing_tags = gallery_tags_set - existing_img_tags - missing_perfs = gallery_perfs_set - existing_img_perfs - studio_mismatch = (gallery_studio_id is not None and img_studio_id != gallery_studio_id) - - if missing_tags or missing_perfs or studio_mismatch: - image_ids_to_update.append(img["id"]) - - if not image_ids_to_update: - stats["skipped_synced"] += 1 - log.debug(f"Skipping Gallery '{gallery_name}': All child assets match parent metadata.") - return + perf = stash.find_performers(f=query, filter={"page": i, "per_page": 1}) - # Reconstruct log metadata safely - perf_names = [p["name"] for p in gallery.get("performers", [])] - tag_names = [t["name"] for t in gallery.get("tags", [])] + performer_tags_ids = [] + performer_tags_names = [] + for performer_tag in perf[0]["tags"]: + performer_tags_ids.append(performer_tag["id"]) + performer_tags_names.append(performer_tag["name"]) + + image_query = { + "performers": { + "value": [perf[0]["id"]], + "modifier": "INCLUDES_ALL" + } + } + if settings['excludeImageOrganized']: + image_query["organized"] = False + if exclusion_marker_tag_id is not None: + image_query["tags"] = { + "value": [exclusion_marker_tag_id], + "modifier": "EXCLUDES" + } + + performer_image_count = stash.find_images(f=image_query, filter={"page": 0, "per_page": 0}, get_count=True)[0] + + if performer_image_count > 0: + log.info(f"updating {performer_image_count} images of performer \"{ perf[0]['name']}\" with tags {performer_tags_names}") + + performer_image_page_size = 100 + performer_image_page = 0 + while performer_image_page * performer_image_page_size < performer_image_count: + performer_images = stash.find_images(f=image_query, filter={"page": performer_image_page, "per_page": performer_image_page_size}, fragment='id') + performer_image_ids = [performer_image['id'] for performer_image in performer_images] + + stash.update_images( + { + "ids": performer_image_ids, + "tag_ids": {"mode": "ADD", "ids": performer_tags_ids}, + } + ) + performer_image_page += 1 + + i = i + 1 + + +def processImage(image): + tags = [] + performersIds = [] + should_tag = True + if settings["excludeImageWithTag"] != "": + for tag in image["tags"]: + if tag["name"] == settings["excludeImageWithTag"]: + should_tag = False + break - perfs_string = ", ".join(perf_names) if perf_names else "None" - tags_string = ", ".join(tag_names) if tag_names else "None" - context_msg = f"Gallery: '{gallery_name}' | Performers: [{perfs_string}] | Tags: [{tags_string}]" - - for i in range(0, len(image_ids_to_update), IMAGE_UPDATE_BATCH): - batch = image_ids_to_update[i:i + IMAGE_UPDATE_BATCH] - sendImageBatch(batch, gallery_tag_ids, gallery_performer_ids, gallery_studio_id, context_msg) - - -def sendImageBatch(image_ids, tag_ids, performer_ids, studio_id, context_msg): - update_data = {"ids": image_ids} - if tag_ids: - update_data["tag_ids"] = {"mode": "ADD", "ids": tag_ids} - if performer_ids: - update_data["performer_ids"] = {"mode": "ADD", "ids": performer_ids} - if studio_id: - update_data["studio_id"] = studio_id - - log.info(f"Bulk updating {len(image_ids)} images ({context_msg})") - stash.update_images(update_data) - - -def processImageHook(image: dict): - target_tag_ids = set() - for perf in image.get("performers", []): - for tag in perf.get("tags", []): - target_tag_ids.add(tag["id"]) - - if not target_tag_ids: - return - - existing_tag_ids = {t["id"] for t in image.get("tags", [])} - missing_tags = target_tag_ids - existing_tag_ids - - if not missing_tags: - return - - log.info(f"Hook Update: Appending missing performer tags to Image ID {image['id']}") - stash.update_images({ - "ids": [image["id"]], - "tag_ids": {"mode": "ADD", "ids": list(missing_tags)} - }) + if settings['excludeImageOrganized']: + if image['organized']: + should_tag = False + + if should_tag: + for perf in image["performers"]: + performersIds.append(perf["id"]) + performers = [] + for perfId in performersIds: + performers.append(stash.find_performer(perfId)) + for perf in performers: + for tag in perf["tags"]: + tags.append(tag["id"]) + stash.update_images({"ids": image["id"], "tag_ids": {"mode": "ADD", "ids": tags}}) + tags = [] + performersIds = [] + performers = [] json_input = json.loads(sys.stdin.read()) FRAGMENT_SERVER = json_input["server_connection"] stash = StashInterface(FRAGMENT_SERVER) - config = stash.get_configuration() -settings = {"excludeWithTag": "", "excludeOrganized": False} - -if "tagImagesFromGalleries" in config["plugins"]: - settings.update(config["plugins"]["tagImagesFromGalleries"]) +settings = { + "excludeImageWithTag": "", + "excludeImageOrganized": False +} +if "tagImagesWithPerfTags" in config["plugins"]: + settings.update(config["plugins"]["tagImagesWithPerfTags"]) if "mode" in json_input["args"]: - if "processAll" in json_input["args"]["mode"]: + PLUGIN_ARGS = json_input["args"]["mode"] + if "processAll" in PLUGIN_ARGS: processAll() elif "hookContext" in json_input["args"]: - hook = json_input["args"]["hookContext"] - hook_id = hook["id"] - hook_type = hook.get("type", "") - - if hook_type in ["Gallery.Update.Post", "Gallery.Create.Post"]: - if hook.get("inputFields") is not None: - gallery = stash.find_gallery(hook_id, fragment="id title code organized tags { id name } performers { id name } studio { id }") - if gallery: - processGallery(gallery) - - elif hook_type in ["Image.Update.Post", "Image.Create.Post"]: - if hook.get("inputFields") is not None: - image = stash.find_image(hook_id, fragment="id tags { id } performers { id tags { id } }") - if image: - processImageHook(image) \ No newline at end of file + id = json_input["args"]["hookContext"]["id"] + if ( + ( + json_input["args"]["hookContext"]["type"] == "Image.Update.Post" + or "Image.Create.Post" + ) and "inputFields" in json_input["args"]["hookContext"] + and len(json_input["args"]["hookContext"]["inputFields"]) > 2 + ): + image = stash.find_image(id) + processImage(image) diff --git a/plugins/tagImagesWithPerfTags/tagImagesWithPerfTags.yml b/plugins/tagImagesWithPerfTags/tagImagesWithPerfTags.yml index 8c6f3113..a7b08170 100755 --- a/plugins/tagImagesWithPerfTags/tagImagesWithPerfTags.yml +++ b/plugins/tagImagesWithPerfTags/tagImagesWithPerfTags.yml @@ -1,6 +1,6 @@ name: Tag Images From Performer Tags description: tags images with performer tags. -version: 1.0 +version: 0.1 url: https://discourse.stashapp.cc/t/tag-images-from-performer-tags/2059 exec: - python diff --git a/plugins/tagScenesWithPerfTags/manifest b/plugins/tagScenesWithPerfTags/manifest deleted file mode 100644 index 5e0d9c9a..00000000 --- a/plugins/tagScenesWithPerfTags/manifest +++ /dev/null @@ -1,13 +0,0 @@ -id: tagScenesWithPerfTags -name: Tag Scenes From Performer Tags -metadata: - description: tags scenes with performer tags. -version: 0.2.3-43feb59 -date: "2025-12-20 01:59:59" -requires: [] -source_repository: https://stashapp.github.io/CommunityScripts/stable/index.yml -files: -- README.md -- tagScenesWithPerfTags.py -- requirements.txt -- tagScenesWithPerfTags.yml diff --git a/plugins/tagScenesWithPerfTags/tagScenesWithPerfTags.py b/plugins/tagScenesWithPerfTags/tagScenesWithPerfTags.py index 29f4cf36..39b307e8 100644 --- a/plugins/tagScenesWithPerfTags/tagScenesWithPerfTags.py +++ b/plugins/tagScenesWithPerfTags/tagScenesWithPerfTags.py @@ -3,10 +3,6 @@ import sys import json -PERFORMER_PAGE_SIZE = 100 -SCENE_UPDATE_BATCH = 1000 - - def processAll(): exclusion_marker_tag_id = None if settings["excludeSceneWithTag"] != "": @@ -23,102 +19,88 @@ def processAll(): "value": 0, }, } - - try: - performersTotal = stash.find_performers(f=query, filter={"page": 0, "per_page": 1}, get_count=True)[0] - except Exception: - performersTotal = 0 + performersTotal = stash.find_performers(f=query, filter={"page": 0, "per_page": 0}, get_count=True)[0] + i = 0 + while i < performersTotal: + log.progress((i / performersTotal)) - processed = 0 - page = 0 - - while True: - if performersTotal > 0: - log.progress(min(processed / performersTotal, 1.0)) - - performers = stash.find_performers( - f=query, - filter={"page": page, "per_page": PERFORMER_PAGE_SIZE}, - fragment="id name tags { id name }" - ) + perf = stash.find_performers(f=query, filter={"page": i, "per_page": 1}) - if not performers: - log.info("Finished processing all performers.") - break - - for perf in performers: - performer_tags_ids = [t["id"] for t in perf["tags"]] - performer_tags_names = [t["name"] for t in perf["tags"]] - - if not performer_tags_ids: - processed += 1 - continue + performer_tags_ids = [] + performer_tags_names = [] + for performer_tag in perf[0]["tags"]: + performer_tags_ids.append(performer_tag["id"]) + performer_tags_names.append(performer_tag["name"]) - scene_query = { - "performers": { - "value": [perf["id"]], - "modifier": "INCLUDES_ALL" - } + scene_query = { + "performers": { + "value": [perf[0]["id"]], + "modifier": "INCLUDES_ALL" + } + } + if settings['excludeSceneOrganized']: + scene_query["organized"] = False + if exclusion_marker_tag_id is not None: + scene_query["tags"] = { + "value": [exclusion_marker_tag_id], + "modifier": "EXCLUDES" } - if settings['excludeSceneOrganized']: - scene_query["organized"] = False - if exclusion_marker_tag_id is not None: - scene_query["tags"] = { - "value": [exclusion_marker_tag_id], - "modifier": "EXCLUDES" - } - - performer_scenes = stash.find_scenes(f=scene_query, fragment='id') - if not performer_scenes: - processed += 1 - continue - performer_scene_ids = [scene['id'] for scene in performer_scenes] - tags_string = ", ".join(performer_tags_names) - context_msg = f"Performer: '{perf['name']}' | Tags: [{tags_string}]" + performer_scene_count = stash.find_scenes(f=scene_query, filter={"page": 0, "per_page": 0}, get_count=True)[0] + + if performer_scene_count > 0: + log.info(f"updating {performer_scene_count} scenes of performer \"{ perf[0]['name']}\" with tags {performer_tags_names}") - log.info(f"Bulk updating {len(performer_scene_ids)} scenes ({context_msg})") + performer_scene_page_size = 100 + performer_scene_page = 0 + while performer_scene_page * performer_scene_page_size < performer_scene_count: + performer_scenes = stash.find_scenes(f=scene_query, filter={"page": performer_scene_page, "per_page": performer_scene_page_size}, fragment='id') + performer_scene_ids = [performer_scene['id'] for performer_scene in performer_scenes] - for i in range(0, len(performer_scene_ids), SCENE_UPDATE_BATCH): - batch = performer_scene_ids[i:i + SCENE_UPDATE_BATCH] stash.update_scenes( { - "ids": batch, + "ids": performer_scene_ids, "tag_ids": {"mode": "ADD", "ids": performer_tags_ids}, } ) - processed += 1 - page += 1 + performer_scene_page += 1 + + i = i + 1 -def processScene(scene: dict): +def processScene(scene): + tags = [] + performersIds = [] + should_tag = True if settings["excludeSceneWithTag"] != "": - for tag in scene.get("tags", []): + for tag in scene["tags"]: if tag["name"] == settings["excludeSceneWithTag"]: - return + should_tag = False + break - if settings['excludeSceneOrganized'] and scene.get('organized'): - return - - target_tag_ids = [] - for perf in scene.get("performers", []): - for tag in perf.get("tags", []): - target_tag_ids.append(tag["id"]) - - if not target_tag_ids: - return - - stash.update_scenes({ - "ids": [scene["id"]], - "tag_ids": {"mode": "ADD", "ids": list(set(target_tag_ids))} - }) + if settings['excludeSceneOrganized']: + if scene['organized']: + should_tag = False + + if should_tag: + for perf in scene["performers"]: + performersIds.append(perf["id"]) + performers = [] + for perfId in performersIds: + performers.append(stash.find_performer(perfId)) + for perf in performers: + for tag in perf["tags"]: + tags.append(tag["id"]) + stash.update_scenes({"ids": scene["id"], "tag_ids": {"mode": "ADD", "ids": tags}}) + tags = [] + performersIds = [] + performers = [] json_input = json.loads(sys.stdin.read()) FRAGMENT_SERVER = json_input["server_connection"] stash = StashInterface(FRAGMENT_SERVER) config = stash.get_configuration() - settings = { "excludeSceneWithTag": "", "excludeSceneOrganized": False @@ -132,14 +114,12 @@ def processScene(scene: dict): processAll() elif "hookContext" in json_input["args"]: id = json_input["args"]["hookContext"]["id"] - hook_type = json_input["args"]["hookContext"].get("type", "") - if ( - (hook_type == "Scene.Update.Post" or hook_type == "Scene.Create.Post") - and "inputFields" in json_input["args"]["hookContext"] - and len(json_input["args"]["hookContext"]["inputFields"]) > 1 + ( + json_input["args"]["hookContext"]["type"] == "Scene.Update.Post" + or "Scene.Create.Post" + ) and "inputFields" in json_input["args"]["hookContext"] + and len(json_input["args"]["hookContext"]["inputFields"]) > 2 ): - # Enforce explicit studio object allocation inside our graphQL post-hook criteria - scene = stash.find_scene(id, fragment="id organized tags { name } performers { id tags { id } } studio { id }") - if scene: - processScene(scene) \ No newline at end of file + scene = stash.find_scene(id, fragment="id organized tags {name} performers {id}") + processScene(scene) diff --git a/plugins/tagScenesWithPerfTags/tagScenesWithPerfTags.yml b/plugins/tagScenesWithPerfTags/tagScenesWithPerfTags.yml index 395d03b9..640da0f1 100644 --- a/plugins/tagScenesWithPerfTags/tagScenesWithPerfTags.yml +++ b/plugins/tagScenesWithPerfTags/tagScenesWithPerfTags.yml @@ -1,6 +1,6 @@ name: Tag Scenes From Performer Tags description: tags scenes with performer tags. -version: 1.0 +version: 0.2.3 url: https://discourse.stashapp.cc/t/tag-scenes-from-performer-tags/1413 exec: - python