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..81413b78 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 +description: Tags images with performer tags. +version: 1.0 url: https://discourse.stashapp.cc/t/tag-images-from-performer-tags/2059 exec: - python @@ -8,8 +8,8 @@ exec: interface: raw hooks: - - name: update image - description: Will tag image with selected performers tags + - name: Update image + description: Will tag image with selected performers tags. triggeredBy: - Image.Update.Post - Image.Create.Post @@ -17,15 +17,15 @@ hooks: settings: excludeImageOrganized: displayName: Exclude Images marked as organized - description: Do not automatically tag images with performer tags if the image is marked as organized + description: Do not automatically tag images with performer tags if the image is marked as organized. type: BOOLEAN excludeImageWithTag: displayName: Exclude Images with Tag from Hook - description: Do not automatically tag images with performer tags if the image has this tag + description: Do not automatically tag images with performer tags if the image has this tag. type: STRING tasks: - name: "Tag All Images" - description: Loops through all performers, finds all of their images, then applies the performers tags to each of the images they appear in. Can take a long time on large db's. + description: Loops through all performers, finds all of their images, then applies the performers tags to each of the images they appear in. Can take a long time on large databases. defaultArgs: mode: processAll