From 31bb8aa42d34f74cabcd73eb186f229b1c50cff6 Mon Sep 17 00:00:00 2001 From: DogmaDragon <103123951+DogmaDragon@users.noreply.github.com> Date: Sat, 30 May 2026 07:09:08 +0300 Subject: [PATCH] Add tagImagesFromGalleries plugin (#722) Co-authored-by: unknown --- .../tagImagesFromGalleries/requirements.txt | 1 + .../tagImagesFromGalleries.py | 174 ++++++++++++++++++ .../tagImagesFromGalleries.yml | 33 ++++ 3 files changed, 208 insertions(+) create mode 100644 plugins/tagImagesFromGalleries/requirements.txt create mode 100644 plugins/tagImagesFromGalleries/tagImagesFromGalleries.py create mode 100644 plugins/tagImagesFromGalleries/tagImagesFromGalleries.yml diff --git a/plugins/tagImagesFromGalleries/requirements.txt b/plugins/tagImagesFromGalleries/requirements.txt new file mode 100644 index 0000000..e0fcf02 --- /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 0000000..7478234 --- /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 0000000..5228b4f --- /dev/null +++ b/plugins/tagImagesFromGalleries/tagImagesFromGalleries.yml @@ -0,0 +1,33 @@ +name: Tag Images from Galleries +description: Tags images with tags of galleries. +version: 1.1 + +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 databases. + defaultArgs: + mode: processAll