* refactor(thumbnails): one resolver for where previews live on disk (#320) Add py/api/preview_store.py: the store of a source, a preview's path, URL parts to disk, containment (owns), a write generation and the lock around every preview's final rename. Every preview reader, writer and remover now asks it instead of joining <source>/thumbnails by hand: serve, on-demand, capture (its path resolved when the write starts), the after-scan queue, the thumbnail plans and job payloads, clear, the temp sweep, History covers, the auto-tag listing and the owned-preview lookups. Source mode only: every path is the one built before, so nothing changes on disk or on the wire. A structural test keeps new thumbnails joins out of the backend. * refactor(thumbnails): keep the after-scan queue's store rule and pin the write lock (#320) A planned target keeps the plan's own containment rule (inside the store, as the thumbnail job writes it); captures keep the serve rule. The structural test also covers PREVIEW_FOLDER joins and the repo-root modules; the write-lock test asserts the lock instead of waiting on a timer; new tests cover nested roots in the after-scan queue and a legacy upper-case preview served through the resolver. * feat(thumbnails): keep previews in one folder, set from config.json (#320) thumbnails.location in config.json can now name one folder for every source's previews: <folder>/<source key>/<rel>_thumb<ext>. A source key is a readable slug plus a short hash of the source's identity, stored in the folder's marker (.promptmanager-thumbnails.json) with the install that made it. Serving, on-demand and capture writes, listings, clear, delete, duplicates, the temp sweep and History covers all follow it; a leftover in <source>/thumbnails is never served and other text under thumbnails/ is refused. A missing, unplugged or foreign folder is unavailable (a probe checks it every 5 s within 2 s; a stalled or failing store call flips it at once): originals are served, nothing is written anywhere, the folder is never re-created, and generate, convert and clear answer 409 location-unavailable. A bad config value is unavailable too, never a fall-back to the sources. No UI yet: POST /settings refuses thumbnail_location (409 location-needs-move); GET /settings reports it with its path, the thumbnail status without one. Sources inside or around the folder and a library move into it are refused. * fix(thumbnails): never follow a marker or a link out of the thumbnail folder (#320) Marker keys are data, not paths: only a single <slug>-<hex> folder name is ever joined onto the thumbnail folder; anything else is dropped and logged. Every preview write and delete passes one ownership check (below a key folder, lexically and resolved, no link, junction or reparse point on the way); make_parent refuses anything else and writes nothing while the folder lacks this install's marker (an unmounted mount point). Clear never descends into a junction. The configured location is in force before any request (unchecked folder mode is unavailable), and a startup check that fails leaves it invalid, never the sources. Older listings that run on the event loop no longer touch the store in folder mode. A leftover under another case of thumbnails/ on a case-insensitive disk is refused too. * test(memory): let the musl test fake os.confstr on Windows (#523) The portability run on main fails since #523: Windows has no os.confstr, so patching it without create=True raises before the test runs. * feat(thumbnails): move thumbnails between the sources and one folder (#320) POST /images/thumbnails/plan reviews a move (what is copied, removed and left, free space, too-long Windows paths, sources no longer listed) and hands out a token; POST /jobs/thumbnails {mode: move} runs it as a thumbnails job: hold automatic previews, copy and check every preview on the job pool, switch with one config commit that records the old copies, then remove them. Per-file errors still switch; a folder-wide error, a cancel before the switch or a crash leaves nothing changed (a journal is undone at the next start); old copies that could not go are removed by a finishing job at the next start. Leaving a folder removes the previews of this install's sources that are no longer listed (Q320-8). Recreate folder and Use this folder answer an unavailable folder; the Thumbnail maintenance scope prune removes the thumbnails of removed sources. * feat(system): choose where thumbnails are kept and move them (#320) System › Sources & scans gains the Thumbnail location row (D196): Inside each source or One folder, the folder path, the status line and Review move…, whose review (D197) lists what is copied, removed and left before Move thumbnails starts; a refused folder is explained under the field, a folder of your own asks for a confirmation first. Activity shows a move and a prune with their own tiles and step line (D198). An unavailable folder shows a notice on Overview and Sources & scans with Recreate folder or Use this folder (D199). Thumbnail maintenance offers Remove thumbnails of removed sources in folder mode (D200), and Move library's review says where the thumbnails stay (D201). Closes #320 * fix(thumbnails): keep a move's own previews and never walk out of a store (#320) A cleanup that waited from an earlier move never names a key folder the new move just filled, so moving back keeps the previews. Removing empty folders walks top-down and never into a link or junction. Any error while copying undoes the copies; once the switch is committed, a journal that cannot be removed no longer undoes it. Hand-edited cleanup entries and marker keys never name a folder outside the stores; removal stops at interpreter exit. The path field carries aria-invalid with its reason, and a review that went stale reviews again. * test(thumbnails): cover the prune scope, the Overview notice and the space refusal (#320) The frontend coverage gate counted the new branches of thumbnail maintenance, the Activity views, the notices' Change location and the api-client's space refusal as uncovered; page and unit tests now drive them. * fix(thumbnails): report removals that fail and explain the quiet excepts (#320) An old thumbnail the finishing move cannot remove (held open on Windows) is now an error row of the job with its path, and logged; a failed copy's temp that cannot go is logged with its path. Every except that only passes says why, and the thread runners say their caller re-raises. * fix(thumbnails): never plan or delete through a linked key folder (#320) Review finding P1 on #591: in folder mode a source key folder that is a link or junction to a folder outside was still handed to the planner, so Convert outdated could remove an older-format file outside the thumbnail folder. ensure_store now refuses a linked store before any planning, and each removal of an old preview passes may_write at the moment it runs. * fix(thumbnails): the temp sweep never walks into a link (#320) Review finding P2 on #591: Clear thumbnails and the startup sweep removed crashed-write temps with a plain os.walk, which on Windows walks into a junction even without followlinks. The sweeper now skips a folder that is a link, junction or reparse point (the top one too), and each temp passes may_write before it is removed. * fix(thumbnails): lock the folder marker across processes (#320) Review finding P2 on #591: two installs sharing one thumbnail folder could each read the marker, add a key and replace it, so the last writer dropped the other's key. write_marker now re-reads, changes and replaces the marker under a lock file made with O_CREAT | O_EXCL beside it (atomic on shares, unlike flock); a lock older than 30 s, left by a writer that died, is taken over, and a wait past 1.5 s fails as a TimeoutError like an unreachable folder. Tested with three processes adding keys at once. * test(thumbnails): cover the space refusal, Overview's Use this folder and the prune review (#320) The frontend coverage floors of activity-kinds, system-overview and thumbnail-review: a disk without the space shows the review but cannot move (a fixture route fakes the free space), Use this folder on Overview reloads the host values, the prune review's text and rows are pure functions with unit tests, and Overview takes the System drafts it is always mounted with. * fix(thumbnails): never copy through a linked folder; refuse a target inside the folder in use (#320) Moving back to Inside each source followed an existing linked folder in the destination and could replace a file outside the source (Codex P1). A target nested inside the thumbnail folder in use was accepted, and the old-copy cleanup then removed the previews just copied there (Codex P2). Every copy now checks the whole destination chain for links, also when the folders exist, and the move refuses a folder inside the one in use with location-inside-current. The cancelled branches no longer mix explicit and implicit returns (code-quality note). * fix(system): one full stop in the move-back review (#320) * fix(thumbnails): a folder check answers for the folder as it is now (#320) Probe.check() joined a check already running, or a finished one nobody had collected. A folder deleted right after configure() woke the probe therefore stayed available until the next probe, which failed the Overview notice spec in the merge queue. It now waits for a running check and starts a new one, still never two at once, and a stuck check still times out. The spec also asserts the fixture's removal took effect.
729 lines
25 KiB
Python
729 lines
25 KiB
Python
"""The thumbnail location setting (#320): parsing, validation, source keys
|
||
and the thumbnail folder's marker.
|
||
|
||
``preview_store`` holds the runtime state and maps paths; this module holds
|
||
the rules it applies. Nothing here keeps state.
|
||
|
||
Schema (config.json ``thumbnails.location``)::
|
||
|
||
{"mode": "folder", "path": "D:\\\\Thumbs", "id": "<uuid4>",
|
||
"install": "<uuid4>", "cleanup": []}
|
||
|
||
The default (and a missing key) is ``{"mode": "source"}``: previews inside
|
||
each source. An invalid value never falls back to the sources: it parses as
|
||
a folder location with problem ``invalid``, so nothing is written anywhere.
|
||
|
||
Layout in folder mode: ``<folder>/<source key>/<rel dir>/<stem>_thumb<ext>``.
|
||
A source key is a readable slug plus a short hash of the source's identity
|
||
(``output-3f9a2c1b``), assigned once and stored in the marker
|
||
``.promptmanager-thumbnails.json`` with the identity it belongs to.
|
||
"""
|
||
|
||
import errno
|
||
import hashlib
|
||
import json
|
||
import logging
|
||
import ntpath
|
||
import os
|
||
import posixpath
|
||
import re
|
||
import stat as stat_module
|
||
import threading
|
||
import time
|
||
import unicodedata
|
||
import uuid
|
||
from contextlib import contextmanager
|
||
from dataclasses import dataclass, field
|
||
|
||
try:
|
||
from ...utils import library_location
|
||
except ImportError: # tests import the package top-level
|
||
from utils import library_location
|
||
|
||
logger = logging.getLogger("prompt_manager.thumbnails")
|
||
|
||
SOURCE_MODE = "source"
|
||
FOLDER_MODE = "folder"
|
||
MARKER_NAME = ".promptmanager-thumbnails.json"
|
||
MARKER_VERSION = 1
|
||
# Beside the marker while one process re-reads, changes and replaces it.
|
||
MARKER_LOCK_NAME = ".promptmanager-thumbnails.lock"
|
||
MARKER_LOCK_TIMEOUT = 1.5 # a writer waits this long (under preview_store.IO_BUDGET)
|
||
MARKER_LOCK_STALE = 30.0 # a lock this old was left by a writer that died
|
||
MARKER_LOCK_POLL = 0.02
|
||
# A source key: one folder name, a slug and a 8-16 hex hash (``source_key``).
|
||
KEY_PATTERN = re.compile(r"^[a-z0-9_-]{1,24}-[0-9a-f]{8,16}$")
|
||
# A marker is a few keys; anything larger is not ours.
|
||
MAX_MARKER_BYTES = 4 * 2**20
|
||
# OS metadata files a folder may hold and still count as empty.
|
||
OS_METADATA = frozenset({"thumbs.db", "desktop.ini", ".ds_store"})
|
||
KEY_HASH_LENGTHS = (8, 12, 16)
|
||
SLUG_LENGTH = 24
|
||
# Folder + the longest key (24 + 1 + 16) + this margin must fit (Windows).
|
||
WINDOWS_FOLDER_LIMIT = 247
|
||
LONGEST_KEY = SLUG_LENGTH + 1 + KEY_HASH_LENGTHS[-1]
|
||
PATH_MARGIN = 40
|
||
# A validation stat on a share that does not answer in time is unavailable.
|
||
VALIDATION_TIMEOUT = 5.0
|
||
FILE_ATTRIBUTE_REPARSE_POINT = 0x400
|
||
|
||
_RESERVED = re.compile(r"^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$", re.IGNORECASE)
|
||
_BAD_CHARS = re.compile(r'[<>:"|?*\x00-\x1f]')
|
||
|
||
# Problem codes, their HTTP status and the text the UI shows (no path).
|
||
MESSAGES = {
|
||
"location-not-absolute": (
|
||
400,
|
||
"Enter a full folder path, such as D:\\Thumbnails or /mnt/fast/thumbnails.",
|
||
),
|
||
"location-device-path": (
|
||
400,
|
||
"Enter the folder as a drive letter or \\\\server\\share path, "
|
||
"without \\\\?\\.",
|
||
),
|
||
"location-invalid-name": (
|
||
400,
|
||
"This folder name can't be used on Windows. Choose another name.",
|
||
),
|
||
"location-too-broad": (
|
||
400,
|
||
"Choose a folder inside a drive or share, not the drive itself.",
|
||
),
|
||
"location-served": (
|
||
400,
|
||
"Choose a folder outside your image sources and ComfyUI's output, input "
|
||
"and temp folders.",
|
||
),
|
||
"location-node-folder": (
|
||
400,
|
||
"Choose a folder outside the Prompt Manager node folder.",
|
||
),
|
||
"location-contains-source": (
|
||
400,
|
||
"This folder contains an image source. Choose a folder beside it instead.",
|
||
),
|
||
"location-same": (400, "Thumbnails are already stored here."),
|
||
"location-inside-current": (
|
||
400,
|
||
"This folder is inside the thumbnail folder in use. Choose a folder "
|
||
"outside it.",
|
||
),
|
||
"location-parent-missing": (
|
||
400,
|
||
"The folder that would hold it doesn't exist. Check the path and that its "
|
||
"drive is connected.",
|
||
),
|
||
"location-unavailable": (
|
||
409,
|
||
"This folder can't be reached right now. Check that its drive or share is "
|
||
"connected.",
|
||
),
|
||
"location-not-empty": (
|
||
409,
|
||
"This folder already holds other files. Choose an empty folder or one "
|
||
"Prompt Manager used before.",
|
||
),
|
||
"location-not-writable": (
|
||
409,
|
||
"Prompt Manager can't write to this folder. Check its permissions.",
|
||
),
|
||
"location-path-too-long": (
|
||
400,
|
||
"This path is too long for Windows. Choose a shorter folder path.",
|
||
),
|
||
"insufficient-space": (
|
||
507,
|
||
"There is not enough space for the thumbnails. Free some space or choose "
|
||
"another folder.",
|
||
),
|
||
"location-needs-move": (
|
||
409,
|
||
"Use Move thumbnails in System › Sources & scans.",
|
||
),
|
||
}
|
||
HOLDS_THUMBNAILS_REASON = "This folder holds Prompt Manager's thumbnails."
|
||
|
||
|
||
def message(code):
|
||
"""``(status, text)`` of a problem code."""
|
||
return MESSAGES[code]
|
||
|
||
|
||
# ── parsing ──────────────────────────────────────────────────────────
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ParsedLocation:
|
||
"""A ``thumbnails.location`` value, checked as data (no filesystem)."""
|
||
|
||
mode: str = SOURCE_MODE
|
||
path: str | None = None
|
||
id: str | None = None
|
||
install: str | None = None
|
||
cleanup: tuple = field(default_factory=tuple)
|
||
problem: str | None = None
|
||
|
||
def to_config(self):
|
||
"""The config.json form (``invalid`` values are never written)."""
|
||
if self.mode == SOURCE_MODE:
|
||
value = {"mode": SOURCE_MODE}
|
||
else:
|
||
value = {"mode": FOLDER_MODE, "path": self.path, "id": self.id}
|
||
if self.install:
|
||
value["install"] = self.install
|
||
value["cleanup"] = list(self.cleanup)
|
||
return value
|
||
|
||
|
||
def _cleanup(value):
|
||
if not isinstance(value, list):
|
||
return ()
|
||
return tuple(item for item in value if isinstance(item, dict))
|
||
|
||
|
||
def parse_location(value, *, flavor=os.path):
|
||
"""The location a ``thumbnails.location`` value names.
|
||
|
||
Missing: inside each source. Anything else that is not a well-formed
|
||
location (an unknown mode, a relative path, a missing id) becomes a
|
||
folder location with problem ``invalid``: never a silent fall-back.
|
||
"""
|
||
if value is None:
|
||
return ParsedLocation()
|
||
if not isinstance(value, dict):
|
||
return ParsedLocation(mode=FOLDER_MODE, problem="invalid")
|
||
mode = value.get("mode", SOURCE_MODE)
|
||
install = value.get("install") if isinstance(value.get("install"), str) else None
|
||
cleanup = _cleanup(value.get("cleanup"))
|
||
if mode == SOURCE_MODE:
|
||
return ParsedLocation(install=install, cleanup=cleanup)
|
||
if mode != FOLDER_MODE:
|
||
return ParsedLocation(mode=FOLDER_MODE, problem="invalid", cleanup=cleanup)
|
||
path, marker_id = value.get("path"), value.get("id")
|
||
if (
|
||
not isinstance(path, str)
|
||
or form_problem(path, flavor=flavor) is not None
|
||
or not isinstance(marker_id, str)
|
||
or not marker_id.strip()
|
||
):
|
||
return ParsedLocation(
|
||
mode=FOLDER_MODE,
|
||
path=path if isinstance(path, str) else None,
|
||
problem="invalid",
|
||
cleanup=cleanup,
|
||
)
|
||
return ParsedLocation(
|
||
mode=FOLDER_MODE,
|
||
path=flavor.abspath(path),
|
||
id=marker_id,
|
||
install=install,
|
||
cleanup=cleanup,
|
||
)
|
||
|
||
|
||
# ── form (text only) ─────────────────────────────────────────────────
|
||
|
||
|
||
def _is_windows(flavor):
|
||
return flavor is ntpath or getattr(flavor, "__name__", "") == "ntpath"
|
||
|
||
|
||
def form_problem(path, *, flavor=os.path):
|
||
"""The text-only problem of *path*, or None (no filesystem access).
|
||
|
||
``\\\\?\\`` and ``\\\\.\\`` prefixes, relative, drive-relative (``C:x``)
|
||
and root-relative (``\\x``) spellings, and names Windows cannot hold
|
||
(checked on every OS: the folder may be moved to Windows).
|
||
"""
|
||
if not isinstance(path, str) or not path.strip() or path != path.lstrip():
|
||
return "location-not-absolute"
|
||
if path.replace("/", "\\").startswith(("\\\\?\\", "\\\\.\\")):
|
||
return "location-device-path"
|
||
if _is_windows(flavor):
|
||
drive, rest = ntpath.splitdrive(path)
|
||
if not drive or not rest.startswith(("\\", "/")):
|
||
return "location-not-absolute"
|
||
elif not posixpath.isabs(path):
|
||
return "location-not-absolute"
|
||
drive, rest = flavor.splitdrive(path)
|
||
for part in re.split(r"[\\/]+", rest):
|
||
if not part:
|
||
continue
|
||
if (
|
||
_RESERVED.match(part)
|
||
or part.endswith((".", " "))
|
||
or _BAD_CHARS.search(part)
|
||
):
|
||
return "location-invalid-name"
|
||
return None
|
||
|
||
|
||
def is_too_broad(path, *, home=None, flavor=os.path):
|
||
"""A filesystem or share root, or the home folder itself."""
|
||
normalized = flavor.normpath(path)
|
||
drive, rest = flavor.splitdrive(normalized)
|
||
if rest in ("", "\\", "/"):
|
||
return True
|
||
home = home if home is not None else os.path.expanduser("~")
|
||
return bool(home) and flavor.normcase(normalized) == flavor.normcase(
|
||
flavor.normpath(home)
|
||
)
|
||
|
||
|
||
def too_long_for_windows(path, long_paths=None):
|
||
"""Folder + the longest key + a margin over the folder limit (Windows
|
||
without long paths; *long_paths* injects the registry answer)."""
|
||
if long_paths is None:
|
||
long_paths = long_paths_enabled()
|
||
if long_paths:
|
||
return False
|
||
return len(path) + 1 + LONGEST_KEY + PATH_MARGIN > WINDOWS_FOLDER_LIMIT
|
||
|
||
|
||
def long_paths_enabled():
|
||
"""``LongPathsEnabled`` on Windows (True on other systems)."""
|
||
if os.name != "nt":
|
||
return True
|
||
try:
|
||
import winreg
|
||
|
||
with winreg.OpenKey(
|
||
winreg.HKEY_LOCAL_MACHINE,
|
||
r"SYSTEM\CurrentControlSet\Control\FileSystem",
|
||
) as key:
|
||
return winreg.QueryValueEx(key, "LongPathsEnabled")[0] == 1
|
||
except OSError:
|
||
return False
|
||
|
||
|
||
# ── links ────────────────────────────────────────────────────────────
|
||
|
||
|
||
def is_link_like(path):
|
||
"""A symlink, a junction or any reparse point (one lstat), or missing.
|
||
|
||
Raises OSError other than "not found" (an unreachable share).
|
||
"""
|
||
try:
|
||
info = os.lstat(path)
|
||
except FileNotFoundError:
|
||
return False
|
||
if stat_module.S_ISLNK(info.st_mode):
|
||
return True
|
||
attributes = getattr(info, "st_file_attributes", 0)
|
||
if attributes & FILE_ATTRIBUTE_REPARSE_POINT:
|
||
return True
|
||
is_junction = getattr(os.path, "isjunction", None)
|
||
return bool(is_junction and is_junction(path))
|
||
|
||
|
||
# ── source keys ──────────────────────────────────────────────────────
|
||
|
||
|
||
def slug(name):
|
||
"""``NFKD`` to ASCII, lower case, runs outside ``[a-z0-9_]`` as ``-``,
|
||
trimmed and cut to 24 characters; empty becomes ``source``."""
|
||
text = unicodedata.normalize("NFKD", str(name))
|
||
text = text.encode("ascii", "ignore").decode("ascii").lower()
|
||
text = re.sub(r"[^a-z0-9_]+", "-", text).strip("-")
|
||
text = text[:SLUG_LENGTH].strip("-")
|
||
return text or "source"
|
||
|
||
|
||
def source_key(spelling, identity, taken):
|
||
"""The key of a source: ``slug(basename) + "-" + sha256(identity)[:n]``.
|
||
|
||
*taken* maps existing keys to their identity; a key taken by another
|
||
identity grows its hash to 12, then 16 characters.
|
||
"""
|
||
base = slug(ntpath.basename(str(spelling).rstrip("\\/")))
|
||
digest = hashlib.sha256(str(identity).encode("utf-8")).hexdigest()
|
||
for length in KEY_HASH_LENGTHS:
|
||
key = f"{base}-{digest[:length]}"
|
||
owner = taken.get(key)
|
||
if owner is None or owner == identity:
|
||
return key
|
||
raise ValueError("no free source key")
|
||
|
||
|
||
def file_identity(path):
|
||
"""``[st_dev, st_ino]`` of an existing folder, or None (no file ids)."""
|
||
try:
|
||
info = os.stat(path)
|
||
except (OSError, ValueError):
|
||
return None
|
||
if not info.st_ino:
|
||
return None
|
||
return [info.st_dev, info.st_ino]
|
||
|
||
|
||
def find_key(keys, spelling, identity, dev_ino):
|
||
"""The marker key of a source: the same configured spelling, then the
|
||
same identity text, then the same file id; or None."""
|
||
keys = {k: v for k, v in keys.items() if valid_key(k) and isinstance(v, dict)}
|
||
for key, entry in keys.items():
|
||
if entry.get("path") == spelling:
|
||
return key
|
||
for key, entry in keys.items():
|
||
if entry.get("identity") == identity:
|
||
return key
|
||
if dev_ino:
|
||
for key, entry in keys.items():
|
||
if entry.get("dev_ino") == list(dev_ino):
|
||
return key
|
||
return None
|
||
|
||
|
||
# ── the marker ───────────────────────────────────────────────────────
|
||
|
||
|
||
def marker_path(folder):
|
||
return os.path.join(folder, MARKER_NAME)
|
||
|
||
|
||
def read_marker(folder):
|
||
"""The marker in *folder* as a dict, or None (absent or unreadable as
|
||
ours). OSError other than "not found" propagates (an unreachable share)."""
|
||
try:
|
||
with open(marker_path(folder), "r", encoding="utf-8") as handle:
|
||
text = handle.read(MAX_MARKER_BYTES + 1)
|
||
except FileNotFoundError:
|
||
return None
|
||
except (ValueError, UnicodeDecodeError):
|
||
return None
|
||
if len(text) > MAX_MARKER_BYTES:
|
||
return None
|
||
try:
|
||
data = json.loads(text)
|
||
except ValueError:
|
||
return None
|
||
if not isinstance(data, dict) or not isinstance(data.get("id"), str):
|
||
return None
|
||
data["keys"] = valid_keys(data.get("keys"))
|
||
return data
|
||
|
||
|
||
def valid_key(key):
|
||
"""Whether *key* can name a source's folder: one folder name of the
|
||
``source_key`` form, never a separator, ``..`` or a drive."""
|
||
return isinstance(key, str) and KEY_PATTERN.fullmatch(key) is not None
|
||
|
||
|
||
def valid_keys(keys):
|
||
"""The marker's keys that are safe to join onto the folder; a hand-edited
|
||
or foreign entry that is not is dropped (and logged), never followed."""
|
||
if not isinstance(keys, dict):
|
||
return {}
|
||
kept = {}
|
||
for key, entry in keys.items():
|
||
if valid_key(key) and isinstance(entry, dict):
|
||
kept[key] = entry
|
||
else:
|
||
logger.warning(
|
||
"Ignoring a thumbnail folder marker entry that is not a source " "key"
|
||
)
|
||
return kept
|
||
|
||
|
||
@contextmanager
|
||
def _marker_lock(folder):
|
||
"""Hold the folder's marker lock: a file made with ``O_CREAT | O_EXCL``
|
||
(atomic on local disks and SMB/NFS shares, unlike ``flock``), so installs
|
||
sharing the folder change the marker one at a time. A lock older than
|
||
MARKER_LOCK_STALE was left by a writer that died and is taken over;
|
||
waiting longer than MARKER_LOCK_TIMEOUT raises TimeoutError."""
|
||
lock = os.path.join(folder, MARKER_LOCK_NAME)
|
||
deadline = time.monotonic() + MARKER_LOCK_TIMEOUT
|
||
while True:
|
||
try:
|
||
handle = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
|
||
except FileExistsError:
|
||
_break_stale_lock(lock)
|
||
if time.monotonic() >= deadline:
|
||
raise TimeoutError(
|
||
errno.ETIMEDOUT, "the thumbnail folder marker is locked", lock
|
||
)
|
||
time.sleep(MARKER_LOCK_POLL)
|
||
continue
|
||
try:
|
||
os.write(handle, f"{os.getpid()}\n".encode("ascii"))
|
||
finally:
|
||
os.close(handle) # closed before any unlink (Windows)
|
||
break
|
||
try:
|
||
yield
|
||
finally:
|
||
try:
|
||
os.unlink(lock)
|
||
except FileNotFoundError:
|
||
pass # taken over as stale by another writer: nothing to release
|
||
|
||
|
||
def _break_stale_lock(lock):
|
||
"""Remove *lock* when it is older than MARKER_LOCK_STALE (its writer
|
||
died); a lock that changed since it was looked at is left alone."""
|
||
try:
|
||
before = os.stat(lock)
|
||
except FileNotFoundError:
|
||
return
|
||
if time.time() - before.st_mtime < MARKER_LOCK_STALE:
|
||
return
|
||
logger.warning(f"Taking over a stale thumbnail folder lock: {lock}")
|
||
try:
|
||
after = os.stat(lock)
|
||
if (after.st_ino, after.st_mtime_ns) == (before.st_ino, before.st_mtime_ns):
|
||
os.unlink(lock)
|
||
except FileNotFoundError:
|
||
pass # another writer took it over first
|
||
|
||
|
||
def write_marker(folder, mutate):
|
||
"""Re-read the marker, apply ``mutate(marker)`` and write it atomically
|
||
(a temp sibling renamed over it), all under the folder's marker lock so a
|
||
second install sharing the folder never drops this one's change (and
|
||
the other way round). Callers hold ``preview_store.write_lock`` (threads
|
||
of this process). Returns the marker written; TimeoutError when another
|
||
writer holds the lock too long."""
|
||
with _marker_lock(folder):
|
||
return _write_marker_locked(folder, mutate)
|
||
|
||
|
||
def _write_marker_locked(folder, mutate):
|
||
current = read_marker(folder) or {}
|
||
marker = {
|
||
"version": MARKER_VERSION,
|
||
"id": current.get("id"),
|
||
"created_by_pm": bool(current.get("created_by_pm", False)),
|
||
"keys": dict(current.get("keys") or {}),
|
||
}
|
||
mutate(marker)
|
||
if not marker.get("id"):
|
||
raise ValueError("a marker needs an id")
|
||
target = marker_path(folder)
|
||
temp = os.path.join(
|
||
folder, f".{MARKER_NAME}.{os.getpid()}-{threading.get_ident()}.tmp"
|
||
)
|
||
try:
|
||
with open(temp, "w", encoding="utf-8") as handle:
|
||
json.dump(marker, handle, indent=2, sort_keys=True)
|
||
handle.flush()
|
||
os.fsync(handle.fileno())
|
||
os.replace(temp, target)
|
||
finally:
|
||
try:
|
||
os.unlink(temp)
|
||
except OSError:
|
||
pass # gone with the rename; a leftover is swept with the temps
|
||
return marker
|
||
|
||
|
||
def new_id():
|
||
return str(uuid.uuid4())
|
||
|
||
|
||
def init_folder(folder, *, created_by_pm=False):
|
||
"""The marker id of *folder*: an existing marker is adopted; a folder
|
||
without one gets a new marker (new id, no keys)."""
|
||
existing = read_marker(folder)
|
||
if existing is not None:
|
||
return existing["id"]
|
||
|
||
def create(marker):
|
||
if not marker.get("id"):
|
||
marker["id"] = new_id()
|
||
marker["created_by_pm"] = created_by_pm
|
||
|
||
return write_marker(folder, create)["id"]
|
||
|
||
|
||
def folder_state(path, expected_id):
|
||
"""``(available, problem, marker)`` of a configured thumbnail folder.
|
||
|
||
Available: the folder exists, is a directory and not a link, junction or
|
||
reparse point, and holds our marker with *expected_id*. Raises OSError
|
||
when the folder cannot be reached (an I/O error other than not found).
|
||
"""
|
||
try:
|
||
info = os.lstat(path)
|
||
except FileNotFoundError:
|
||
return False, "missing", None
|
||
except OSError as error:
|
||
if error.errno in (errno.ENOENT, errno.ENOTDIR):
|
||
return False, "missing", None
|
||
raise
|
||
if is_link_like(path):
|
||
return False, "invalid", None
|
||
if not stat_module.S_ISDIR(info.st_mode):
|
||
return False, "invalid", None
|
||
marker = read_marker(path)
|
||
if marker is None:
|
||
return False, "missing", None
|
||
if marker["id"] != expected_id:
|
||
return False, "marker-mismatch", marker
|
||
return True, None, marker
|
||
|
||
|
||
def holds_only_metadata(path):
|
||
"""Whether an existing folder is empty but for OS metadata files."""
|
||
with os.scandir(path) as entries:
|
||
for entry in entries:
|
||
name = entry.name.lower()
|
||
if name in OS_METADATA or name.startswith("._"):
|
||
continue
|
||
return False
|
||
return True
|
||
|
||
|
||
# ── validation ───────────────────────────────────────────────────────
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Context:
|
||
"""What a location is checked against (gathered by the caller)."""
|
||
|
||
roots: tuple = () # every image source of every root list
|
||
contained: tuple = () # ComfyUI output/input/temp and the library folder
|
||
node_folder: str | None = None
|
||
default_path: str | None = None
|
||
current_path: str | None = None # the folder in use (folder mode)
|
||
home: str | None = None
|
||
flavor: object = os.path
|
||
long_paths: bool | None = None
|
||
environ: object = None
|
||
# ComfyUI's folder_paths module (None: none; unset: imported).
|
||
folder_paths: object = library_location._UNSET
|
||
|
||
|
||
def _timed(func, timeout):
|
||
"""``func()`` on a daemon thread; raises TimeoutError past *timeout*.
|
||
A stuck call is left to finish on its own thread."""
|
||
result = {}
|
||
done = threading.Event()
|
||
|
||
def run():
|
||
try:
|
||
result["value"] = func()
|
||
except BaseException as error: # _timed() re-raises it in the caller
|
||
result["error"] = error
|
||
finally:
|
||
done.set()
|
||
|
||
threading.Thread(target=run, name="pm-preview-stat", daemon=True).start()
|
||
if not done.wait(timeout):
|
||
raise TimeoutError("the folder did not answer in time")
|
||
if "error" in result:
|
||
raise result["error"]
|
||
return result.get("value")
|
||
|
||
|
||
def _inside(path, folders):
|
||
return library_location._inside_any(path, folders)
|
||
|
||
|
||
def _same(first, second):
|
||
return library_location.same_folder(first, second) or os.path.normcase(
|
||
os.path.realpath(first)
|
||
) == os.path.normcase(os.path.realpath(second))
|
||
|
||
|
||
def _served(path, context):
|
||
environ = os.environ if context.environ is None else context.environ
|
||
return (
|
||
library_location.data_dir_problem(
|
||
path,
|
||
folder_paths=context.folder_paths,
|
||
environ=environ,
|
||
gallery_roots=list(context.roots),
|
||
)
|
||
== "data-dir-served"
|
||
)
|
||
|
||
|
||
def _probe_write(path):
|
||
"""Create and delete a probe file in *path*; False when refused."""
|
||
probe = os.path.join(path, f".pm-probe-{uuid.uuid4().hex[:8]}.tmp")
|
||
try:
|
||
with open(probe, "wb") as handle:
|
||
handle.write(b"")
|
||
except OSError:
|
||
return False
|
||
try:
|
||
os.unlink(probe)
|
||
except OSError:
|
||
return False
|
||
return True
|
||
|
||
|
||
def location_problem(path, *, purpose, context, timeout=VALIDATION_TIMEOUT):
|
||
"""Why *path* cannot be the thumbnail folder, as a code, or None.
|
||
|
||
*purpose*: ``move`` (every rule), ``startup`` (no ``same``, ``empty``,
|
||
``writable`` or parent rules: availability covers them) or ``recreate``
|
||
(the folder is missing and is made again).
|
||
"""
|
||
flavor = context.flavor
|
||
code = form_problem(path, flavor=flavor)
|
||
if code is not None:
|
||
return code
|
||
path = flavor.abspath(path)
|
||
default = bool(context.default_path) and flavor.normcase(path) == flavor.normcase(
|
||
flavor.abspath(context.default_path)
|
||
)
|
||
if is_too_broad(path, home=context.home, flavor=flavor):
|
||
return "location-too-broad"
|
||
try:
|
||
return _timed(
|
||
lambda: _filesystem_problem(path, purpose, context, default), timeout
|
||
)
|
||
except (TimeoutError, OSError):
|
||
return "location-unavailable"
|
||
|
||
|
||
def _filesystem_problem(path, purpose, context, default):
|
||
if _served(path, context):
|
||
return "location-served"
|
||
if context.node_folder and not default and _inside(path, [context.node_folder]):
|
||
return "location-node-folder"
|
||
inner = list(context.roots) + list(context.contained)
|
||
if any(folder and _inside(folder, [path]) for folder in inner):
|
||
if not default:
|
||
return "location-contains-source"
|
||
if (
|
||
purpose == "move"
|
||
and context.current_path
|
||
and (
|
||
os.path.normcase(path) == os.path.normcase(context.current_path)
|
||
or (os.path.isdir(path) and _same(path, context.current_path))
|
||
)
|
||
):
|
||
return "location-same"
|
||
if (
|
||
purpose == "move"
|
||
and context.current_path
|
||
and _inside(path, [context.current_path])
|
||
):
|
||
# The old key folders are removed after the move, and with them a
|
||
# target nested inside one of them.
|
||
return "location-inside-current"
|
||
# Windows, or a test injecting its limit (``long_paths=False``).
|
||
if os.name == "nt" or context.long_paths is False:
|
||
if too_long_for_windows(path, context.long_paths):
|
||
return "location-path-too-long"
|
||
if purpose == "startup":
|
||
return None
|
||
if is_link_like(path):
|
||
# Never through a link: the folder itself must be a real folder.
|
||
return "location-not-empty"
|
||
exists = os.path.isdir(path)
|
||
if not exists:
|
||
if os.path.lexists(path):
|
||
return "location-not-empty"
|
||
if not default and not os.path.isdir(os.path.dirname(path)):
|
||
return "location-parent-missing"
|
||
return None
|
||
if read_marker(path) is None and not holds_only_metadata(path):
|
||
return "location-not-empty"
|
||
if not _probe_write(path):
|
||
return "location-not-writable"
|
||
return None
|