Merge remote-tracking branch 'origin/main' into feat/247-foreign-workflows-pr2
# Conflicts: # TESTING.md
This commit is contained in:
@@ -48,6 +48,7 @@ from .review import ReviewRoutesMixin
|
||||
from .review_recording import ReviewRecordingMixin
|
||||
from .review_events import ReviewEventsMixin
|
||||
from .saved_views import SavedViewRoutesMixin
|
||||
from .collections import CollectionRoutesMixin
|
||||
from .history import HistoryRoutesMixin
|
||||
from .images import ImageRoutesMixin
|
||||
from .gallery_listing import WALK_STOPPED, RootListing, WalkOutcome
|
||||
@@ -317,6 +318,7 @@ class PromptManagerAPI(
|
||||
ReviewRecordingMixin,
|
||||
ReviewEventsMixin,
|
||||
SavedViewRoutesMixin,
|
||||
CollectionRoutesMixin,
|
||||
HistoryRoutesMixin,
|
||||
PromptRoutesMixin,
|
||||
ImageRoutesMixin,
|
||||
@@ -688,6 +690,7 @@ class PromptManagerAPI(
|
||||
self._register_review_routes(routes)
|
||||
self._register_review_event_routes(routes)
|
||||
self._register_saved_view_routes(routes)
|
||||
self._register_collection_routes(routes)
|
||||
self._register_history_routes(routes)
|
||||
self._register_prompt_routes(routes)
|
||||
self._register_image_routes(routes)
|
||||
|
||||
@@ -86,6 +86,23 @@ def unavailable_message(model_name: str) -> str:
|
||||
return f"{model_name} is not downloaded. {UNAVAILABLE_HINT}"
|
||||
|
||||
|
||||
def model_refusal(status: dict, model_type: str, model_name: str):
|
||||
"""Why *model_type* cannot run, from ``get_models_status()``, else None.
|
||||
|
||||
Not downloaded first (``unavailable_message``), then a backend package
|
||||
that cannot be imported (#424). Both are a 409 ``model-unavailable``.
|
||||
"""
|
||||
entry = status.get(model_type) or {}
|
||||
if not entry.get("downloaded"):
|
||||
return unavailable_message(model_name)
|
||||
missing = entry.get("missing_packages") or []
|
||||
if missing:
|
||||
from ..autotag import missing_packages_message
|
||||
|
||||
return missing_packages_message(model_type, [str(name) for name in missing])
|
||||
return None
|
||||
|
||||
|
||||
def run_suggestions(service, capture_path: str, model_type: str, options: dict):
|
||||
"""Blocking: load (or swap to) *model_type*, then suggest for the capture.
|
||||
|
||||
|
||||
@@ -21,9 +21,9 @@ from aiohttp import web
|
||||
from .annotation_suggest import (
|
||||
SUGGEST_FIELDS,
|
||||
OptionError,
|
||||
model_refusal,
|
||||
run_suggestions,
|
||||
suggestion_rows,
|
||||
unavailable_message,
|
||||
validate_options,
|
||||
)
|
||||
from .autotag_routes import model_busy_response
|
||||
@@ -595,7 +595,7 @@ class AnnotationRoutesMixin:
|
||||
it, including a legacy batch), never downloads (``model-unavailable``)
|
||||
and never writes.
|
||||
"""
|
||||
from ..autotag import MODELS, get_autotag_service
|
||||
from ..autotag import MODELS, MissingBackendPackages, get_autotag_service
|
||||
from ..config import MAX_SUGGESTION_LIMIT, AutotagConfig
|
||||
|
||||
data, error = await self._annotation_body(request, SUGGEST_FIELDS)
|
||||
@@ -633,9 +633,10 @@ class AnnotationRoutesMixin:
|
||||
return error
|
||||
service = get_autotag_service()
|
||||
status = await self._run_in_executor(service.get_models_status)
|
||||
if not status.get(model_type, {}).get("downloaded"):
|
||||
name = MODELS.get(model_type, {}).get("name", model_type)
|
||||
return _coded("model-unavailable", unavailable_message(name))
|
||||
name = MODELS.get(model_type, {}).get("name", model_type)
|
||||
refusal = model_refusal(status, model_type, name)
|
||||
if refusal is not None:
|
||||
return _coded("model-unavailable", refusal)
|
||||
owner = model_gate.new_owner("image-annotations")
|
||||
if not model_gate.try_acquire(owner):
|
||||
return model_busy_response()
|
||||
@@ -654,6 +655,9 @@ class AnnotationRoutesMixin:
|
||||
options,
|
||||
on_skipped=functools.partial(store.release_use, token),
|
||||
)
|
||||
except MissingBackendPackages as exc:
|
||||
# A package gone since the status read (load_model logged it).
|
||||
return _coded("model-unavailable", str(exc))
|
||||
except Exception:
|
||||
self.logger.exception("Tag suggestion failed")
|
||||
return _error(
|
||||
|
||||
@@ -102,6 +102,17 @@ def size_label(size: str) -> str:
|
||||
return f"{match.group(1)} {match.group(2)}" if match else str(size or "")
|
||||
|
||||
|
||||
def package_entry(flags: dict) -> dict:
|
||||
"""The public package fields of one status entry (#424): the pip names
|
||||
that cannot be imported, the install command and note (strings or null)."""
|
||||
missing = flags.get("missing_packages") or []
|
||||
text = {
|
||||
key: flags[key] if isinstance(flags.get(key), str) else None
|
||||
for key in ("install_command", "install_note")
|
||||
}
|
||||
return {"missing_packages": [str(name) for name in missing], **text}
|
||||
|
||||
|
||||
def public_models(status: dict, loaded_type: Optional[str]) -> dict:
|
||||
"""Each engine model's public entry: names and sizes from ``MODELS``, the
|
||||
flags from ``status``; never a path (``model_path`` is dropped)."""
|
||||
@@ -122,6 +133,7 @@ def public_models(status: dict, loaded_type: Optional[str]) -> dict:
|
||||
for key in ("model_exists", "mmproj_exists"):
|
||||
if key in flags:
|
||||
entry[key] = bool(flags[key])
|
||||
entry.update(package_entry(flags))
|
||||
models[model_type] = entry
|
||||
return models
|
||||
|
||||
@@ -750,6 +762,7 @@ class AutotagRoutesMixin:
|
||||
"downloaded": bool(
|
||||
(status.get(model_type) or {}).get("downloaded")
|
||||
),
|
||||
**package_entry(status.get(model_type) or {}),
|
||||
},
|
||||
"existing": existing,
|
||||
"review_required": existing == "replace_after_review",
|
||||
@@ -1073,8 +1086,8 @@ class AutotagRoutesMixin:
|
||||
return self.db.current_image_tags({locator: fingerprint}).get(locator, [])
|
||||
|
||||
async def _suggest_single(self, target, model_type, options, base):
|
||||
from ..autotag import MODELS, get_autotag_service
|
||||
from .annotation_suggest import unavailable_message
|
||||
from ..autotag import MODELS, MissingBackendPackages, get_autotag_service
|
||||
from .annotation_suggest import model_refusal
|
||||
from .annotations import _pinned_suggestions, _settled, _submit
|
||||
|
||||
try:
|
||||
@@ -1084,8 +1097,8 @@ class AutotagRoutesMixin:
|
||||
|
||||
service = get_autotag_service()
|
||||
status = await self._run_in_executor(service.get_models_status)
|
||||
if not (status.get(model_type) or {}).get("downloaded"):
|
||||
message = unavailable_message(MODELS[model_type]["name"])
|
||||
message = model_refusal(status, model_type, MODELS[model_type]["name"])
|
||||
if message is not None:
|
||||
return self._coded_error(409, "model-unavailable", message)
|
||||
owner = model_gate.new_owner("legacy-single")
|
||||
if not model_gate.try_acquire(owner):
|
||||
@@ -1131,6 +1144,9 @@ class AutotagRoutesMixin:
|
||||
options,
|
||||
on_skipped=functools.partial(store.release_use, capture.token),
|
||||
)
|
||||
except MissingBackendPackages as exc:
|
||||
# A package gone since the status read (load_model logged it).
|
||||
return self._coded_error(409, "model-unavailable", str(exc))
|
||||
finally:
|
||||
store.release(capture.token)
|
||||
finally:
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
"""Collections (#331): named, hand-picked groups of images.
|
||||
|
||||
Routes under ``/prompt_manager/collections``. A file is named by its listing
|
||||
identity ``{root_index, root_scope, path}`` (``media_target.py``) and stored
|
||||
under the favorites' keys (``_favorite_root_key``, ``_favorite_relative_key``),
|
||||
which never leave the server. A stale scope (the sources were reordered) is a
|
||||
409 ``source-changed`` before anything is written. Responses are
|
||||
``{"success": bool, ...}``; an unexpected failure is a logged 500 with a
|
||||
generic message, never the exception text.
|
||||
"""
|
||||
|
||||
import hmac
|
||||
import re
|
||||
from functools import wraps
|
||||
from typing import Callable
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from .images import (
|
||||
_favorite_relative_key,
|
||||
_favorite_root_key,
|
||||
_forbidden,
|
||||
_json_body_or_error,
|
||||
collection_not_found,
|
||||
)
|
||||
from .media_target import media_target
|
||||
from .prompts import bad_request
|
||||
|
||||
try:
|
||||
from ...database.image_collections import (
|
||||
CollectionFilesMissing,
|
||||
CollectionInvalid,
|
||||
CollectionMissing,
|
||||
CollectionNameTaken,
|
||||
normalize_collection_name,
|
||||
)
|
||||
from ...database.image_favorites import MAX_KEY_LENGTH
|
||||
except ImportError:
|
||||
from database.image_collections import (
|
||||
CollectionFilesMissing,
|
||||
CollectionInvalid,
|
||||
CollectionMissing,
|
||||
CollectionNameTaken,
|
||||
normalize_collection_name,
|
||||
)
|
||||
from database.image_favorites import MAX_KEY_LENGTH
|
||||
|
||||
BASE = "/prompt_manager/collections"
|
||||
# The most files one add or remove names (the bulk tagging cap, MC12).
|
||||
MAX_ITEMS = 500
|
||||
MAX_ID = 999_999_999
|
||||
_PATH_ID = re.compile(r"[1-9][0-9]{0,8}")
|
||||
_ROOT_INDEX = re.compile(r"[0-9]{1,9}")
|
||||
_ADD_TARGETS = (frozenset({"collection_id", "items"}), frozenset({"name", "items"}))
|
||||
_REMOVE_FIELDS = frozenset({"collection_id", "items"})
|
||||
_RENAME_FIELDS = frozenset({"name"})
|
||||
_MEDIA_MESSAGE = "Only media files can be added to a collection"
|
||||
READ_FAILED = "Collections could not be read."
|
||||
CHANGE_FAILED = "The collection could not be changed."
|
||||
SOURCES_CHANGED = "The sources changed. Refresh the images and try again."
|
||||
FILES_NOT_FOUND = (
|
||||
"None of the selected images could be found. Refresh the images and try again."
|
||||
)
|
||||
NAME_TAKEN = "Another collection already has this name."
|
||||
|
||||
|
||||
class _Refused(Exception):
|
||||
"""A request refused before any write; carries its response."""
|
||||
|
||||
def __init__(self, response: web.Response):
|
||||
super().__init__(response.status)
|
||||
self.response = response
|
||||
|
||||
|
||||
def _coded(status: int, code: str, message: str, **extra) -> web.Response:
|
||||
return web.json_response(
|
||||
{"success": False, "code": code, "error": message, **extra},
|
||||
status=status,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
def _collection_response(failure: str) -> Callable:
|
||||
"""Map the database's refusals to their status; anything else to a 500."""
|
||||
|
||||
def decorate(handler: Callable) -> Callable:
|
||||
@wraps(handler)
|
||||
async def wrapped(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
return await handler(self, request)
|
||||
except _Refused as refused:
|
||||
return refused.response
|
||||
except CollectionInvalid as exc:
|
||||
return bad_request(str(exc))
|
||||
except CollectionMissing:
|
||||
return collection_not_found()
|
||||
except CollectionFilesMissing:
|
||||
return _coded(404, "files-not-found", FILES_NOT_FOUND)
|
||||
except CollectionNameTaken as exc:
|
||||
return _coded(
|
||||
409, "name-taken", NAME_TAKEN, collection_id=exc.collection_id
|
||||
)
|
||||
except Exception:
|
||||
self.logger.exception("Collection request failed")
|
||||
return web.json_response(
|
||||
{"success": False, "error": failure}, status=500
|
||||
)
|
||||
|
||||
return wrapped
|
||||
|
||||
return decorate
|
||||
|
||||
|
||||
def _path_id(request: web.Request) -> int:
|
||||
raw = request.match_info["collection_id"]
|
||||
if not raw.isascii() or _PATH_ID.fullmatch(raw) is None:
|
||||
raise _Refused(bad_request("Invalid collection id"))
|
||||
return int(raw)
|
||||
|
||||
|
||||
def _body_id(value: object) -> int:
|
||||
if type(value) is not int or not 1 <= value <= MAX_ID:
|
||||
raise _Refused(bad_request("Invalid collection id"))
|
||||
return value
|
||||
|
||||
|
||||
async def _body(request: web.Request, allowed: tuple) -> dict:
|
||||
data, error = await _json_body_or_error(request)
|
||||
if error is not None:
|
||||
raise _Refused(error)
|
||||
if frozenset(data) not in allowed:
|
||||
names = " or ".join(", ".join(sorted(fields)) for fields in allowed)
|
||||
raise _Refused(bad_request(f"Request must contain exactly {names}"))
|
||||
return data
|
||||
|
||||
|
||||
class CollectionRoutesMixin:
|
||||
"""Mixin providing the collection routes (#331)."""
|
||||
|
||||
def _register_collection_routes(self, routes: web.RouteTableDef) -> None:
|
||||
# The fixed paths go first: "/items" must never match "/{collection_id}".
|
||||
routes.get(BASE)(self.list_collections)
|
||||
routes.post(BASE + "/items")(self.add_collection_items)
|
||||
routes.delete(BASE + "/items")(self.remove_collection_items)
|
||||
routes.get(BASE + "/membership")(self.collection_membership)
|
||||
routes.patch(BASE + "/{collection_id}")(self.rename_collection)
|
||||
routes.delete(BASE + "/{collection_id}")(self.delete_collection)
|
||||
|
||||
# -- reads -------------------------------------------------------------
|
||||
|
||||
@_collection_response(READ_FAILED)
|
||||
async def list_collections(self, request: web.Request) -> web.Response:
|
||||
"""Every collection with its listed ``count`` and its ``missing`` rows.
|
||||
|
||||
``count`` is the members listed under every configured source (one
|
||||
primary-key range read per source, matched against its cached
|
||||
listing); ``missing`` the stored rows beyond it. When a scan or a read
|
||||
fails both are null for every collection and the names still return.
|
||||
"""
|
||||
collections = await self._run_in_executor(self.db.list_collections)
|
||||
counts = None
|
||||
if collections:
|
||||
try:
|
||||
counts = await self._collection_counts()
|
||||
except Exception:
|
||||
self.logger.warning(
|
||||
"Collection counts could not be read", exc_info=True
|
||||
)
|
||||
rows = []
|
||||
for collection in collections:
|
||||
listed, stored = (None, None) if counts is None else counts
|
||||
count = None if listed is None else listed.get(collection["id"], 0)
|
||||
stored_rows = None if stored is None else stored.get(collection["id"], 0)
|
||||
rows.append(
|
||||
{
|
||||
**collection,
|
||||
"count": count,
|
||||
# Clamped: never below zero, whatever the snapshot saw.
|
||||
"missing": None if count is None else max(0, stored_rows - count),
|
||||
}
|
||||
)
|
||||
return web.json_response({"success": True, "collections": rows})
|
||||
|
||||
async def _collection_counts(self) -> tuple[dict, dict]:
|
||||
"""``({id: listed members}, {id: stored rows})`` over every source.
|
||||
|
||||
The scans come from the gallery cache; the root keys (realpath), the
|
||||
listings and one read snapshot of every member and stored count run
|
||||
in the executor, so a write between two reads cannot skew them.
|
||||
"""
|
||||
roots = list(self._get_all_output_dirs())
|
||||
root_keys = await self._run_in_executor(
|
||||
lambda: [_favorite_root_key(root) for root in roots]
|
||||
)
|
||||
distinct = {} # one folder configured twice counts once
|
||||
for root, root_key in zip(roots, root_keys):
|
||||
distinct.setdefault(root_key, root)
|
||||
scans = {
|
||||
root_key: await self._get_gallery_files(root)
|
||||
for root_key, root in distinct.items()
|
||||
}
|
||||
return await self._run_in_executor(self._count_members, distinct, scans)
|
||||
|
||||
def _count_members(self, roots: dict, scans: dict) -> tuple[dict, dict]:
|
||||
"""Blocking: match one snapshot's members against each listing."""
|
||||
members, stored = self.db.collection_count_snapshot(list(roots))
|
||||
listed: dict[int, int] = {}
|
||||
for root_key, root in roots.items():
|
||||
listing = self._favorite_listing(root, scans[root_key])
|
||||
for collection_id, keys in members.get(root_key, {}).items():
|
||||
found = sum(1 for key in keys if key in listing)
|
||||
listed[collection_id] = listed.get(collection_id, 0) + found
|
||||
return listed, stored
|
||||
|
||||
@_collection_response(READ_FAILED)
|
||||
async def collection_membership(self, request: web.Request) -> web.Response:
|
||||
"""GET ``?root_index&root_scope&path``: the ids holding that file."""
|
||||
query = request.query
|
||||
if len(query) != 3 or set(query) != {"root_index", "root_scope", "path"}:
|
||||
# len() counts a repeated key (?path=a&path=b) twice.
|
||||
return bad_request("Query must name root_index, root_scope and path once")
|
||||
raw_index = query["root_index"]
|
||||
if not raw_index.isascii() or _ROOT_INDEX.fullmatch(raw_index) is None:
|
||||
return bad_request("Invalid gallery root")
|
||||
roots = list(self._get_all_output_dirs())
|
||||
target = {
|
||||
"root_index": int(raw_index),
|
||||
"root_scope": query["root_scope"],
|
||||
"path": query["path"],
|
||||
}
|
||||
root_key, key = await self._run_in_executor(
|
||||
self._collection_key, target, roots, {}
|
||||
)
|
||||
ids = await self._run_in_executor(self.db.file_collections, root_key, key)
|
||||
return web.json_response({"success": True, "collection_ids": ids})
|
||||
|
||||
# -- writes ------------------------------------------------------------
|
||||
|
||||
@_collection_response(CHANGE_FAILED)
|
||||
async def add_collection_items(self, request: web.Request) -> web.Response:
|
||||
"""POST ``{collection_id | name, items}``: add the listed files.
|
||||
|
||||
Only files the source's listing holds are added; the rest count in
|
||||
``not_found``. A new name with nothing to add creates nothing (404
|
||||
``files-not-found``).
|
||||
"""
|
||||
data = await _body(request, _ADD_TARGETS)
|
||||
target = {}
|
||||
if "name" in data:
|
||||
target["name"] = normalize_collection_name(data["name"])
|
||||
else:
|
||||
target["collection_id"] = _body_id(data["collection_id"])
|
||||
roots = list(self._get_all_output_dirs())
|
||||
keys = await self._run_in_executor(self._collection_items, data["items"], roots)
|
||||
found, not_found = [], 0
|
||||
listings = {}
|
||||
for index, root_key, key in keys:
|
||||
if index not in listings:
|
||||
root = roots[index]
|
||||
files = await self._get_gallery_files(root)
|
||||
listings[index] = await self._run_in_executor(
|
||||
self._favorite_listing, root, files
|
||||
)
|
||||
if key in listings[index]:
|
||||
found.append((root_key, key))
|
||||
else:
|
||||
not_found += 1
|
||||
result = await self._run_in_executor(
|
||||
lambda: self.db.add_to_collection(items=found, **target)
|
||||
)
|
||||
return web.json_response({"success": True, **result, "not_found": not_found})
|
||||
|
||||
@_collection_response(CHANGE_FAILED)
|
||||
async def remove_collection_items(self, request: web.Request) -> web.Response:
|
||||
"""DELETE ``{collection_id, items}``: listed or not, every named file."""
|
||||
data = await _body(request, (_REMOVE_FIELDS,))
|
||||
collection_id = _body_id(data["collection_id"])
|
||||
roots = list(self._get_all_output_dirs())
|
||||
keys = await self._run_in_executor(self._collection_items, data["items"], roots)
|
||||
result = await self._run_in_executor(
|
||||
self.db.remove_from_collection,
|
||||
collection_id,
|
||||
[(root_key, key) for _index, root_key, key in keys],
|
||||
)
|
||||
return web.json_response({"success": True, **result})
|
||||
|
||||
@_collection_response(CHANGE_FAILED)
|
||||
async def rename_collection(self, request: web.Request) -> web.Response:
|
||||
"""PATCH ``{name}``: a case variant of its own name is allowed."""
|
||||
collection_id = _path_id(request)
|
||||
data = await _body(request, (_RENAME_FIELDS,))
|
||||
name = normalize_collection_name(data["name"])
|
||||
collection = await self._run_in_executor(
|
||||
self.db.rename_collection, collection_id, name
|
||||
)
|
||||
return web.json_response({"success": True, "collection": collection})
|
||||
|
||||
@_collection_response(CHANGE_FAILED)
|
||||
async def delete_collection(self, request: web.Request) -> web.Response:
|
||||
"""DELETE: the collection and its members; files are never touched."""
|
||||
collection_id = _path_id(request)
|
||||
await self._run_in_executor(self.db.delete_collection, collection_id)
|
||||
return web.json_response({"success": True, "deleted": True})
|
||||
|
||||
# -- request steps -----------------------------------------------------
|
||||
|
||||
def _collection_items(self, items: object, roots: list) -> list[tuple]:
|
||||
"""``(index, root key, relative key)`` per distinct file, in order.
|
||||
|
||||
Blocking (realpath per root, once per request): run in the executor.
|
||||
Every item is checked before anything is written: 400 for a malformed
|
||||
shape, 403 for an unsafe path, 409 for a stale scope. Two spellings
|
||||
that fold to one key (Windows) count once.
|
||||
"""
|
||||
if not isinstance(items, list) or not 1 <= len(items) <= MAX_ITEMS:
|
||||
raise _Refused(bad_request(f"items must list 1 to {MAX_ITEMS} images"))
|
||||
keys, sources = {}, {}
|
||||
for item in items:
|
||||
index = item.get("root_index") if isinstance(item, dict) else None
|
||||
root_key, key = self._collection_key(item, roots, sources)
|
||||
keys.setdefault((root_key, key), index)
|
||||
return [(index, root_key, key) for (root_key, key), index in keys.items()]
|
||||
|
||||
def _collection_key(
|
||||
self, item: object, roots: list, sources: dict
|
||||
) -> tuple[str, str]:
|
||||
"""The stored keys of one target, or raise its refusal (blocking).
|
||||
|
||||
*sources* caches ``{index: (scope, root key)}`` for one request, so
|
||||
each root's realpath runs once however many items name it.
|
||||
"""
|
||||
target, error = media_target(item, media_message=_MEDIA_MESSAGE)
|
||||
if error is not None:
|
||||
raise _Refused(error)
|
||||
index, scope, relative = target
|
||||
if index >= len(roots):
|
||||
raise _Refused(_coded(409, "source-changed", SOURCES_CHANGED))
|
||||
if index not in sources:
|
||||
sources[index] = (
|
||||
self._gallery_root_scope(roots[index], index),
|
||||
_favorite_root_key(roots[index]),
|
||||
)
|
||||
current, root_key = sources[index]
|
||||
if not hmac.compare_digest(scope, current):
|
||||
raise _Refused(_coded(409, "source-changed", SOURCES_CHANGED))
|
||||
key = _favorite_relative_key(relative)
|
||||
if max(len(root_key), len(key)) > MAX_KEY_LENGTH:
|
||||
# A stored key past the schema bound would fail the next restore.
|
||||
raise _Refused(_forbidden("Path is too long to add to a collection"))
|
||||
return root_key, key
|
||||
@@ -588,7 +588,9 @@ class DuplicateFilesMixin:
|
||||
job.update(total=len(targets), status_line="Removing duplicate files")
|
||||
spellings = await self._run_in_executor(self._source_spellings)
|
||||
removed, failure, keepers = 0, None, {}
|
||||
totals = dict.fromkeys(("moved", "merged", "favorites", "tags", "review"), 0)
|
||||
totals = dict.fromkeys(
|
||||
("moved", "merged", "favorites", "tags", "review", "collections"), 0
|
||||
)
|
||||
totals["categories"] = 0 # image categories kept on the kept copies (#332)
|
||||
for done, target in enumerate(targets, start=1):
|
||||
if failure is not None:
|
||||
@@ -632,6 +634,11 @@ class DuplicateFilesMixin:
|
||||
job.log_line(
|
||||
f"Moved {_plural(totals['favorites'], 'favorite')} to the kept copies"
|
||||
)
|
||||
if totals["collections"]:
|
||||
job.log_line(
|
||||
f"Moved {_plural(totals['collections'], 'collection membership')} "
|
||||
"to the kept copies"
|
||||
)
|
||||
if totals["tags"]:
|
||||
job.log_line(
|
||||
f"Added {_plural(totals['tags'], 'image tag')} to the kept copies"
|
||||
|
||||
@@ -104,7 +104,8 @@ def copy_move(target, kept_stat, spellings):
|
||||
|
||||
A record found under a folder spelling moves to the kept copy under
|
||||
the same spelling when that folder holds it too, else to its real
|
||||
path. Its favorite moves under every listed root holding the file.
|
||||
path. Its favorite and its collection memberships move under every
|
||||
listed root holding the file.
|
||||
"""
|
||||
removed = spellings.of(target["abs"])
|
||||
kept = spellings.of(target["keeper_abs"])
|
||||
|
||||
@@ -42,7 +42,7 @@ from .. import model_gate
|
||||
from .admin import _plural, _time_left
|
||||
from .annotation_suggest import (
|
||||
OptionError,
|
||||
unavailable_message,
|
||||
model_refusal,
|
||||
validate_options,
|
||||
)
|
||||
from .annotations import (
|
||||
@@ -58,7 +58,9 @@ from .annotations import (
|
||||
from .autotag_routes import BATCH_CANCELLED, MODEL_BUSY_ERROR, model_busy_response
|
||||
from .images import (
|
||||
MAX_LISTING_SELECTION,
|
||||
CollectionMissing,
|
||||
ListingSourceChanged,
|
||||
collection_not_found,
|
||||
_current_fingerprint,
|
||||
_listing_posix,
|
||||
_public_path,
|
||||
@@ -510,10 +512,8 @@ class ImageTagJobsMixin:
|
||||
)
|
||||
)
|
||||
label = MODELS[model_type]["label"]
|
||||
if not (status.get(model_type) or {}).get("downloaded"):
|
||||
message = (
|
||||
f"{label} is not downloaded. Download it in System › Auto-tagging."
|
||||
)
|
||||
message = model_refusal(status, model_type, label)
|
||||
if message is not None:
|
||||
return None, _refusal(409, "model-unavailable", message)
|
||||
if backup_on and short is not None:
|
||||
return None, _refusal(507, "insufficient-space", short_space_message(short))
|
||||
@@ -689,6 +689,8 @@ class ImageTagJobsMixin:
|
||||
selection = await self.resolve_listing_filter(spec.filter, cap)
|
||||
except ListingSourceChanged:
|
||||
return None, _coded("source-changed")
|
||||
except CollectionMissing:
|
||||
return None, collection_not_found()
|
||||
except ValueError as exc:
|
||||
return None, bad_request(str(exc))
|
||||
except Exception:
|
||||
@@ -788,13 +790,13 @@ class ImageTagJobsMixin:
|
||||
|
||||
async def _prepare_tagging_model(self, job, spec: _Spec, owner):
|
||||
"""The loaded service, or None (logged) when the job cannot run."""
|
||||
from ..autotag import MODELS, get_autotag_service
|
||||
from ..autotag import MODELS, MissingBackendPackages, get_autotag_service
|
||||
|
||||
service = get_autotag_service()
|
||||
status = await self._run_in_executor(service.get_models_status)
|
||||
if not status.get(spec.model_type, {}).get("downloaded"):
|
||||
name = MODELS.get(spec.model_type, {}).get("name", spec.model_type)
|
||||
message = unavailable_message(name)
|
||||
name = MODELS.get(spec.model_type, {}).get("name", spec.model_type)
|
||||
message = model_refusal(status, spec.model_type, name)
|
||||
if message is not None:
|
||||
job.update(status_line=message)
|
||||
job.log_line(f"! {message}")
|
||||
return None
|
||||
@@ -806,6 +808,11 @@ class ImageTagJobsMixin:
|
||||
spec.model_type,
|
||||
spec.options["use_gpu"],
|
||||
)
|
||||
except MissingBackendPackages as exc:
|
||||
# A package gone since the status read (load_model logged it).
|
||||
job.update(status_line=str(exc))
|
||||
job.log_line(f"! {exc}")
|
||||
return None
|
||||
except Exception:
|
||||
self.logger.exception("The auto-tag job could not load its model")
|
||||
job.update(status_line=INFERENCE_FAILED)
|
||||
|
||||
+110
-43
@@ -18,7 +18,7 @@ import urllib.parse
|
||||
from bisect import bisect_left, bisect_right, insort
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
from typing import Callable, NamedTuple
|
||||
from typing import Callable, Iterable, NamedTuple
|
||||
|
||||
from aiohttp import web
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
@@ -58,6 +58,7 @@ from .inspection_service import (
|
||||
)
|
||||
|
||||
try:
|
||||
from ...database.image_collections import CollectionMissing
|
||||
from ...database.image_favorites import MAX_KEY_LENGTH
|
||||
from ...database.library import display_title
|
||||
from ...database.models import normalize_image_path
|
||||
@@ -81,6 +82,7 @@ try:
|
||||
reveal_in_file_manager,
|
||||
)
|
||||
except ImportError:
|
||||
from database.image_collections import CollectionMissing
|
||||
from database.image_favorites import MAX_KEY_LENGTH
|
||||
from database.library import display_title
|
||||
from database.models import normalize_image_path
|
||||
@@ -110,6 +112,21 @@ IMAGE_EXTENSIONS = _IMAGE_EXTENSIONS
|
||||
_CARD_RECORDS = ("prompt", "workflow")
|
||||
_PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
||||
_LISTING_FAILED = "The image list could not be filtered. Check server logs for details."
|
||||
_COLLECTION_ID = re.compile(r"[1-9][0-9]{0,8}")
|
||||
COLLECTION_NOT_FOUND = "That collection no longer exists."
|
||||
|
||||
|
||||
def collection_not_found() -> web.Response:
|
||||
"""The 404 a write or a listing answers for an unknown collection (#331)."""
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"code": "collection-not-found",
|
||||
"error": COLLECTION_NOT_FOUND,
|
||||
},
|
||||
status=404,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
def _card_header(path: Path) -> tuple:
|
||||
@@ -1110,8 +1127,9 @@ def _workspace_filters(query: Mapping) -> dict:
|
||||
the linked positive's text, title and tag names. ``category`` is exact,
|
||||
``tags`` is a comma list that must all be present, ``sort=rating`` ranks by
|
||||
the linked positive, and ``favorites=1`` keeps only favorites (the
|
||||
Favorites page, #249 D51). Raises ValueError for any unknown or oversized
|
||||
value.
|
||||
Favorites page, #249 D51). ``collection=<id>`` (1 to 9 digits) keeps only
|
||||
that collection's members (#331); it wins over ``favorites`` (a page never
|
||||
sets both). Raises ValueError for any unknown or oversized value.
|
||||
"""
|
||||
text = query.get("q", "").strip()
|
||||
folded = text.casefold()
|
||||
@@ -1125,8 +1143,10 @@ def _workspace_filters(query: Mapping) -> dict:
|
||||
{tag.strip() for tag in query.get("tags", "").split(",") if tag.strip()}
|
||||
)
|
||||
favorites = query.get("favorites", "0")
|
||||
collection = query.get("collection", "")
|
||||
if (
|
||||
favorites not in ("0", "1")
|
||||
or (collection != "" and _COLLECTION_ID.fullmatch(collection) is None)
|
||||
or len(text) > 500
|
||||
or len(folded) > 500
|
||||
or media not in ("all", "image", "video")
|
||||
@@ -1149,7 +1169,8 @@ def _workspace_filters(query: Mapping) -> dict:
|
||||
"path": exact.casefold(),
|
||||
"category": category,
|
||||
"tags": tags,
|
||||
"favorites": favorites == "1",
|
||||
"favorites": favorites == "1" and not collection,
|
||||
"collection": int(collection) if collection else None,
|
||||
}
|
||||
|
||||
|
||||
@@ -1167,6 +1188,8 @@ LISTING_FILTER_KEYS = frozenset(
|
||||
"media",
|
||||
"sort",
|
||||
"favorites",
|
||||
# A collection page's filter (#331): bulk actions act on its members.
|
||||
"collection",
|
||||
}
|
||||
)
|
||||
MAX_LISTING_FILTER_VALUE = 2048
|
||||
@@ -1223,7 +1246,7 @@ def _admitted_entries(
|
||||
entries: list,
|
||||
filters: dict,
|
||||
links: dict | None,
|
||||
favorites: frozenset | None = None,
|
||||
members: frozenset | None = None,
|
||||
image_tags: dict | None = None,
|
||||
own_categories: dict | None = None,
|
||||
):
|
||||
@@ -1236,11 +1259,11 @@ def _admitted_entries(
|
||||
admits a file whose effective category equals it (#332 MC5): its own
|
||||
category (*own_categories*, ``{normalized path: category}``) when it has one,
|
||||
else the inherited one, so an unlinked file with its own category is
|
||||
admitted. *favorites*, when given, admits only those listed
|
||||
paths (``favorites=1``). *image_tags* maps normalized paths to their
|
||||
_ImageTagMatch: a tag filter also admits a file whose current image tags
|
||||
(with its linked positive's tags) carry every tag, and ``q`` also admits an
|
||||
image tag name hit.
|
||||
admitted. *members*, when given, admits only those listed paths (the
|
||||
favorites of ``favorites=1`` or a ``collection``'s members). *image_tags*
|
||||
maps normalized paths to their _ImageTagMatch: a tag filter also admits a
|
||||
file whose current image tags (with its linked positive's tags) carry every
|
||||
tag, and ``q`` also admits an image tag name hit.
|
||||
"""
|
||||
query, media, folder = filters["query"], filters["media"], filters["folder"]
|
||||
category = filters["category"]
|
||||
@@ -1258,7 +1281,7 @@ def _admitted_entries(
|
||||
continue
|
||||
if media != "all" and (media == "video") != entry.video:
|
||||
continue
|
||||
if favorites is not None and entry.path not in favorites:
|
||||
if members is not None and entry.path not in members:
|
||||
continue
|
||||
match = None if links is None else links.get(entry.normalized)
|
||||
image = None if image_tags is None else image_tags.get(entry.normalized)
|
||||
@@ -1286,7 +1309,7 @@ def _filtered_page(
|
||||
size: int,
|
||||
filters: dict,
|
||||
links: dict | None,
|
||||
favorites: frozenset | None = None,
|
||||
members: frozenset | None = None,
|
||||
image_tags: dict | None = None,
|
||||
own_categories: dict | None = None,
|
||||
) -> tuple[int, list, int | None]:
|
||||
@@ -1298,7 +1321,7 @@ def _filtered_page(
|
||||
"""
|
||||
total, page, positives = 0, [], set()
|
||||
for position, entry, match in _admitted_entries(
|
||||
entries, filters, links, favorites, image_tags, own_categories
|
||||
entries, filters, links, members, image_tags, own_categories
|
||||
):
|
||||
total += 1
|
||||
if match is not None:
|
||||
@@ -2002,6 +2025,7 @@ class ImageRoutesMixin:
|
||||
filters["category"],
|
||||
filters["tags"],
|
||||
filters["favorites"],
|
||||
filters["collection"],
|
||||
]
|
||||
).encode()
|
||||
).hexdigest()
|
||||
@@ -2012,17 +2036,19 @@ class ImageRoutesMixin:
|
||||
except ValueError as exc:
|
||||
return bad_request(str(exc))
|
||||
|
||||
listings = [
|
||||
(index, root, await self._get_gallery_files(root))
|
||||
for index, root in enumerate(output_dirs)
|
||||
if root_index is None or index == root_index
|
||||
]
|
||||
try:
|
||||
if filters["collection"] is not None and not await self._run_in_executor(
|
||||
self.db.collection_exists, filters["collection"]
|
||||
):
|
||||
raise CollectionMissing("No such collection")
|
||||
listings = await self._workspace_listings(output_dirs, root_index)
|
||||
page, total, has_more, next_cursor, positive_count = (
|
||||
await self._run_in_executor(
|
||||
self._select_workspace_page, listings, filters, after, limit, scope
|
||||
)
|
||||
)
|
||||
except CollectionMissing:
|
||||
return collection_not_found()
|
||||
except Exception:
|
||||
# A filter that needs the prompt links or the image tags cannot be
|
||||
# served without them. Never echo the exception: it can carry SQL
|
||||
@@ -2282,9 +2308,11 @@ class ImageRoutesMixin:
|
||||
) -> tuple:
|
||||
"""The ordered entries and what admits them (blocking; run in executor).
|
||||
|
||||
Returns ``(entries, key, links, image_tags, favorites, own)``: the
|
||||
Returns ``(entries, key, links, image_tags, members, own)``: the
|
||||
listing in the filters' order, its keyset key, and the link, image-tag,
|
||||
favorite and own-category maps _admitted_entries reads. One SELECT
|
||||
member and own-category maps _admitted_entries reads (members: a
|
||||
collection's, else the favorites, else None). A collection deleted since
|
||||
the route checked it raises CollectionMissing. One SELECT
|
||||
(get_image_link_matches) reads every linked positive when *read_links*;
|
||||
under a category it keeps only links whose raw recipe or positive
|
||||
category is that value (the inherited one is decided in Python), and
|
||||
@@ -2368,10 +2396,14 @@ class ImageRoutesMixin:
|
||||
def key(entry):
|
||||
return _workspace_key(entry, order)
|
||||
|
||||
favorites = (
|
||||
self._workspace_favorite_paths(listings) if filters["favorites"] else None
|
||||
return (
|
||||
entries,
|
||||
key,
|
||||
links,
|
||||
image_tags,
|
||||
self._workspace_members(listings, filters),
|
||||
own,
|
||||
)
|
||||
return entries, key, links, image_tags, favorites, own
|
||||
|
||||
def _own_category_links(self, links: dict, own: dict, filters: dict) -> dict:
|
||||
"""*links* plus the links of files whose own category is the filter's.
|
||||
@@ -2423,7 +2455,7 @@ class ImageRoutesMixin:
|
||||
or bool(filters["tags"])
|
||||
or order == "rating"
|
||||
)
|
||||
entries, key, links, image_tags, favorites, own = self._workspace_admission(
|
||||
entries, key, links, image_tags, members, own = self._workspace_admission(
|
||||
listings, filters, read_links=needed or after is None
|
||||
)
|
||||
start = 0
|
||||
@@ -2434,7 +2466,7 @@ class ImageRoutesMixin:
|
||||
else:
|
||||
start = bisect_right(entries, after, key=key)
|
||||
total, page, positive_count = _filtered_page(
|
||||
entries, start, limit + 1, filters, links, favorites, image_tags, own
|
||||
entries, start, limit + 1, filters, links, members, image_tags, own
|
||||
)
|
||||
if not needed and after is not None:
|
||||
positive_count = _COUNT_OMITTED
|
||||
@@ -2456,12 +2488,12 @@ class ImageRoutesMixin:
|
||||
|
||||
The links are read strictly: the positives must be the listing's own.
|
||||
"""
|
||||
entries, _key, links, image_tags, favorites, own = self._workspace_admission(
|
||||
entries, _key, links, image_tags, members, own = self._workspace_admission(
|
||||
listings, filters, read_links=True, strict=True
|
||||
)
|
||||
targets, positives, unlinked, scopes = [], {}, 0, {}
|
||||
for _position, entry, match in _admitted_entries(
|
||||
entries, filters, links, favorites, image_tags, own
|
||||
entries, filters, links, members, image_tags, own
|
||||
):
|
||||
if len(targets) == cap:
|
||||
return ListingSelection(targets, list(positives), unlinked, True)
|
||||
@@ -2483,9 +2515,11 @@ class ImageRoutesMixin:
|
||||
) -> ListingSelection:
|
||||
"""The images a parse_listing_filter result admits right now (#392).
|
||||
|
||||
Raises ValueError for a root outside the current roots, and
|
||||
Raises ValueError for a root outside the current roots,
|
||||
ListingSourceChanged when the filter's ``root_scope`` no longer names
|
||||
its root (the sources were renumbered since the listing).
|
||||
its root (the sources were renumbered since the listing), and
|
||||
CollectionMissing when its ``collection`` no longer exists (checked
|
||||
before any scan and again after the members are read).
|
||||
"""
|
||||
output_dirs = list(self._get_all_output_dirs())
|
||||
root_index = _workspace_root(params, output_dirs)
|
||||
@@ -2495,11 +2529,11 @@ class ImageRoutesMixin:
|
||||
raise ListingSourceChanged()
|
||||
raise ValueError("Invalid gallery root scope")
|
||||
filters = _workspace_filters(params)
|
||||
listings = [
|
||||
(index, root, await self._get_gallery_files(root))
|
||||
for index, root in enumerate(output_dirs)
|
||||
if root_index is None or index == root_index
|
||||
]
|
||||
if filters["collection"] is not None and not await self._run_in_executor(
|
||||
self.db.collection_exists, filters["collection"]
|
||||
):
|
||||
raise CollectionMissing("No such collection")
|
||||
listings = await self._workspace_listings(output_dirs, root_index)
|
||||
return await self._run_in_executor(
|
||||
self._select_workspace_matches, listings, filters, cap
|
||||
)
|
||||
@@ -3684,25 +3718,58 @@ class ImageRoutesMixin:
|
||||
self._favorite_listing_cache[cache_key] = (files, listing)
|
||||
return listing
|
||||
|
||||
def _workspace_favorite_paths(self, listings: list[tuple]) -> frozenset:
|
||||
"""The listed paths of every favorite in the selected roots (blocking).
|
||||
def _workspace_members(self, listings: list[tuple], filters: dict):
|
||||
"""The member set the filters ask for, or None (blocking)."""
|
||||
collection = filters.get("collection")
|
||||
if collection is not None:
|
||||
members = self._workspace_member_paths(
|
||||
listings,
|
||||
lambda root_key: self.db.collection_members(collection, root_key),
|
||||
)
|
||||
# Checked after the reads: a collection deleted before or while
|
||||
# they ran is a 404, never an empty page passing for it.
|
||||
if not self.db.collection_exists(collection):
|
||||
raise CollectionMissing("No such collection")
|
||||
return members
|
||||
if filters["favorites"]:
|
||||
return self._workspace_favorite_paths(listings)
|
||||
return None
|
||||
|
||||
One primary-key read per root (get_root_favorites), matched against the
|
||||
root's cached ``{relative key: listing spelling}``, so the work follows
|
||||
the favorites rather than the files, and a key folds case exactly as
|
||||
the toggle stored it (Windows). A favorite whose file is gone matches
|
||||
nothing. A failed read raises: an unfiltered page must never pass for
|
||||
the favorites.
|
||||
def _workspace_favorite_paths(self, listings: list[tuple]) -> frozenset:
|
||||
"""The listed paths of every favorite in the selected roots (blocking)."""
|
||||
return self._workspace_member_paths(listings, self.db.get_root_favorites)
|
||||
|
||||
def _workspace_member_paths(
|
||||
self, listings: list[tuple], keys_for_source: Callable[[str], Iterable[str]]
|
||||
) -> frozenset:
|
||||
"""The listed paths of the stored keys of each selected root (blocking).
|
||||
|
||||
One read per root (``keys_for_source(root key)``: the favorites' or a
|
||||
collection's primary-key range), matched against the root's cached
|
||||
``{relative key: listing spelling}``, so the work follows the members
|
||||
rather than the files, and a key folds case exactly as it was stored
|
||||
(Windows). A member whose file is gone matches nothing. A failed read
|
||||
raises: an unfiltered page must never pass for the members.
|
||||
"""
|
||||
paths = set()
|
||||
for _index, root, files in listings:
|
||||
listing = self._favorite_listing(root, files)
|
||||
for key in self.db.get_root_favorites(_favorite_root_key(root)):
|
||||
for key in keys_for_source(_favorite_root_key(root)):
|
||||
listed = listing.get(key)
|
||||
if listed is not None:
|
||||
paths.add(root / listed)
|
||||
return frozenset(paths)
|
||||
|
||||
async def _workspace_listings(
|
||||
self, output_dirs: list[Path], root_index: int | None
|
||||
) -> list[tuple]:
|
||||
"""``(index, root, scan)`` of every selected root (cached scans)."""
|
||||
return [
|
||||
(index, root, await self._get_gallery_files(root))
|
||||
for index, root in enumerate(output_dirs)
|
||||
if root_index is None or index == root_index
|
||||
]
|
||||
|
||||
def _workspace_favorites(self, page: list[tuple]) -> set[tuple[int, str]]:
|
||||
"""``(root index, listing path)`` of the favorites among *page* entries."""
|
||||
by_root: dict[tuple[int, Path], dict[str, str]] = {}
|
||||
|
||||
@@ -1227,7 +1227,9 @@ class PromptRoutesMixin:
|
||||
"""
|
||||
from .images import (
|
||||
MAX_LISTING_SELECTION,
|
||||
CollectionMissing,
|
||||
ListingSourceChanged,
|
||||
collection_not_found,
|
||||
parse_listing_filter,
|
||||
)
|
||||
|
||||
@@ -1252,6 +1254,8 @@ class PromptRoutesMixin:
|
||||
},
|
||||
status=409,
|
||||
)
|
||||
except CollectionMissing:
|
||||
return collection_not_found()
|
||||
except ValueError as exc:
|
||||
return refused(str(exc))
|
||||
if selection.truncated:
|
||||
|
||||
Reference in New Issue
Block a user