feat(images): set, remove and list images' own categories (#332)

PUT /prompt_manager/images/category sets the own category of up to 500
listed files, DELETE removes it (listed or not), and
GET /prompt_manager/images/categories lists the values whose file is
listed. Targets are the Images listing identity of an output source,
checked by media_target (shared with Collections). The routes never
touch prompts, recipes or links; POST /prompt_manager/bulk/category is
unchanged.
This commit is contained in:
Vito Sansevero
2026-10-27 18:21:35 -07:00
parent 0237fba77a
commit df6f538283
8 changed files with 741 additions and 39 deletions
+3
View File
@@ -72,6 +72,7 @@ from .diagnostics_report import DiagnosticsReportMixin
from .autotag_routes import AutotagRoutesMixin
from .lora_integration import LoraIntegrationMixin
from .lora_import_routes import LoraImportRoutesMixin
from .image_categories import ImageCategoryRoutesMixin
try:
from ...database.operations import PromptDatabase
@@ -336,6 +337,7 @@ class PromptManagerAPI(
AutotagRoutesMixin,
LoraIntegrationMixin,
LoraImportRoutesMixin,
ImageCategoryRoutesMixin,
):
"""REST API handler for PromptManager operations and web interface.
@@ -700,6 +702,7 @@ class PromptManagerAPI(
self._register_autotag_routes(routes)
self._register_lora_routes(routes)
self._register_lora_import_routes(routes)
self._register_image_category_routes(routes)
# Register gzip compression middleware (once)
global _gzip_registered
+8 -36
View File
@@ -27,7 +27,12 @@ from .annotation_suggest import (
validate_options,
)
from .autotag_routes import model_busy_response
from .images import _forbidden, _has_traversal, _unavailable_link, _verified_title
# The target check is shared with the collection routes (#331); both names
# stay importable from here.
from .media_target import MAX_TARGET_PATH # noqa: F401
from .media_target import media_target as _target
from .images import _unavailable_link, _verified_title
from .prompts import bad_request
from .. import model_gate
@@ -40,7 +45,7 @@ try:
)
from ...database.execution_history import subscribe_history_restore
from ...database.library_commands import LibraryCommandError
from ...utils.media_capture import CAPTURE_EXTENSIONS, CaptureError, CaptureStore
from ...utils.media_capture import CaptureError, CaptureStore
except ImportError:
from database.annotations import (
MAX_IMAGE_TAGS,
@@ -50,15 +55,13 @@ except ImportError:
)
from database.execution_history import subscribe_history_restore
from database.library_commands import LibraryCommandError
from utils.media_capture import CAPTURE_EXTENSIONS, CaptureError, CaptureStore
from utils.media_capture import CaptureError, CaptureStore
# Request bodies of the annotation routes are small (a target plus a tag set).
MAX_BODY_BYTES = 64 * 1024
MAX_TARGET_PATH = 1024
CAPTURE_URL = "/prompt_manager/image-annotations/capture/"
_PREVIEW_CHUNK = 64 * 1024
_TARGET_FIELDS = frozenset({"root_index", "root_scope", "path"})
_OBSERVE_FIELDS = frozenset({"target"})
_SAVE_FIELDS = frozenset(
{
@@ -188,37 +191,6 @@ def _capture_error(exc: CaptureError) -> web.Response:
return _coded(exc.code, _MESSAGES.get(exc.code) or str(exc))
def _target(data: object):
"""``((index, scope, relative), None)`` for a target, else ``(None, error)``.
Only the listing identity is accepted: an int root index, the root's
64-hex scope and a bounded root-relative POSIX media path, never an
absolute, drive-qualified, backslash or ``..`` path. Nothing here touches
the filesystem or the database.
"""
if not isinstance(data, dict) or set(data) != _TARGET_FIELDS:
return None, bad_request("target must name root_index, root_scope and path")
index, scope, relative = data["root_index"], data["root_scope"], data["path"]
if type(index) is not int or index < 0:
return None, bad_request("Invalid gallery root")
if not isinstance(scope, str) or _HEX64.fullmatch(scope) is None:
return None, bad_request("Invalid gallery root scope")
if not isinstance(relative, str) or not relative or len(relative) > MAX_TARGET_PATH:
return None, bad_request("path must be a relative media path")
parts = relative.split("/")
if (
"\0" in relative
or "\\" in relative
or relative.startswith("/")
or _has_traversal(relative)
or any(part in ("", ".", "..") or ":" in part for part in parts)
):
return None, _forbidden()
if os.path.splitext(parts[-1])[1].lower() not in CAPTURE_EXTENSIONS:
return None, _forbidden("Only media files can be tagged")
return (index, scope, relative), None
def _expires_at(seconds: float | None) -> str:
moment = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(
seconds=max(0.0, seconds or 0.0)
+285
View File
@@ -0,0 +1,285 @@
"""Image category routes (#332): an image file's own category.
``PUT /prompt_manager/images/category`` sets the own category of up to 500
listed files; ``DELETE`` removes it (listed or not, so a missing file's
category can still be removed); ``GET /prompt_manager/images/categories``
lists the own-category values of files the sources list. Targets are the
Images listing identity (``{root_index, root_scope, path}``, checked by
``media_target``) of an output source; LoRA folders are not sources here.
Each file is stored under its file key (``images.image_category_key``).
These routes never read or write prompts, recipes or image links.
"""
import os
from pathlib import Path
from aiohttp import web
from .image_tag_jobs import MAX_JOB_TARGETS, _parse_targets
from .images import (
_favorite_relative_key,
_favorite_root_key,
_forbidden,
_json_body_or_error,
image_category_key,
)
from .prompts import bad_request
try:
from ...database.image_categories import MAX_CATEGORY_LENGTH, MAX_KEY_LENGTH
from ...utils.validators import validate_category
except ImportError:
from database.image_categories import MAX_CATEGORY_LENGTH, MAX_KEY_LENGTH
from utils.validators import validate_category
CATEGORY_MEDIA_MESSAGE = "Only media files can have a category"
_PUT_FIELDS = frozenset({"items", "category"})
_DELETE_FIELDS = frozenset({"items"})
SOURCE_CHANGED_MESSAGE = "The sources changed. Refresh the images and try again."
WRITE_FAILED = "The category could not be changed."
READ_FAILED = "Image categories could not be read."
def _coded(status: int, code: str, message: str) -> web.Response:
return web.json_response(
{"success": False, "code": code, "error": message},
status=status,
headers={"Cache-Control": "no-store"},
)
def _category_value(raw) -> tuple[str | None, web.Response | None]:
"""The stripped category, or a 400 (an empty value is a 400: use DELETE)."""
if not isinstance(raw, str):
return None, bad_request("category must be a string")
value = raw.strip()
if not value:
return None, bad_request("category must not be empty")
if len(value) > MAX_CATEGORY_LENGTH:
return None, bad_request(
f"Category cannot exceed {MAX_CATEGORY_LENGTH} characters"
)
try:
validate_category(value)
except ValueError as exc:
return None, bad_request(str(exc))
return value, None
def _nested_sources(root: Path, sources) -> list[tuple[str, str]]:
"""``[(relative key prefix, source root key)]`` of sources inside *root*.
Deepest first, so the first prefix that matches is the file's source.
"""
base = os.path.join(os.path.normcase(os.path.realpath(str(root))), "")
nested = []
for source in sources:
real = os.path.realpath(str(source))
if not os.path.normcase(real).startswith(base):
continue
relative = os.path.relpath(real, os.path.realpath(str(root)))
nested.append(
(
_favorite_relative_key(relative.replace(os.sep, "/")),
_favorite_root_key(source),
)
)
nested.sort(key=lambda item: item[0].count("/"), reverse=True)
return nested
def _source_prefix(relative_key: str, nested) -> str | None:
"""The prefix of the deepest nested source holding *relative_key*."""
for prefix, _source_key in nested:
if relative_key.startswith(prefix + "/"):
return prefix
return None
class ImageCategoryRoutesMixin:
"""Routes for images' own categories (#332)."""
def _register_image_category_routes(self, routes):
# Fixed paths beside DELETE /prompt_manager/images/{image_id}, as the
# favorite routes are: matched before the dynamic resource.
@routes.put("/prompt_manager/images/category")
async def set_image_category_route(request):
return await self.set_image_category(request)
@routes.delete("/prompt_manager/images/category")
async def clear_image_category_route(request):
return await self.clear_image_category(request)
@routes.get("/prompt_manager/images/categories")
async def list_image_categories_route(request):
return await self.list_image_categories(request)
# -- reading ---------------------------------------------------------------
def _own_category_map(self, sources, listings) -> dict:
"""``{root / listed path: own category}`` for the listed files (blocking).
One primary-key range read per source; each stored row is matched to
the listing of every selected root that holds its source, so the work
follows the stored rows, not the files. A file under a nested source
is keyed by the deepest one (``image_category_key``). A row whose file
is gone matches nothing. A failed read raises.
"""
sources = list(sources)
rows = self.db.own_categories_of(_favorite_root_key(s) for s in sources)
if not rows:
return {}
by_source: dict[str, dict[str, str]] = {}
for (root_key, relative), category in rows.items():
by_source.setdefault(root_key, {})[relative] = category
found = {}
for _index, root, files in listings:
listing = self._favorite_listing(root, files)
root_key = _favorite_root_key(root)
nested = [
item for item in _nested_sources(root, sources) if item[1] != root_key
]
for relative, category in by_source.get(root_key, {}).items():
listed = listing.get(relative)
if listed is not None and _source_prefix(relative, nested) is None:
found[root / listed] = category
for prefix, source_key in nested:
for relative, category in by_source.get(source_key, {}).items():
full = f"{prefix}/{relative}"
listed = listing.get(full)
if listed is not None and _source_prefix(full, nested) == prefix:
found[root / listed] = category
return found
async def _source_listings(self, roots) -> list[tuple]:
return [
(index, root, await self._get_gallery_files(root))
for index, root in enumerate(roots)
]
async def list_image_categories(self, request):
"""GET: the own-category values of listed files, sorted."""
try:
roots = list(self._get_all_output_dirs())
listings = await self._source_listings(roots)
found = await self._run_in_executor(self._own_category_map, roots, listings)
except Exception:
self.logger.exception("Image categories read failed")
return _coded(500, "categories-failed", READ_FAILED)
return web.json_response(
{"success": True, "categories": sorted(set(found.values()))},
headers={"Cache-Control": "no-store"},
)
# -- writing ---------------------------------------------------------------
async def _category_request(self, request, fields: frozenset):
"""``(data, targets, roots, None)`` or ``(None, None, None, error)``."""
data, error = await _json_body_or_error(request)
if error is not None:
return None, None, None, error
if set(data) != fields:
return (
None,
None,
None,
bad_request(
"Request must contain exactly " + ", ".join(sorted(fields))
),
)
items = data["items"]
if not isinstance(items, list) or not 1 <= len(items) <= MAX_JOB_TARGETS:
return (
None,
None,
None,
bad_request(f"items must list 1 to {MAX_JOB_TARGETS} images"),
)
targets, error = _parse_targets(items, media_message=CATEGORY_MEDIA_MESSAGE)
if error is not None:
return None, None, None, error
roots = list(self._get_all_output_dirs())
for index, scope in dict.fromkeys((t[0], t[1]) for t in targets):
if index >= len(roots):
return None, None, None, bad_request("Invalid gallery root")
error = self._gallery_scope_error(
{"root_scope": scope, "root": str(index)}, roots
)
if error is not None:
if error.status == 409:
error = _coded(409, "source-changed", SOURCE_CHANGED_MESSAGE)
return None, None, None, error
return data, targets, roots, None
async def set_image_category(self, request):
"""PUT ``{items, category}``: set the own category of listed files."""
data, targets, roots, error = await self._category_request(request, _PUT_FIELDS)
if error is not None:
return error
category, error = _category_value(data["category"])
if error is not None:
return error
try:
listings = {}
listed_keys, missing_keys = {}, {}
for index, _scope, relative in targets:
root = roots[index]
if index not in listings:
files = await self._get_gallery_files(root)
listings[index] = await self._run_in_executor(
self._favorite_listing, root, files
)
listed = listings[index].get(_favorite_relative_key(relative))
key = image_category_key(roots, root / (listed or relative))
if key is None or max(len(key[0]), len(key[1])) > MAX_KEY_LENGTH:
return _forbidden("Path is too long")
(missing_keys if listed is None else listed_keys)[key] = True
for key in listed_keys:
missing_keys.pop(key, None)
if not listed_keys:
return _coded(
404,
"files-not-found",
"None of the selected images could be found.",
)
counts = await self._run_in_executor(
self.db.set_image_categories, list(listed_keys), category
)
except Exception:
self.logger.exception("Image category update failed")
return _coded(500, "category-failed", WRITE_FAILED)
return web.json_response(
{
"success": True,
"category": category,
"updated": counts["updated"],
"unchanged": counts["unchanged"],
"not_found": len(missing_keys),
},
headers={"Cache-Control": "no-store"},
)
async def clear_image_category(self, request):
"""DELETE ``{items}``: remove the own category, listed or not."""
_data, targets, roots, error = await self._category_request(
request, _DELETE_FIELDS
)
if error is not None:
return error
keys = {}
for index, _scope, relative in targets:
key = image_category_key(roots, roots[index] / relative)
if key is None or max(len(key[0]), len(key[1])) > MAX_KEY_LENGTH:
return _forbidden("Path is too long")
keys[key] = True
try:
cleared = await self._run_in_executor(
self.db.clear_image_categories, list(keys)
)
except Exception:
self.logger.exception("Image category removal failed")
return _coded(500, "category-failed", WRITE_FAILED)
return web.json_response(
{"success": True, "cleared": cleared},
headers={"Cache-Control": "no-store"},
)
+7 -3
View File
@@ -193,13 +193,17 @@ class _Spec:
return scope
def _parse_targets(raw):
"""Unique validated targets in request order, or ``(None, error)``."""
def _parse_targets(raw, **target_options):
"""Unique validated targets in request order, or ``(None, error)``.
``target_options`` reach the target check (the image category routes
pass their own ``media_message``, #332).
"""
if not isinstance(raw, list) or not 1 <= len(raw) <= MAX_JOB_TARGETS:
return None, bad_request(f"targets must list 1 to {MAX_JOB_TARGETS} images")
seen, targets = set(), []
for item in raw:
target, error = _target(item)
target, error = _target(item, **target_options)
if error is not None:
return None, error
if target not in seen:
+54
View File
@@ -0,0 +1,54 @@
"""The listing identity of one media file, as a request names it.
Shared by the image-annotation routes and the collection routes (#331): a
target is ``{root_index, root_scope, path}``, checked here without touching
the filesystem or the database. The scope is compared with the current roots
by the caller.
"""
import os
import re
from .images import _forbidden, _has_traversal
from .prompts import bad_request
try:
from ...utils.media_capture import CAPTURE_EXTENSIONS
except ImportError:
from utils.media_capture import CAPTURE_EXTENSIONS
MAX_TARGET_PATH = 1024
TARGET_FIELDS = frozenset({"root_index", "root_scope", "path"})
_HEX64 = re.compile(r"[0-9a-f]{64}")
TAGGING_MEDIA_MESSAGE = "Only media files can be tagged"
def media_target(data: object, *, media_message: str = TAGGING_MEDIA_MESSAGE):
"""``((index, scope, relative), None)`` for a target, else ``(None, error)``.
Only the listing identity is accepted: an int root index, the root's
64-hex scope and a bounded root-relative POSIX media path, never an
absolute, drive-qualified, backslash or ``..`` path (a 400 for a malformed
shape, a 403 for an unsafe or non-media path, carrying *media_message*).
"""
if not isinstance(data, dict) or set(data) != TARGET_FIELDS:
return None, bad_request("target must name root_index, root_scope and path")
index, scope, relative = data["root_index"], data["root_scope"], data["path"]
if type(index) is not int or index < 0:
return None, bad_request("Invalid gallery root")
if not isinstance(scope, str) or _HEX64.fullmatch(scope) is None:
return None, bad_request("Invalid gallery root scope")
if not isinstance(relative, str) or not relative or len(relative) > MAX_TARGET_PATH:
return None, bad_request("path must be a relative media path")
parts = relative.split("/")
if (
"\0" in relative
or "\\" in relative
or relative.startswith("/")
or _has_traversal(relative)
or any(part in ("", ".", "..") or ":" in part for part in parts)
):
return None, _forbidden()
if os.path.splitext(parts[-1])[1].lower() not in CAPTURE_EXTENSIONS:
return None, _forbidden(media_message)
return (index, scope, relative), None