Replaces old list-format tags (which duplicated prompt content) with structured dict tags per category (origin_series, outfit_type, participants, style_type, scene_type, etc.). Tags are now purely organizational metadata — removed from the prompt pipeline entirely. Adds is_favourite and is_nsfw columns to all 8 resource models. Favourite is DB-only (user preference); NSFW is mirrored in JSON tags for rescan persistence. All library pages get filter controls and favourites-first sorting. Introduces a parallel LLM job queue (_enqueue_task + _llm_queue_worker) for background tag regeneration, with the same status polling UI as ComfyUI jobs. Fixes call_llm() to use has_request_context() fallback for background threads. Adds global search (/search) across resources and gallery images, with navbar search bar. Adds gallery image sidecar JSON for per-image favourite/NSFW metadata. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
210 lines
9.0 KiB
Python
210 lines
9.0 KiB
Python
import json
|
|
import os
|
|
import logging
|
|
|
|
from flask import render_template, request, current_app
|
|
from models import (
|
|
db, Character, Look, Outfit, Action, Style, Scene, Detailer, Checkpoint,
|
|
)
|
|
|
|
logger = logging.getLogger('gaze')
|
|
|
|
# Category config: (model_class, id_field, name_field, url_prefix, detail_route)
|
|
_SEARCH_CATEGORIES = {
|
|
'characters': (Character, 'character_id', 'character_name', '/character', 'detail'),
|
|
'looks': (Look, 'look_id', 'look_name', '/look', 'look_detail'),
|
|
'outfits': (Outfit, 'outfit_id', 'outfit_name', '/outfit', 'outfit_detail'),
|
|
'actions': (Action, 'action_id', 'action_name', '/action', 'action_detail'),
|
|
'styles': (Style, 'style_id', 'style_name', '/style', 'style_detail'),
|
|
'scenes': (Scene, 'scene_id', 'scene_name', '/scene', 'scene_detail'),
|
|
'detailers': (Detailer, 'detailer_id', 'detailer_name', '/detailer', 'detailer_detail'),
|
|
'checkpoints': (Checkpoint, 'checkpoint_id', 'checkpoint_name', '/checkpoint', 'checkpoint_detail'),
|
|
}
|
|
|
|
|
|
def register_routes(app):
|
|
|
|
def _search_resources(query_str, category_filter='all', nsfw_filter='all'):
|
|
"""Search resources by name, tag values, and prompt field content."""
|
|
results = []
|
|
q = query_str.lower()
|
|
|
|
categories = _SEARCH_CATEGORIES if category_filter == 'all' else {category_filter: _SEARCH_CATEGORIES.get(category_filter)}
|
|
categories = {k: v for k, v in categories.items() if v}
|
|
|
|
for cat_name, (model_class, id_field, name_field, url_prefix, detail_route) in categories.items():
|
|
entities = model_class.query.all()
|
|
for entity in entities:
|
|
# Apply NSFW filter
|
|
if nsfw_filter == 'sfw' and getattr(entity, 'is_nsfw', False):
|
|
continue
|
|
if nsfw_filter == 'nsfw' and not getattr(entity, 'is_nsfw', False):
|
|
continue
|
|
|
|
match_context = None
|
|
score = 0
|
|
|
|
# 1. Name match (highest priority)
|
|
if q in entity.name.lower():
|
|
score = 100 if entity.name.lower() == q else 90
|
|
match_context = f"Name: {entity.name}"
|
|
|
|
# 2. Tag match
|
|
if not match_context:
|
|
data = entity.data or {}
|
|
tags = data.get('tags', {})
|
|
if isinstance(tags, dict):
|
|
for key, val in tags.items():
|
|
if key == 'nsfw':
|
|
continue
|
|
if isinstance(val, str) and q in val.lower():
|
|
score = 70
|
|
match_context = f"Tag {key}: {val}"
|
|
break
|
|
elif isinstance(val, list):
|
|
for item in val:
|
|
if isinstance(item, str) and q in item.lower():
|
|
score = 70
|
|
match_context = f"Tag {key}: {item}"
|
|
break
|
|
|
|
# 3. Prompt field match (search JSON data values)
|
|
if not match_context:
|
|
data_str = json.dumps(entity.data or {}).lower()
|
|
if q in data_str:
|
|
score = 30
|
|
# Find which field matched for context
|
|
data = entity.data or {}
|
|
for section_key, section_val in data.items():
|
|
if section_key in ('lora', 'tags'):
|
|
continue
|
|
if isinstance(section_val, dict):
|
|
for field_key, field_val in section_val.items():
|
|
if isinstance(field_val, str) and q in field_val.lower():
|
|
match_context = f"{section_key}.{field_key}: ...{field_val[:80]}..."
|
|
break
|
|
elif isinstance(section_val, str) and q in section_val.lower():
|
|
match_context = f"{section_key}: ...{section_val[:80]}..."
|
|
if match_context:
|
|
break
|
|
if not match_context:
|
|
match_context = "Matched in data"
|
|
|
|
if match_context:
|
|
results.append({
|
|
'type': cat_name,
|
|
'slug': entity.slug,
|
|
'name': entity.name,
|
|
'match_context': match_context,
|
|
'score': score,
|
|
'is_favourite': getattr(entity, 'is_favourite', False),
|
|
'is_nsfw': getattr(entity, 'is_nsfw', False),
|
|
'image_path': entity.image_path,
|
|
'detail_route': detail_route,
|
|
})
|
|
|
|
results.sort(key=lambda x: (-x['score'], x['name'].lower()))
|
|
return results
|
|
|
|
def _search_images(query_str, nsfw_filter='all'):
|
|
"""Search gallery images by prompt metadata in sidecar JSON files."""
|
|
results = []
|
|
q = query_str.lower()
|
|
upload_folder = app.config['UPLOAD_FOLDER']
|
|
|
|
gallery_cats = ['characters', 'actions', 'outfits', 'scenes', 'styles',
|
|
'detailers', 'checkpoints', 'looks', 'presets', 'generator']
|
|
|
|
for cat in gallery_cats:
|
|
cat_folder = os.path.join(upload_folder, cat)
|
|
if not os.path.isdir(cat_folder):
|
|
continue
|
|
try:
|
|
slugs = os.listdir(cat_folder)
|
|
except OSError:
|
|
continue
|
|
for item_slug in slugs:
|
|
item_folder = os.path.join(cat_folder, item_slug)
|
|
if not os.path.isdir(item_folder):
|
|
continue
|
|
try:
|
|
files = os.listdir(item_folder)
|
|
except OSError:
|
|
continue
|
|
for filename in files:
|
|
if not filename.lower().endswith('.json'):
|
|
continue
|
|
# This is a sidecar JSON
|
|
sidecar_path = os.path.join(item_folder, filename)
|
|
try:
|
|
with open(sidecar_path) as f:
|
|
sidecar = json.load(f)
|
|
except (OSError, json.JSONDecodeError):
|
|
continue
|
|
|
|
# NSFW filter
|
|
if nsfw_filter == 'sfw' and sidecar.get('is_nsfw'):
|
|
continue
|
|
if nsfw_filter == 'nsfw' and not sidecar.get('is_nsfw'):
|
|
continue
|
|
|
|
# Search positive/negative prompts stored in sidecar
|
|
sidecar_str = json.dumps(sidecar).lower()
|
|
if q in sidecar_str:
|
|
img_filename = filename.rsplit('.', 1)[0]
|
|
# Find the actual image file
|
|
img_path = None
|
|
for ext in ('.png', '.jpg', '.jpeg', '.webp'):
|
|
candidate = f"{cat}/{item_slug}/{img_filename}{ext}"
|
|
if os.path.isfile(os.path.join(upload_folder, candidate)):
|
|
img_path = candidate
|
|
break
|
|
|
|
if img_path:
|
|
results.append({
|
|
'path': img_path,
|
|
'category': cat,
|
|
'slug': item_slug,
|
|
'is_favourite': sidecar.get('is_favourite', False),
|
|
'is_nsfw': sidecar.get('is_nsfw', False),
|
|
'match_context': f"Found in sidecar metadata",
|
|
})
|
|
|
|
if len(results) >= 50:
|
|
break
|
|
|
|
return results[:50]
|
|
|
|
@app.route('/search')
|
|
def search():
|
|
q = request.args.get('q', '').strip()
|
|
category = request.args.get('category', 'all')
|
|
nsfw = request.args.get('nsfw', 'all')
|
|
search_type = request.args.get('type', 'all')
|
|
|
|
resources = []
|
|
images = []
|
|
|
|
if q:
|
|
if search_type in ('all', 'resources'):
|
|
resources = _search_resources(q, category, nsfw)
|
|
if search_type in ('all', 'images'):
|
|
images = _search_images(q, nsfw)
|
|
|
|
# Group resources by type
|
|
grouped = {}
|
|
for r in resources:
|
|
grouped.setdefault(r['type'], []).append(r)
|
|
|
|
return render_template(
|
|
'search.html',
|
|
query=q,
|
|
category=category,
|
|
nsfw_filter=nsfw,
|
|
search_type=search_type,
|
|
grouped_resources=grouped,
|
|
images=images,
|
|
total_resources=len(resources),
|
|
total_images=len(images),
|
|
)
|