Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions plugins/tagImagesFromGalleries/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
stashapp-tools
174 changes: 174 additions & 0 deletions plugins/tagImagesFromGalleries/tagImagesFromGalleries.py
Original file line number Diff line number Diff line change
@@ -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)
33 changes: 33 additions & 0 deletions plugins/tagImagesFromGalleries/tagImagesFromGalleries.yml
Original file line number Diff line number Diff line change
@@ -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
Loading