Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e249199ca | ||
|
|
430c15ebd7 | ||
|
|
f51f349336 | ||
|
|
7654590dce | ||
|
|
30ff4fd2eb | ||
|
|
4dd66c51c6 | ||
|
|
3c1fd69bfa | ||
|
|
d62c37df77 | ||
|
|
0d79665b81 | ||
|
|
2f0eaa39c5 | ||
|
|
34df48c031 | ||
|
|
a33b0f4648 | ||
|
|
c14e1af87f | ||
|
|
981639740b | ||
|
|
57ab85b002 | ||
|
|
38f5491da1 | ||
|
|
c0328e993a | ||
|
|
8a81114dfc | ||
|
|
7a2a04b752 | ||
|
|
cb94b12c1b | ||
|
|
3cb90b0c1e | ||
|
|
9a43b67008 | ||
|
|
c3c75c3369 | ||
|
|
93bf24f47d | ||
|
|
e29c23de8d | ||
|
|
b82e68a9d0 | ||
|
|
fb02066cda | ||
|
|
ab17818792 | ||
|
|
ef97d01dbd | ||
|
|
b7a43755ae | ||
|
|
2c7e84dedd | ||
|
|
cc65d15d14 | ||
|
|
d88cf9e7c6 | ||
|
|
df70b5dfd6 | ||
|
|
7e1847955b | ||
|
|
ecb6b09813 | ||
|
|
cdb791d4a9 | ||
|
|
f924d7195d | ||
|
|
3789d85e58 | ||
|
|
fb6452934f | ||
|
|
395dc74cbe | ||
|
|
2f061904a2 | ||
|
|
f80e67c812 | ||
|
|
ab6fee3a82 | ||
|
|
f53a43152d | ||
|
|
fe63f01214 | ||
|
|
aab0fedc10 | ||
|
|
085e45e475 | ||
|
|
89572e03c6 |
@@ -1,9 +1,9 @@
|
||||
name: Windows and macOS
|
||||
name: Windows
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Non-blocking portability runs (#420): never on pull requests or in the merge queue, and no context
|
||||
# Non-blocking Windows runs (#420; macOS dropped from CI in #571): never on pull requests or in the merge queue, and no context
|
||||
# here is a required check. A red run is fixed at its cause before the workspace cutover (#334).
|
||||
on:
|
||||
push:
|
||||
@@ -25,10 +25,10 @@ env:
|
||||
# run counts (tests/vendor/ci-workflow.test.mjs recomputes it, #493).
|
||||
# Measured 2026-10-29 on main runs 40935337099 (dd247e3), 40938407252 (dd247e3), 40941240624 (dd247e3):
|
||||
# slowest leg: 7m24s (py3.12 windows) / 7m31s (py3.13 windows) / 7m32s (py3.14 windows) -> 15 min
|
||||
# (windows 4m56s-7m32s, macos 4m39s-6m14s)
|
||||
# native-trash (#499) has no green main runs yet: its probe, run 40949370943 (2026-10-29), took
|
||||
# about 1 minute per macOS leg, so ceil(1.5 x 2) + 3 gives it 6 minutes (step 4). Recalibrate
|
||||
# after three green runs.
|
||||
# (windows 4m56s-7m32s)
|
||||
# native-trash-linux (#543) has no green main runs yet: it installs the dev requirements and runs
|
||||
# one test module, about 1 minute by the same estimate, so ceil(1.5 x 2) + 3 gives it 6 minutes.
|
||||
# Recalibrate after three green runs.
|
||||
jobs:
|
||||
test:
|
||||
name: py${{ matrix.python-version }} on ${{ matrix.os }}
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [windows-latest, macos-latest]
|
||||
os: [windows-latest]
|
||||
python-version: ["3.12", "3.13", "3.14"]
|
||||
|
||||
steps:
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
|
||||
# The runner's TEMP is on C:, the OS disk, where a flush took about 6 ms
|
||||
# and a test database's create-to-delete about 111 ms; on the D: work
|
||||
# disk the same took 0.5 ms and 23 ms (macOS: 19 ms). Tests write their
|
||||
# disk the same took 0.5 ms and 23 ms. Tests write their
|
||||
# databases under TEMP, so it moves to a folder on D: (measured in #487,
|
||||
# run 40915866444). The runner's TEMP was an 8.3 short name
|
||||
# (C:\Users\RUNNER~1\...), so the new one is spelled by its short name too
|
||||
@@ -80,16 +80,12 @@ jobs:
|
||||
PM_TEST_SLOWEST: "40"
|
||||
run: python -m tests.run_suite -v
|
||||
|
||||
# The real Trash on both macOS CPUs (#499 design §10.2). It runs apart from the suite, so a red
|
||||
# suite never skips the native proof.
|
||||
native-trash:
|
||||
name: native trash on ${{ matrix.os }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
# The same tests as the fake mount table, on a real ext4 mount used as the output root (#543). A
|
||||
# runner can mount with sudo; the tests run as the normal user, so owner and mode checks are real.
|
||||
native-trash-linux:
|
||||
name: native trash on a real mount
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 6
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [macos-latest, macos-15-intel]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
@@ -98,9 +94,24 @@ jobs:
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: pip
|
||||
cache-dependency-path: requirements-dev.txt
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements-dev.txt
|
||||
|
||||
- name: Mount an ext4 image
|
||||
run: |
|
||||
truncate -s 64M "$RUNNER_TEMP/drive.img"
|
||||
mkfs.ext4 -q "$RUNNER_TEMP/drive.img"
|
||||
mkdir "$RUNNER_TEMP/drive"
|
||||
sudo mount -o loop "$RUNNER_TEMP/drive.img" "$RUNNER_TEMP/drive"
|
||||
sudo rmdir "$RUNNER_TEMP/drive/lost+found"
|
||||
sudo chown "$(id -u):$(id -g)" "$RUNNER_TEMP/drive"
|
||||
findmnt "$RUNNER_TEMP/drive"
|
||||
|
||||
- name: Native trash proof
|
||||
timeout-minutes: 4
|
||||
env:
|
||||
PM_NATIVE_TRASH: macos
|
||||
run: python -m unittest tests.test_trash_macos_native tests.test_trash_macos -v
|
||||
run: PM_TRASH_MOUNT="$RUNNER_TEMP/drive" python -m unittest tests.test_trash_mount_root -v
|
||||
|
||||
@@ -28,7 +28,7 @@ env:
|
||||
# py matrix, slowest py3.10 on windows-latest: 12m43s / 15m54s / 14m53s -> 27 min
|
||||
# (ubuntu 5m27s-6m27s, macos 6m14s-8m30s, windows 11m42s-15m54s)
|
||||
# 3.10 and 3.11 left the matrix and 3.14 joined it in #356; re-measure after three green main runs.
|
||||
# Windows and macOS moved to portability.yml (#420), which has its own measured limit (#493);
|
||||
# Windows moved to portability.yml (#420; macOS left CI in #571), which has its own measured limit (#493);
|
||||
# this Linux-only matrix keeps 27 minutes until it is re-measured.
|
||||
# real ComfyUI compatibility: 13m32s / 8m55s / 11m46s -> 24 min
|
||||
# coverage gate (85%): 7m26s / 7m22s / 7m54s -> 15 min
|
||||
@@ -39,7 +39,7 @@ env:
|
||||
# Pull requests run a fast subset (#420): Python 3.12 on ubuntu-latest, coverage, lint and frontend
|
||||
# modules. The merge queue (merge_group), pushes to main and manual runs add Python 3.13 and 3.14 on
|
||||
# Linux and the real ComfyUI job. "all tests" is the required gate that holds the queue to that set.
|
||||
# Windows and macOS run in portability.yml (push to main, nightly, on demand) and never gate a merge.
|
||||
# Windows runs in portability.yml (push to main, nightly, on demand) and never gate a merge.
|
||||
jobs:
|
||||
test:
|
||||
name: py${{ matrix.python-version }} on ${{ matrix.os }}
|
||||
|
||||
@@ -41,9 +41,13 @@ concurrency:
|
||||
# 7.3m-12.2m), because --shard splits by test count and the slow concept specs sort into shard 1. The page
|
||||
# workflows now run in three shards per browser with the limits unchanged; the per-shard times are
|
||||
# re-measured after three green main runs.
|
||||
# #563: coverage shards reached their 18-minute step limit three times on 2026-10-31 with no failing
|
||||
# test (PR #560 shard 2/3: 814 passed in 17.9m; queue run 41206495451 shard 1/3: 827 passed; PR #564
|
||||
# shard 2/3 twice), against recent green shards of 11m-17m. Coverage now runs in four shards with the
|
||||
# limits unchanged; the per-shard times are re-measured after three green main runs.
|
||||
jobs:
|
||||
frontend-coverage:
|
||||
name: frontend coverage (shard ${{ matrix.shard }}/3)
|
||||
name: frontend coverage (shard ${{ matrix.shard }}/4)
|
||||
runs-on: ubuntu-latest
|
||||
# Layout comparisons depend on the fonts each browser falls back to, so browser jobs run in the
|
||||
# same pinned Playwright image as local runs (tag = @playwright/test in tools/vendor/package-lock.json;
|
||||
@@ -59,7 +63,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3]
|
||||
shard: [1, 2, 3, 4]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
# The image's own Python 3.12 and Node 24: setup-python/setup-node install the runner's toolcache
|
||||
@@ -88,13 +92,13 @@ jobs:
|
||||
- run: npm ci --prefix tools/vendor --ignore-scripts
|
||||
# Shard 1 also runs the node units and the viewer suite, so the vendor pins and the tooling tests
|
||||
# run on the lightest shard instead.
|
||||
- if: matrix.shard == 3
|
||||
- if: matrix.shard == 4
|
||||
run: npm run --prefix tools/vendor check
|
||||
- if: matrix.shard == 3
|
||||
- if: matrix.shard == 4
|
||||
run: npm run --prefix tools/vendor test
|
||||
- name: Page workflows with coverage, shard ${{ matrix.shard }}/3
|
||||
- name: Page workflows with coverage, shard ${{ matrix.shard }}/4
|
||||
timeout-minutes: 18
|
||||
run: npm run --prefix tools/vendor test:coverage:shard -- ${{ matrix.shard }}/3
|
||||
run: npm run --prefix tools/vendor test:coverage:shard -- ${{ matrix.shard }}/4
|
||||
- name: Upload raw coverage
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
@@ -136,13 +140,13 @@ jobs:
|
||||
with:
|
||||
pattern: coverage-raw-*
|
||||
path: ${{ runner.temp }}/coverage-raw
|
||||
# coverage:merge only checks that raw files exist; merging two of three shards would report
|
||||
# coverage:merge only checks that raw files exist; merging three of four shards would report
|
||||
# partial coverage, so a missing or empty shard artifact fails here.
|
||||
- name: Verify every shard's raw coverage arrived
|
||||
shell: bash
|
||||
env:
|
||||
STAGED_RAW: ${{ runner.temp }}/coverage-raw
|
||||
EXPECTED_SHARDS: 3
|
||||
EXPECTED_SHARDS: 4
|
||||
run: |
|
||||
found=0
|
||||
for shard in $(seq 1 "$EXPECTED_SHARDS"); do
|
||||
|
||||
@@ -74,14 +74,14 @@ V8 files it writes under `coverage/js/raw`:
|
||||
|
||||
```sh
|
||||
PM_TEST_WORKERS=8 npm run --prefix tools/vendor test:coverage # whole run, one report
|
||||
npm run --prefix tools/vendor test:coverage:shard -- 1/3 # node units, viewer suite, workflows 1/3
|
||||
npm run --prefix tools/vendor test:coverage:shard -- 2/3 # workflows 2/3 (likewise 3/3)
|
||||
npm run --prefix tools/vendor test:coverage:shard -- 1/4 # node units, viewer suite, workflows 1/4
|
||||
npm run --prefix tools/vendor test:coverage:shard -- 2/4 # workflows 2/4 (likewise 3/4, 4/4)
|
||||
npm run --prefix tools/vendor coverage:merge # report and baseline check of every raw file
|
||||
npm run --prefix tools/vendor test:workflows -- --project webkit --shard 2/2
|
||||
```
|
||||
|
||||
`coverage:merge` refuses an empty raw directory; it does not count shards, so run
|
||||
every shard first (CI's merge job checks all three arrived). Measured on a 32-CPU
|
||||
every shard first (CI's merge job checks all four arrived). Measured on a 32-CPU
|
||||
machine in the pinned image (2026-10-21): `test:coverage` takes about 5 minutes
|
||||
with 8 workers and 24 minutes with 1; all four projects, 8 workers, each test 3
|
||||
times (6132 tests), 24 minutes. A CI-sized run (`docker run --cpus=4`,
|
||||
@@ -189,14 +189,14 @@ check, so run it before pushing a new or deleted frontend file instead of
|
||||
waiting for the `frontend coverage` jobs.
|
||||
|
||||
The `viewerjs` check runs on every PR, including backend-only changes. It is an
|
||||
aggregate job. It requires the three `frontend coverage (shard N/3)` jobs and
|
||||
aggregate job. It requires the four `frontend coverage (shard N/4)` jobs and
|
||||
`frontend coverage (merge)` to succeed on every event. The four
|
||||
`page workflows (firefox|webkit, shard N/2)` jobs run only in the merge queue,
|
||||
on `main` and on `workflow_dispatch` (see "Pull requests and the merge queue").
|
||||
`viewerjs` requires them to be skipped on a pull request and to succeed
|
||||
everywhere else. Any other failed, cancelled or skipped job fails it. Every browser job runs with `PM_TEST_WORKERS: 3` (the public
|
||||
runners have 4 vCPUs). Each coverage shard runs `test:coverage:shard -- N/3`
|
||||
(shard 1 also runs the node units and the viewer suite, shard 3 the vendor pins
|
||||
runners have 4 vCPUs). Each coverage shard runs `test:coverage:shard -- N/4`
|
||||
(shard 1 also runs the node units and the viewer suite, shard 4 the vendor pins
|
||||
and tooling tests) and uploads its raw V8 files as `coverage-raw-N` (kept 3
|
||||
days). The merge job downloads them, fails unless all three arrived with raw
|
||||
files and without clashing names, then runs `coverage:merge` (report plus
|
||||
@@ -261,7 +261,7 @@ the process exits non-zero.
|
||||
job fails anyway and the dump is what matters.
|
||||
- `PM_TEST_SLOWEST=N` (off by default) lists the N slowest tests after the
|
||||
summary, each timed from its `start:` line to the next one like the hang
|
||||
limit. The Windows and macOS jobs (`portability.yml`) set it to 40, so every
|
||||
limit. The Windows jobs (`portability.yml`) set it to 40, so every
|
||||
run shows where its time went (#487). The list prints only when the run
|
||||
ends: a job GitHub cancels or a watchdog exit prints none, so read the
|
||||
timestamps between `start:` lines instead.
|
||||
@@ -327,7 +327,7 @@ Python 3.13/3.14, real ComfyUI and Firefox/WebKit failures first show up in the
|
||||
merge queue, on the merge-group commit rather than the
|
||||
pushed one. Find that run with `gh run list --event merge_group --workflow test.yml`
|
||||
(or `viewerjs-check.yml`) and match its head branch
|
||||
(`gh-readonly-queue/main/pr-<N>-...`). Windows and macOS failures show up only
|
||||
(`gh-readonly-queue/main/pr-<N>-...`). Windows failures show up only
|
||||
after the merge, in `gh run list --workflow portability.yml`.
|
||||
|
||||
Start fixing as soon as a failed log shows the cause, but push the fix only after
|
||||
@@ -345,11 +345,16 @@ and the time left. The steps, in order:
|
||||
monitor's callbacks (Needs review recorder, preview queue, Images listing
|
||||
listener).
|
||||
2. `ImageMonitor.stop_monitoring` works against one 5 s deadline. Closing the
|
||||
image handler joins the image worker; watchdog's `observer.stop()` (not
|
||||
bounded here) and the observer join get what is left, which can be 0 s.
|
||||
3. `close_auto_thumbnails(final=True)` stops the preview worker (30 s) and
|
||||
kills ffmpeg.
|
||||
4. `PromptTracker.shutdown` stops the prompt cleanup thread (2 s).
|
||||
image handler joins the image worker; watchdog's `observer.stop()` and the
|
||||
observer join get what is left, which can be 0 s. `stop()` joins watchdog's
|
||||
emitters without a limit, so it runs on a daemon thread and is waited for
|
||||
only until the deadline; a later stop waits for that same call (#561).
|
||||
3. `close_auto_thumbnails(final=True)` stops the preview worker (30 s),
|
||||
kills ffmpeg and shuts the interactive image pool down.
|
||||
4. `shutdown_job_pool` (`utils/parallel.py`) cancels the long-lived job pool's
|
||||
queued items and joins its threads (5 s; part `workers`, only when a job
|
||||
used the pool, #523).
|
||||
5. `PromptTracker.shutdown` stops the prompt cleanup thread (2 s).
|
||||
|
||||
A failing step, or a second Ctrl+C, is logged with its traceback and does not
|
||||
skip the later steps. A clean run logs one INFO line to PromptManager's log:
|
||||
@@ -361,6 +366,15 @@ stored, with the parts not reached False, and the `atexit` callback is
|
||||
removed: the later steps are skipped, no summary line is logged, and a later
|
||||
call returns that result and runs nothing.
|
||||
|
||||
The observer is started from a short-lived daemon thread, so every thread it
|
||||
creates is a daemon (a thread inherits that flag from the thread that creates
|
||||
it). watchdog 7 on Windows starts a plain `threading.Thread` per watch for its
|
||||
`ReadDirectoryChangesW` reader; created from ComfyUI's main thread it would not
|
||||
be a daemon, and the interpreter waits for every such thread before it runs any
|
||||
`atexit` callback, so the teardown would never start (#561).
|
||||
`tests/test_package_exit.py` runs a child whose observer does both (a plain
|
||||
reader thread, a `stop()` that never returns) and requires its exit.
|
||||
|
||||
ComfyUI never runs aiohttp's `on_cleanup` hooks, because it never calls
|
||||
`runner.cleanup()`. So `atexit` is the live host's path. It runs on Ctrl+C
|
||||
(`main.py` catches `KeyboardInterrupt` and returns) and on a normal exit.
|
||||
@@ -445,12 +459,44 @@ A hot SQLite read that returns many rows aggregates them into one step
|
||||
(`json_group_array`) instead of stepping once per row, so it does not wait for
|
||||
the GIL once per row while ComfyUI generates (#354, `get_image_link_matches`).
|
||||
|
||||
A metadata read never decodes pixels (#523). Pillow's `PngImageFile.text` loads
|
||||
every pixel to reach text chunks after IDAT; the scan (`_extract_comfyui_metadata`)
|
||||
and the image monitor (`ComfyUIMetadataExtractor.extract_metadata`) read PNG text
|
||||
through `utils/png_text.py`, which runs Pillow's own chunk handlers after the
|
||||
first IDAT and seeks over pixel data. The legacy `.text` read is the floor:
|
||||
`tests/test_png_text.py` compares both readers on every image under
|
||||
`tests/fixtures`, a curated corpus (text after IDAT, zTXt, iTXt, split IDAT, bad
|
||||
checksums, truncations, oversized text, APNG) and a seeded fuzz, and asserts that
|
||||
neither reader decodes pixels by patching `ImageFile.load`. The only reads that
|
||||
differ are files whose pixel data is undecodable or cut short: legacy found
|
||||
nothing, the walk keeps the text before the pixels. Animated PNGs still use
|
||||
`.text`.
|
||||
|
||||
Image work runs on PromptManager's own threads (#523). glibc keeps each thread's
|
||||
malloc arena at its high-water mark, so image buffers spread over the default
|
||||
executor's threads stay resident. Interactive image work (view previews behind their
|
||||
gate, workspace card facts, both inspector routes) runs on the API's fixed
|
||||
`pm-image` pool (`IMAGE_POOL_SIZE` 3, `_run_image`, `_map_image`; card facts already
|
||||
cached answer without a trip; while the gate's 3 preview writes hold every thread, a
|
||||
card or inspector read waits for one of them, by design of the merged pool; after the
|
||||
package teardown the pool is not recreated and late calls use the default executor). Jobs (scan metadata and file facts, thumbnail and
|
||||
convert jobs, the legacy gallery generation, capture previews) run through
|
||||
`map_parallel` on one long-lived `pm-worker` pool of Worker threads, reused between
|
||||
jobs; a new Worker threads value replaces it (the teardown also joins a replaced pool
|
||||
still finishing), and a call made from a job thread runs inline. After a scan, thumbnail or convert job, or the legacy gallery generation,
|
||||
`release_free_memory` (`utils/native_memory.py`) calls glibc's `malloc_trim(0)` once;
|
||||
it does nothing on Windows, macOS and musl and never raises. No allocator setting
|
||||
changes. `tests/test_image_pools.py` asserts the thread of each read and write and the
|
||||
trim; `tests/test_utils_parallel.py` the pool's reuse, resize, nesting and shutdown.
|
||||
`tools/perf/memory_drive.py` (manual, Linux, never in CI) measures RSS, heap, threads
|
||||
and arenas under the #523 load against the disposable browser test server.
|
||||
|
||||
The `perf budget` job in `.github/workflows/test.yml` is the reference
|
||||
measurement: `ubuntu-latest`, one Python, no coverage, only the wall-clock tests.
|
||||
It runs on every push to `main` and on `workflow_dispatch`, never on pull
|
||||
requests, because shared-runner timing must not block a merge. Dispatch it on a
|
||||
branch to measure before merging.
|
||||
Shared Windows and macOS runners do not run them; the structural tests cover
|
||||
Shared Windows runners do not run them; the structural tests cover
|
||||
portability. Run it locally with
|
||||
`PROMPTMANAGER_PERF_BUDGET=1 python -m unittest tests.test_api_workspace_images.TestWorkspaceImages.test_filter_budget`
|
||||
and quote the printed p95 in the PR body.
|
||||
@@ -646,10 +692,10 @@ Before changing paths, SQLite, streams, restore or cleanup:
|
||||
ordinary busy timeout; cross-thread handoffs wait on events with generous
|
||||
ceilings, never a short wall clock (#242).
|
||||
- Run native Windows CI early for these changes. Pull requests and the merge
|
||||
queue run only Linux, so start Windows and macOS on the branch with
|
||||
queue run only Linux, so start Windows on the branch with
|
||||
`gh workflow run portability.yml --ref <branch>`.
|
||||
Include a Windows impact and evidence note in the PR. All six Windows/macOS
|
||||
combinations in that run, every Linux leg and every applicable
|
||||
Include a Windows impact and evidence note in the PR. All three Windows
|
||||
legs in that run, every Linux leg and every applicable
|
||||
exact-head check must pass before merge. Portable `ntpath`/`PureWindowsPath`
|
||||
controls do not establish native filesystem, locking or cleanup success.
|
||||
|
||||
@@ -664,11 +710,11 @@ what runs:
|
||||
| `py3.13 on ubuntu-latest`, `py3.14 on ubuntu-latest` | not created | run |
|
||||
| `real ComfyUI compatibility` | skipped | runs |
|
||||
| `coverage gate (85%)`, `black and flake8`, `frontend modules`, `bandit (high severity)` | run | run |
|
||||
| `frontend coverage (shard N/3)`, `frontend coverage (merge)` (vendor pins and tooling tests on shard 3) | run | run |
|
||||
| `frontend coverage (shard N/4)`, `frontend coverage (merge)` (vendor pins and tooling tests on shard 4) | run | run |
|
||||
| `page workflows (firefox\|webkit, shard N/2)` | skipped | run |
|
||||
| `all tests`, `viewerjs` (gates) | run | run |
|
||||
| `perf budget` | skipped | push and dispatch only (not in the queue) |
|
||||
| `py3.12-3.14 on windows-latest/macos-latest` (`portability.yml`) | not run | push to `main`, nightly, `workflow_dispatch` only; never required |
|
||||
| `py3.12-3.14 on windows-latest` (`portability.yml`) | not run | push to `main`, nightly, `workflow_dispatch` only; never required |
|
||||
|
||||
GitHub has one required set for pull requests and the queue. A job skipped by
|
||||
its `if:` reports Success. A dropped matrix leg never reports, so it would stay
|
||||
@@ -681,8 +727,9 @@ guarantee:
|
||||
So a merge group cannot turn green unless all three Linux Python legs, real
|
||||
ComfyUI and the four browser shards passed.
|
||||
|
||||
Windows and macOS (Python 3.12-3.14) are out of the blocking path for now
|
||||
(maintainer, 2026-10-28). They run in `portability.yml` on every push to `main`,
|
||||
Windows (Python 3.12-3.14) is out of the blocking path for now
|
||||
(maintainer, 2026-10-28). macOS is not tested in CI (maintainer, 2026-10-31, #571);
|
||||
its code stays and support is best-effort. They run in `portability.yml` on every push to `main`,
|
||||
nightly and on demand, and no context there is required. A red portability run
|
||||
is still a failure: fix it at its cause, not by rerunning, and have every one
|
||||
green before the workspace cutover (#334). `tests/vendor/ci-workflow.test.mjs` pins the
|
||||
@@ -763,7 +810,7 @@ filter Images, D21), the six facts, and a footer with "Send to ComfyUI" and
|
||||
"Prompt history" only when prompt roles resolved or a link is saved; the sticky panel
|
||||
scrolls inside the window. The viewer's details (`#viewer-details`) repeat that
|
||||
content without the cover, then "File & provenance" (the honest file facts, the
|
||||
`Prompt evidence` line, Download original and, for a listed file, D38's local-only
|
||||
`Prompt evidence` line, Download original and, for a listed file, D38's
|
||||
"Show in folder" after the Path) and "Workflow & raw metadata" (raw records and their
|
||||
two downloads), then the pair send and Prompt history buttons. D22 words an empty
|
||||
positive explicitly and makes confirmed prompt text focusable.
|
||||
@@ -1402,6 +1449,15 @@ seeds two seedless files (`seedless_images` on `/__test__/concept/reset`, with
|
||||
Tag) and asserts that every row reserves the tallest footer and that their own footer
|
||||
is shorter than the row it reserves.
|
||||
|
||||
### Overview counts (#547)
|
||||
|
||||
Each Overview card is the count of the page it names: the shell's Library, Images and Tags sidebar counts, one source,
|
||||
so a card and its badge cannot differ. `/prompt_manager/stats` is for the legacy page and counts raw rows. A visit re-reads
|
||||
the three unless a read is in flight. An unread or failed card shows a dash; Retry re-reads only the failed counts.
|
||||
System › Overview's Images metric reads the same shell count (#555), so it, the Overview card and the Images badge show
|
||||
one number; its Statistics dialog labels the legacy row counts as "Database records".
|
||||
Tests: `tests/js/workspace-state.test.js`, `tests/browser/workspace.spec.mjs`, `tests/comfyui/workspace.spec.mjs`.
|
||||
|
||||
### Images page controls and recorded deviations (#249 PR C)
|
||||
|
||||
The Images page renders the concept's DOM with real data. Its controls, top to bottom:
|
||||
@@ -1566,20 +1622,27 @@ Recorded deviations on this page (spec §8):
|
||||
|
||||
| Result | Toast |
|
||||
|---|---|
|
||||
| Success | "Opened the folder on this computer." |
|
||||
| 403 `not-local` | "Show in folder works only in a browser on the computer running ComfyUI." |
|
||||
| 501 `unavailable` | "No file manager is available on this computer." |
|
||||
| Success | "Opened the folder on the computer running ComfyUI." |
|
||||
| 403 `not-same-origin` | "The folder was not opened: this page's address does not match the server's." |
|
||||
| 501 `unavailable` | "No file manager is available on the computer running ComfyUI." |
|
||||
| 429 `busy` | "The folder is already opening." |
|
||||
| Anything else (415, 404, 409, 500, offline) | "The folder could not be opened. Try again." |
|
||||
|
||||
The route is local-only. It answers 403 `not-local` unless all of these hold: no
|
||||
forwarding-style header (`Forwarded`, `Via`, `X-Real-IP`, `CF-Connecting-IP`,
|
||||
`True-Client-IP`, `X-Client-IP`, `X-Cluster-Client-IP`, `Fastly-Client-IP`, and any
|
||||
`X-Forwarded-*`, `X-Original-*` or `*-Client-IP`); a loopback `request.remote`; a
|
||||
loopback `Host` (`localhost`, `*.localhost` or a loopback address, against DNS
|
||||
rebinding); an `Origin` equal to the request's own scheme, host and port (absent,
|
||||
`null`, another port or another scheme is refused; browsers send `Origin` on every
|
||||
fetch POST); and, when sent, `Sec-Fetch-Site: same-origin`. A body that is not
|
||||
**D176 (#533, beyond the concept):** the viewer is a modal, so `showWorkspaceToast`
|
||||
moves `#workspace-toast` into the topmost open modal (by hit test when several are
|
||||
open), keeping its fixed position, and back when that modal closes. A modal opened
|
||||
after a toast covers it until the next toast. Otherwise every toast raised from a modal is
|
||||
covered and inert while `toHaveText` still passes: assert `toBeVisible` and the
|
||||
hit test as `full-inspector.spec.mjs` does.
|
||||
|
||||
The route opens the folder on the computer running ComfyUI wherever the browser is,
|
||||
through a proxy too (maintainer decision, #533, amending the original local-only
|
||||
rule; accepted risk: anyone who can reach the server can open file windows on it).
|
||||
It answers 403 `not-same-origin` unless `Origin` is present and matches: its host
|
||||
and port against `X-Forwarded-Host` when sent, else `Host`, with the scheme's default
|
||||
port filled in; its scheme against `X-Forwarded-Proto` when sent, else the socket's
|
||||
(an https page on an http socket passes, TLS having ended at a proxy); and, when
|
||||
sent, `Sec-Fetch-Site: same-origin`. A body that is not
|
||||
`application/json` is 415 `unsupported-media-type`, so any cross-origin request
|
||||
needs a CORS preflight. The body is strict (an int root, a 64-hex scope, a bounded
|
||||
relative path; absolute, drive and `..` paths are 400), the scope goes through
|
||||
@@ -1596,7 +1659,9 @@ Recorded deviations on this page (spec §8):
|
||||
reach it), falling back to `os.startfile(folder, "explore")`. macOS runs
|
||||
`/usr/bin/open -R`; Linux asks FileManager1 `ShowItems` with `dbus-send
|
||||
--print-reply --reply-timeout=2000` (a real reply or a fallback) and else runs
|
||||
`xdg-open` on the folder. Those openers are argv lists resolved only on a fixed
|
||||
`xdg-open` on the folder, or answers 501 without `DISPLAY` and `WAYLAND_DISPLAY`.
|
||||
`open` and `xdg-open` get `OPENER_WAIT` (1 s): a nonzero exit is the 500 above, and
|
||||
one still running counts as started and goes to the reaper (#533). Those openers are argv lists resolved only on a fixed
|
||||
system PATH, with no shell, DEVNULL streams, `close_fds`, their own session, a
|
||||
minimal environment (no venv PATH, PYTHONPATH or API keys) and a daemon-thread
|
||||
reaper. `tests/test_image_reveal.py` mocks `Popen`, `sys.platform`, `shutil.which`,
|
||||
@@ -1604,12 +1669,11 @@ Recorded deviations on this page (spec §8):
|
||||
(`POST`/`GET /__test__/reveal`) with no cooldown. The inspector comparison reads
|
||||
closed disclosures as hidden, so the button needs no mapping there.
|
||||
|
||||
Residual risk, accepted: a reverse proxy or tunnel that adds no forwarding header
|
||||
(a bare nginx `proxy_pass` to 127.0.0.1, `ssh -R`, `socat`) cannot be told apart
|
||||
from a local client, and a non-browser client behind it can forge a matching
|
||||
`Host` and `Origin`. Such a client can open a folder of a listed file on the host's
|
||||
screen, at most once a second, and learn whether that file exists (which the
|
||||
listing routes already tell it). Operators who expose ComfyUI that way accept this.
|
||||
Limits of that check: a proxy that rewrites `Host` and sends no `X-Forwarded-Host` is
|
||||
refused with that toast until it sends one. A non-browser client can forge every
|
||||
header. The check stops a plain cross-site POST, not every other site: a DNS-rebinding
|
||||
page has a matching `Host` and `Origin`, so it can open a folder of a listed file, at
|
||||
most once a second. The path check confines it, one check ahead of every opener.
|
||||
- **D39** (ruled 2026-10-21: fallback): a recipe-linked image shows its recipe's own
|
||||
category when set, otherwise the linked positive prompt's, and its recipe's own tags
|
||||
when it has any, otherwise the positive's; each field falls back on its own and the
|
||||
@@ -2489,7 +2553,28 @@ files to the system trash. **No code path unlinks or overwrites a media file.**
|
||||
(`_collect_media_files`), the legacy auto-tag listing (`_collect_scan_files`) and the
|
||||
delete review (`trash-folder`), and the image monitor, which tests the folders below the
|
||||
watched folder holding the new file (a source named like a trash folder is still watched). Tests: `tests/test_media_folders.py`,
|
||||
`tests/test_image_removal_gaps.py`.
|
||||
`tests/test_image_removal_gaps.py`. Media routes answer not found for a path in such a
|
||||
folder, resolved first so a link or another spelling cannot pass (#543):
|
||||
`_media_access_error` is the one gate (by-id, inspect, reveal, link, rescope as
|
||||
`file-missing`, a prompt's media listing); `/images/serve` and the autotag target ask
|
||||
`in_trash_below_roots` too, `media_target` refuses by name (annotations, collections,
|
||||
whose capture store denies links and aliases), and previews are neither made nor served, the fallback past the preview gate's cap
|
||||
included (`_quick_source`). A History output in one answers `missing`, checked at the
|
||||
candidate and again at the open (a preview can be a link). The legacy duplicate delete
|
||||
removes exactly the folder's real path plus the name (a link itself, never its target)
|
||||
and refuses if either spelling is in a trash folder; `file_problem` refuses too. The delete
|
||||
and Return flows never use this gate. Windows spellings (trailing dots and spaces, case,
|
||||
8.3) run for real on the Windows job in `tests/test_trash_gate_routes.py`.
|
||||
- **An output folder that is a mount point** (#543): the mount's own `.Trash-$uid` and
|
||||
`.Trash/$uid` are allowed only for a root that is exactly that mount, with the spec's
|
||||
checks intact. A root above the mount, a mount nested under the root, a source that is or
|
||||
sits in the trash folder, and a link in place of the trash folder stay refused. Tests:
|
||||
`tests/test_trash_mount_root.py`, on a fake mount table in the suite and, with
|
||||
`PM_TRASH_MOUNT=<real mount>`, in portability.yml's `native-trash-linux` job (loop-mounted
|
||||
ext4). Locally, without root: `unshare -Urm` over a tmpfs.
|
||||
- **D190** (#543, beyond the concept): a trash review where nothing can move shows a primary
|
||||
that clearly cannot act: `aria-disabled`, no danger colors (also on hover), and a press does
|
||||
nothing. Tests: `tests/browser/workspace-images-delete.spec.mjs`.
|
||||
- **Trash backends** (`utils/trash.py`, no dependency): Linux and BSD implement the
|
||||
freedesktop Trash specification (home trash on the same mount, else the mount's
|
||||
`.Trash/$uid` or `.Trash-$uid`), reserve the name with `O_EXCL` and move with
|
||||
@@ -2513,8 +2598,8 @@ files to the system trash. **No code path unlinks or overwrites a media file.**
|
||||
("Moving files to the Recycle Bin isn't available on Windows: files larger than the bin's
|
||||
capacity would be deleted permanently."). Library-only removal works everywhere. Tests use `tests/fake_trash.py`; the
|
||||
browser test server installs it, so no test touches a real trash. The native proof,
|
||||
`tests/test_trash_macos_native.py`, runs only with `PM_NATIVE_TRASH=macos` in
|
||||
portability.yml's `native-trash` job (`macos-latest` and `macos-15-intel`): the startup
|
||||
`tests/test_trash_macos_native.py`, runs only with `PM_NATIVE_TRASH=macos` on a Mac, by hand
|
||||
(CI dropped its `native-trash` macOS job in #571; it last passed on `macos-latest` and `macos-15-intel`): the startup
|
||||
volume, a name clash, fresh APFS and FAT32 disk images, an image used as an output root,
|
||||
a read-only image and an unwritable folder (design and probe:
|
||||
`docs/egm/499-macos-trash/`, local).
|
||||
@@ -2888,6 +2973,14 @@ with its own negative, a self-pair, each drift, validation); `tests/test_library
|
||||
copy, the server after, archive-only, reset to Archive) and `library-delete-concept.spec.mjs`. A Python
|
||||
change: ComfyUI needs a restart.
|
||||
|
||||
### Pair node gives a new prompt its category and tags (#546)
|
||||
|
||||
A Pair save, by a run or the node's Save pair button, writes the category and tags to the positive prompt only when that
|
||||
save creates the prompt row (maintainer ruling). An existing prompt never changes on a run: empty, cleared or set by a person, including one first seen as
|
||||
another pair's negative. The negative stays empty, the saved pair keeps its own metadata, and `save_prompt_pair` writes
|
||||
no prompt metadata unless called with `fill_new_positive`. No backfill; optional repair is #550.
|
||||
Tests: `tests/test_prompt_pair_node.py`, `tests/test_api_pairs.py`, `tests/comfyui/pair-node.spec.mjs`. A Python change: ComfyUI needs a restart.
|
||||
|
||||
### Delete… on the archived shelf (#492)
|
||||
|
||||
Extends **D160** (coordinator ruling 2026-10-29, cutover item): the archived shelf's bar reads "N selected · Restore ·
|
||||
@@ -2928,6 +3021,29 @@ concept's danger "Archive…" maps to "Archive variant…" followed by a "Delete
|
||||
Tests: `tests/browser/workspace-library-delete.spec.mjs` (#481 cases), and `workspace-library-editor.spec.mjs` (the
|
||||
hand-off and the comparisons) and `library-editor-visual.spec.mjs` with the renamed button. JS only: no restart.
|
||||
|
||||
### Card tops, the Reusable tags window and System's width (#530)
|
||||
|
||||
Found in the maintainer's test of the cutover branch; each departs from the concept, whose files are not edited. The
|
||||
comparisons add `CONCEPT_LAYOUT_DEVIATIONS` (`concept-inventory.mjs`, four rules: card margins, System limit, tag header
|
||||
background and line) to the opened concept page, so the boxes that follow from them are still compared. The new spec pins the
|
||||
real values and that the unmapped concept keeps its 18px offset.
|
||||
|
||||
- **D182**: the second card of a `.two-col` starts level with the first. The concept's `.surface + .surface` 18px margin
|
||||
also applies inside its grid, which put Categories and Workspace preferences 18px lower. Stacked cards are now one 18px
|
||||
gap apart instead of 36px. A surface after a two-column row (Backup & export's Recovery policy) touched the row in the
|
||||
concept and on main; it now sits 18px below it.
|
||||
- **D183**: Reusable tags scrolls in its own window inside the card (`max(280px, 100dvh - 500px)`), so a long vocabulary no
|
||||
longer runs the page off screen. The column headers stick to the top of the window, with the surface background and a
|
||||
drawn line. Load more stays below the window; the end of the list inside the window (`.vocabulary-end`, observed with
|
||||
the window as root) appends the next page. A new search or sort returns the window to its top. Reaching Load more by
|
||||
keyboard no longer appends on its own; Enter does. A rename or merge reloads page one, so the window returns to its top.
|
||||
- **D184**: `.settings-layout` has no 1000px limit, so every System section uses the full content width, as the section
|
||||
nav and the other pages do.
|
||||
- **D185**: on touch and at 390px the same window stays shorter than the screen and overscroll chains to the page, so a
|
||||
swipe outside the window, or past its top, moves the page.
|
||||
|
||||
Measured in `workspace-layout-tags-system.spec.mjs`; the swipe case runs in the touch project.
|
||||
|
||||
### Prompt page heading on one row (#524)
|
||||
|
||||
**D164** (beyond the concept, which never has four heading actions beside a long title): from 951px, the concept's
|
||||
@@ -3396,15 +3512,20 @@ document title "Review" (the concept's), heading "Metadata review" with its line
|
||||
concept's sentence, then `.gallery-layout`: the queue's concept media cards
|
||||
(`image-card.mjs`, shared with Images; the image opens the shared viewer, Info selects
|
||||
the item, Tag opens the shared Tag image dialog with the review scope note) and
|
||||
`section.surface` "Resolve the association" (filename, Tag image, Sampler context,
|
||||
the chosen path's prompt blocks, Leave for later and primary Apply association). The
|
||||
`section.surface` "Resolve the association" (filename, Tag image, Link to a prompt…,
|
||||
Sampler context, the chosen path's prompt blocks, Leave for later, Leave unresolved and
|
||||
primary Apply association). The
|
||||
first item is selected on load, as the concept's first image is. Sampler context lists
|
||||
"Sampler <id> · <positive> / <negative>" per candidate (the server's label), disables
|
||||
unusable ones, preselects the first workable one and ends with "Leave unresolved"
|
||||
(rulings R2/R3); "Leave unresolved" shows the ambiguous status in both blocks. Apply
|
||||
unusable ones with the server's reason and preselects the first workable one
|
||||
(rulings R2/R3). Apply
|
||||
takes a fresh capture (`observe`) and posts the decision; Leave unresolved on a file
|
||||
under no current root posts without a capture. Both actions return to Images (R7); a
|
||||
recorded decision re-reads the row's count, as does every metadata scan that ends. A
|
||||
under no current root posts without a capture. Each decision and Leave for later opens
|
||||
the next item and the page stays (#536 overturns ruling R7); Leave for later writes
|
||||
nothing. Link to a prompt… (#536) records a Library prompt the reviewer chose,
|
||||
`POST /prompt_manager/review/items/{id}/link`: the capture guards of Apply, the same
|
||||
one-prompt-per-file rules, and link provenance `kind: "manual-link"` with no prompt
|
||||
sides, so it never reads as file evidence. A recorded decision re-reads the row's count, as does every metadata scan that ends. A
|
||||
refused decision keeps the selection and shows the server's sentence (the
|
||||
association conflict's explanation included) as `p.warning-note` in the panel; a
|
||||
restored storage or a changed queue reads the queue again; a request with no answer
|
||||
@@ -3419,6 +3540,14 @@ pending count (`GET /prompt_manager/review/items?limit=1`, its `total`).
|
||||
carries the concept's **Review metadata** (app.js a738 `go-review`): in its Activity
|
||||
item after the item's line, and in its job view's heading after Download report. The
|
||||
item's chip still names its state (D54), where the concept's says "Needs attention".
|
||||
- **D179** (#536, maintainer decision 2026-10-30): "Leave unresolved" is a button beside
|
||||
Leave for later, not the Sampler context select's last option; a disabled option names
|
||||
the server's reason.
|
||||
- **D180** (#536): with nothing to apply, the select, the prompt blocks and Apply are
|
||||
hidden; the panel gives the path count and each reason, and Link to a prompt… is primary.
|
||||
- **D181** (#536): the concept has no picker. Link to a prompt… opens the shared content
|
||||
dialog: Library search, the prompt's recipes as Negative prompt (one preselected,
|
||||
several must be chosen, always "No negative"), Cancel and Link image.
|
||||
- **D119**: the queue cards have no selection checkbox and
|
||||
no star (the page has no selection bar and the queue carries no favorite state); a
|
||||
file under no current root has an empty, `aria-disabled` image button and Tag; more
|
||||
@@ -3764,8 +3893,8 @@ workspace never asks; previews are a cache the server keeps:
|
||||
- `GET /images/serve/thumbnails/<rel>_thumb<ext>` for a missing preview is refused as
|
||||
text first (traversal, a backslash, a drive or stream colon, empty or dot segments, so
|
||||
`D:/x`, `\\server\share` and a leading `/` never reach a joined path), then admitted
|
||||
on the event loop (`_PreviewGate`): 3 previews are written at once on the API's own
|
||||
`pm-preview` pool (never the default executor), one flight runs per file (the history
|
||||
on the event loop (`_PreviewGate`): 3 previews are written at once on the API's
|
||||
interactive `pm-image` pool (never the default executor; #523), one flight runs per file (the history
|
||||
route shares the flight key and the admission), and at most 64 requests wait; one
|
||||
more gets its fallback at once without listing a folder. Inside the admission the
|
||||
preview maps back to its media file under the same root (exact names, then one folder
|
||||
@@ -3798,7 +3927,7 @@ workspace never asks; previews are a cache the server keeps:
|
||||
`exclusive` job runs (`_auto_thumbnail_blocker`), checked when a write starts (after
|
||||
its slot) and again just before its rename.
|
||||
- Shutdown is bounded and logged: the worker stops at a file boundary, ffmpeg is
|
||||
killed, an idle worker leaves (the next output starts a fresh one), the view pool
|
||||
killed, an idle worker leaves (the next output starts a fresh one), the image pool
|
||||
shuts down, and after the app's cleanup a late monitor event
|
||||
starts nothing. A package reload stops the workers the previous import left.
|
||||
- Cards and strips load previews only: Images in every layout and size, Library cards,
|
||||
@@ -3926,7 +4055,11 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati
|
||||
"Save preferences" is `aria-disabled` (opacity .55, `not-allowed`) while clean or
|
||||
saving; a setting the last save could not save gets a red outline. CSS under D92.
|
||||
- **D93** (Overview): real values. The metrics are the Library's eligible positives
|
||||
and active saved negatives and the indexed `generated_images` rows (Q2). "Database
|
||||
and active saved negatives and the Images count (#555, amending Q2's indexed
|
||||
`generated_images` rows): the shell's Images sidebar count, the Images page's own
|
||||
total, which also lists files no row links, as the concept's `images.length` is
|
||||
both. A visit re-reads it unless a read is in flight; "–" until read or after a
|
||||
failed read. `images.indexed` stays in the API and is not shown. "Database
|
||||
integrity" is "Not checked" until a check ran this session, then "N issues" (checks
|
||||
that found issues, even beside a check that could not run), "Passed" or "Incomplete".
|
||||
The review row is "N ambiguous image links" (the Needs review queue's pending count,
|
||||
@@ -3964,10 +4097,11 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati
|
||||
- **D96** (Statistics; amended by D153): "Usage statistics" without the sample note: the
|
||||
concept's "Runs by category" table without its Avg. rating column (Category / Runs)
|
||||
with the real runs of eligible parents per category ("Uncategorized"), then a second
|
||||
table "Library totals" (Total / Count: Prompts, Tags, Images from the legacy
|
||||
statistics; no Rated images). The API still reports `average_rating` and
|
||||
table "Database records" (Total / Count: Prompt records, Tags on prompts, Image
|
||||
records: the raw row counts of the legacy statistics, labelled as rows so they never
|
||||
read as the Library or Images page counts, #555; no Rated images). The API still reports `average_rating` and
|
||||
`rated_images`; the workspace does not show them.
|
||||
The Overview row says "Runs by category, with library totals." because
|
||||
The Overview row says "Runs by category, with database record counts." because
|
||||
that is what the dialog shows (the concept's own dialog shows only "Runs by
|
||||
category"); per-prompt run counts, prompt tags and dated runs are recorded, but no
|
||||
table of them exists yet. A failed read toasts "Statistics could not
|
||||
@@ -3992,6 +4126,23 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati
|
||||
defaults" ("Restore this browser’s display defaults, then save. Other settings are
|
||||
kept.", button "Reset"), which drafts the gallery's defaults for the browser rows only.
|
||||
The node search timeout shows the server's real value, not the sample 30. CSS under D97.
|
||||
- **D170** (#532, maintainer request 2026-10-30, beyond the concept): every viewer has
|
||||
`button#viewer-keys.icon-btn` right before the D156 view switch (accessible name and
|
||||
`title` "Keyboard shortcuts", a `command` outline icon, hidden at ≤700px with the
|
||||
switch). It opens `dialog#viewer-keys-dialog`, a modal built on demand inside the
|
||||
viewer, so a closed overlay adds nothing to the concept comparisons but the button:
|
||||
`.dialog-header` with × and the keys grouped as Move (← → Space), Image (+ - 0 R H),
|
||||
View (F I ? Esc) and File (D, Delete where the viewer has Delete…), one column at
|
||||
390px. Esc or × closes only the overlay and focus returns to the button; opening it
|
||||
stops the slideshow. ← and → navigate from anywhere in the viewer, on `document` as the
|
||||
legacy gallery does, unless a modifier is held, text is selected, another dialog is on
|
||||
top, or focus is in a video, form field, `pre`, `summary`, or anything in the details
|
||||
but a button or link. The other keys share that gate but allow Shift, and each clicks
|
||||
the toolbar's own control, so an aria-disabled, missing or hidden control does nothing
|
||||
(Rotate on a video, I when Centered, F at ≤700px, Delete without Delete…). `?` opens
|
||||
the overlay directly. Delete only opens the review. Space on a focused button or link
|
||||
activates it instead, and only + and - repeat. `workspace-viewer-keys.spec.mjs` pins
|
||||
this.
|
||||
- **D156** (#450, maintainer request 2026-10-28, beyond the concept): every viewer (the
|
||||
shared `dialog#viewer-dialog` of Images, Library, History, collections and Inspect) has
|
||||
`button#viewer-mode.icon-btn` in `.dialog-header`, right before ✕ (a direct child, so
|
||||
@@ -4235,6 +4386,41 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati
|
||||
makes the page see a restore run and finish deterministically. A refused file toasts "This file can't be restored: <validator reason>.". A
|
||||
review value may wrap anywhere (`#modal-body > dl.meta-grid dd`, CSS under D105), so a
|
||||
long file name keeps the concept's two equal columns at phone width.
|
||||
- **D173** (#535, amends D106; maintainer 2026-10-30): "always asks" now means asks when
|
||||
there is a choice. One eligible window is selected, and a selected window with exactly
|
||||
one eligible node selects that node; two or more nodes are never selected. A node whose
|
||||
requested field is linked is not offered (`linkedRoles` on each destination), and the
|
||||
status line says which case applies: linked, wrong roles, no node, or no window. It
|
||||
counts the other nodes that cannot receive, and the review names the node and window.
|
||||
- **D174** (#535): the window option shows the tab's current title, read at each discovery
|
||||
(a getter on the host options, which the Toolkit reads per discover), without the old
|
||||
"PromptManager —" prefix. Same-named windows get " (window 2)", numbered by host id so the
|
||||
number is stable while the tabs stay loaded. Every node option reads "#<id> <title>", so
|
||||
identical nodes can be told apart. Discovery waits 300 ms, as System does.
|
||||
- **D175** (#535, maintainer 2026-10-30): the concept README previews before applying;
|
||||
production makes Preview optional. Apply is enabled once a window and node are selected
|
||||
and requests the ticket itself, in one click. It stays closed after "Sent to ComfyUI.",
|
||||
so a double click sends once, and after an unknown outcome until a new preview or
|
||||
choice. The host's checks still refuse a linked field or a changed workflow, and the
|
||||
sender also refuses if the destination's `revision` (node object plus graph version)
|
||||
moved since discovery; it is undefined on an older tab, which skips the comparison. A host error reads as a refusal, not "may have applied".
|
||||
- **D191** (#542, beyond the concept; maintainer 2026-10-31, Q542-1 A to 5 A): a window with two or more
|
||||
eligible nodes adds a last Destination node option "All N eligible nodes" (`ALL_NODES` in
|
||||
`prompt-sender.mjs`), never selected for you (D173). Eligible is D173's filter; the nodes it leaves out
|
||||
stay counted in the discovery sentence. One window only. Preview stays optional (D175): Preview changes
|
||||
shows one `section.prompt-sender-node` per node, headed "#<id> <title>", in send order (node id,
|
||||
numeric). Apply previews every node first and writes nothing if any refuses ("#<id> <title>: <reason>
|
||||
Nothing was sent."). Then it sends one node after another. Only the first node of a pass taken in that
|
||||
click keeps its ticket; every other node, and every node of a held Preview (host tickets expire after
|
||||
30 s), takes a fresh ticket that is refused unless its Before texts and composition equal the reviewed
|
||||
ones. That fresh preview checks the discovered revision until something has been posted, and not after,
|
||||
since our own write may move the graph version. Refusals are best effort: "Sent to 2 of 3 nodes in
|
||||
<window>. #12 <title>: <host's refusal>."; Apply stays available and a retry sends to every node again
|
||||
(rewriting the same text). A lost reply stops the run, names that node as unknown and lists the nodes
|
||||
not sent. Closing the dialog stops after the current node. The review sections are labelled by their
|
||||
`h4` node heading. Unit tests in `tests/js/prompt-sender.test.js`, a fake three-node host in
|
||||
`tests/browser/prompt-inspector.spec.mjs`, real Classic and Nodes 2.0 widgets in
|
||||
`tests/comfyui/prompt-sender.spec.mjs`.
|
||||
- **D106** (Integrations, S10, Q8): the send bridge always asks for the ComfyUI window
|
||||
and node, and the nodes always follow ComfyUI's own renderer, so "Default send
|
||||
destination" offers only "Ask each time" and "Host compatibility" only "Follow host
|
||||
@@ -5298,7 +5484,7 @@ list stay unscoped: any row's control proves them.
|
||||
| Worker threads | `admin.html:437–445` | S4 Sources | covered | `sources` · slider · "Worker threads" | worker range max is cpu_count (workspace-system-sources.spec.mjs) |
|
||||
| LoraManager badge, enable, path, trigger words, CivitAI key, clear key | `admin.html:448–490`; `admin.js:1052–1156` | S10 Integrations | covered | `integrations` · switch · "LoraManager integration" | LoRA rows show detected / enabled / cache-loaded separately (workspace-system-integrations.spec.mjs) |
|
||||
| LoRA data import with progress | `admin.html:492–498,517–541`; `admin.js:1157` | S10 Integrations | covered | `integrations` · button · "Review import" | Review import shows replacement, blocked items and network effects with the backup box; Start opens the job (D107) (workspace-system-integrations.spec.mjs) |
|
||||
| Statistics | `admin.html:159`; `admin.js:1627` | S2 Overview | covered | `overview` · button · "Explore" | statistics dialog lists runs by category and library totals (workspace-system.spec.mjs) |
|
||||
| Statistics | `admin.html:159`; `admin.js:1627` | S2 Overview | covered | `overview` · button · "Explore" | statistics dialog lists runs by category and database record counts (workspace-system.spec.mjs) |
|
||||
| Export components JSON `/export` | `admin.html:163`; `admin.js:1607`; `py/api/prompts.py:238` | S9 Backup & export | covered | `backups` · button · "Export all prompts JSON" | Export JSON downloads /library/export; secondary buttons download all prompts (/export) and /pairs/export (D103) (workspace-system-backups.spec.mjs) |
|
||||
| Saved pairs export | `py/api/pairs.py:84` | S9 Backup & export | covered | `backups` · button · "Export saved pairs JSON" | Export JSON downloads /library/export; secondary buttons download all prompts (/export) and /pairs/export (D103) (workspace-system-backups.spec.mjs) |
|
||||
| Library export | `py/api/library.py:223` | S9 Backup & export | covered | `backups` · button · "Export JSON" | Export JSON downloads /library/export; secondary buttons download all prompts (/export) and /pairs/export (D103) (workspace-system-backups.spec.mjs) |
|
||||
@@ -5537,6 +5723,73 @@ are closed before `os.replace`; renames stay inside one folder; folder identity
|
||||
folder are refused. The browser test server closes its own log handlers before its temp
|
||||
root (which now holds the logs) is removed.
|
||||
|
||||
### Thumbnail location contract (#320)
|
||||
|
||||
Previews live in each source's `thumbnails/` (the default) or in one folder, `<folder>/<source key>/…`. Every reader,
|
||||
writer and remover asks `py/api/preview_store.py`; `tests/test_preview_store.py` fails on a `thumbnails` path join
|
||||
anywhere else. The setting is `thumbnails.location` in config.json, and only the Move thumbnails job writes it: POST
|
||||
`/settings` answers 409 `location-needs-move`.
|
||||
|
||||
- **Keys and marker.** A key is a slug plus a hash of the source's identity. The folder's
|
||||
`.promptmanager-thumbnails.json` stores each key with its identity and install. Only a single `<slug>-<hex>` name
|
||||
is ever joined onto the folder, and anything else in a marker is dropped. Every write and delete passes
|
||||
`preview_store.may_write`: below a key folder, with no link, junction or reparse point on the way
|
||||
(`tests/test_preview_store_folder.py`, including tampered markers).
|
||||
- **Unavailable.** A folder is unavailable when it is missing, is a link, holds no marker or holds another
|
||||
folder's marker. A probe checks it every 5 s, and a store call that fails or stalls marks it at once. While it is
|
||||
unavailable:
|
||||
- originals are served and nothing is written anywhere;
|
||||
- generate, convert, clear and prune answer 409 `location-unavailable`;
|
||||
- the folder is never made again except by Recreate folder.
|
||||
|
||||
The configured location is in force before any request. A bad value is `invalid`, never a fall-back to the
|
||||
sources.
|
||||
- **Move thumbnails.** The job is a `thumbnails` job with `scope.mode` `move` (`tests/test_thumbnail_move.py`). It
|
||||
runs in this order:
|
||||
1. The review answers `POST /images/thumbnails/plan`; not enough space answers 507.
|
||||
2. A token starts the job.
|
||||
3. Automatic previews are held.
|
||||
4. Copies run on the job pool in chunks of 2 or 4 files, each with its size checked and its mtime rounded up to
|
||||
2 s.
|
||||
5. One config commit makes the switch and records the old stores under `cleanup`. The job cannot be cancelled
|
||||
from here.
|
||||
6. The old copies are removed.
|
||||
|
||||
If it fails or is cancelled before the switch, nothing changes. A crash leaves
|
||||
`<data>/data/thumbnail-move.json`, which the next start undoes. Old copies still held open are removed by the
|
||||
next start's "Move thumbnails (finishing)" job. Leaving a folder also removes the previews of this install's
|
||||
sources that are no longer listed (Q320-8).
|
||||
- **Remove thumbnails of removed sources** (`scope.mode` `prune`). It is offered in Thumbnail maintenance only in
|
||||
folder mode, and it touches only this install's keys.
|
||||
- **Page tests.** `tests/browser/workspace-thumbnail-location.spec.mjs` covers the row, both moves, the in-place
|
||||
refusal, the confirmation, the notice with Recreate and with Use this folder. The legacy gallery runs once in
|
||||
folder mode (`workflows.spec.mjs`). The hooks are `tests/browser/thumbnail_location_fixture.py`.
|
||||
|
||||
Beyond the concept, against the approved static mock `docs/egm/post-3.4.1/320-mock`:
|
||||
|
||||
- D196: the Thumbnail location row in System › Sources & scans, after Thumbnail generation.
|
||||
- The status line is "Now: inside each source." or "Now: one folder, <path>.". It does not show the mock's
|
||||
"(3 sources, 8,512 thumbnails)" counts, because counting means walking every preview on each view. It says
|
||||
"Using the default folder next to your library." and names old copies that wait to be removed.
|
||||
- D197: the Move thumbnails review uses the shared review.
|
||||
- A grid value may be a lead sentence with a list (`[lead, items]`).
|
||||
- Sources no longer listed appear under "Removed after the move", where the mock has a "Not moved" row
|
||||
(maintainer answer to Q320-8: remove).
|
||||
- The Free space line is "<size> free", without naming the disk.
|
||||
- D198: Activity's view of `mode: move` and `mode: prune`.
|
||||
- Tiles for a move: Moved, Already there, Left in place, Errors. Tiles for a prune: Removed, Left in place,
|
||||
Errors.
|
||||
- The step line under the bar is `#job-phase`.
|
||||
- Cancel shows only until the switch.
|
||||
- D199: the unavailable notice on Overview and Sources & scans.
|
||||
- Recreate folder appears for a missing folder, after a confirmation.
|
||||
- Use this folder appears for another folder's marker.
|
||||
- Change location focuses the row; on Overview it opens Sources & scans.
|
||||
- D200: Thumbnail maintenance's "Remove thumbnails of removed sources" scope, with the plan line and a grid of
|
||||
key folders.
|
||||
- D201: Move library's review adds "Thumbnails: Stay in <path>. Move them in System › Sources & scans." in folder
|
||||
mode.
|
||||
|
||||
### Backup and restore contract (#249 System S8)
|
||||
|
||||
A restore is staged, then published (`database/restore_stage.py`). The upload is
|
||||
@@ -5626,6 +5879,23 @@ npm run --prefix tools/vendor test:workflows -- workspace-system-backups.spec.mj
|
||||
--project chromium --project touch --project firefox --project webkit
|
||||
```
|
||||
|
||||
## Sticky chrome (#531)
|
||||
|
||||
Beyond the concept, from the maintainer's test of #528; the concept is unchanged. `workspace-sticky-chrome.spec.mjs` holds these.
|
||||
|
||||
- **D186:** the selection bars (Images `#bulkbar`, Library `#library-bulkbar`; there are no others) are sticky: under the
|
||||
top bar at the top of the page, and at the top of the window once that non-sticky top bar has scrolled away (one rule
|
||||
in workspace.css). While the bar's flow place is above the window, its appearing, resizing or leaving does not move the cards
|
||||
(`keepingPlace`). Focused card controls and the Images panel keep clear of it (`--selection-bar-block`). At 700px and below
|
||||
the bar is one row that scrolls sideways. After a bulk action finishes the page scrolls to the top and refreshes the
|
||||
list; Set category, Delete… and Return to library clear the selection as before, while tagging and collections keep
|
||||
it (D85, D138). Export metadata changes nothing, so it stays put. Single-image actions from the inspector and viewer stay put.
|
||||
- **D187:** Back to top: one shell chip past 200px of scroll, above the toast's spot; it rises only for a toast taller than two lines. It never animates, because the
|
||||
pages' height corrections would cancel a smooth scroll, and focus moves to `#workspace-main`. D42's band grows to 140px.
|
||||
- **D188:** the Needs review panel is sticky like the Images panel, with its own scroll on a window under 720px tall, and static at 700px and below.
|
||||
- **D189:** where a panel is one column (static): Info brings the panel into view. On
|
||||
Images the panel follows the whole list, past infinite scroll, so loading waits for the reader's next move (wheel, touch or key) rather than pushing the panel away.
|
||||
|
||||
## Workspace data fields for the concept pages (#249)
|
||||
|
||||
These fields are additive. The legacy `/prompt_manager/images/output` listing (no
|
||||
@@ -6665,7 +6935,7 @@ checkout or point this harness at a live ComfyUI checkout.
|
||||
|
||||
The package-qualified config import also corrects #211: the configured database
|
||||
path is honored when PromptManager runs as a ComfyUI custom node. Follow the
|
||||
[database path compatibility and safe backup sequence](README.md#database-path-compatibility-211)
|
||||
[database path compatibility and safe backup sequence](README-legacy.md#database-path-compatibility-211)
|
||||
before restarting an existing deployment.
|
||||
|
||||
The ledger records only new dequeued jobs and observed outcomes. Existing
|
||||
|
||||
@@ -92,10 +92,21 @@ try:
|
||||
routes = config.routes
|
||||
api = PromptManagerAPI()
|
||||
api.add_routes(routes)
|
||||
# Temp files of preview writes a crash interrupted (#317), off the
|
||||
# import path: the walk may cover a large thumbnails folder.
|
||||
|
||||
# The thumbnail location's startup check (#320), then the temp files of
|
||||
# preview writes a crash interrupted (#317), off the import path: the
|
||||
# walk may cover a large thumbnails folder.
|
||||
# #320: the configured thumbnail location is in force before any request
|
||||
# (no I/O); the thread below checks it.
|
||||
api.use_preview_location()
|
||||
|
||||
def _preview_startup():
|
||||
api.start_preview_location()
|
||||
api.recover_thumbnail_move() # an interrupted move, before the sweep
|
||||
api.sweep_thumbnail_temps()
|
||||
|
||||
threading.Thread(
|
||||
target=api.sweep_thumbnail_temps,
|
||||
target=_preview_startup,
|
||||
name="PromptManagerPreviewTempSweep",
|
||||
daemon=True,
|
||||
).start()
|
||||
@@ -108,6 +119,8 @@ try:
|
||||
# Build each output root's Images listing once the server's loop runs,
|
||||
# so the first open does not wait for the walk (#348).
|
||||
host_app.on_startup.append(api.warm_gallery_listings)
|
||||
# #320: old thumbnails a move left to remove, once it is checked.
|
||||
host_app.on_startup.append(api.finish_thumbnail_move)
|
||||
if host_app is not None and hasattr(host_app, "on_shutdown"):
|
||||
# Open Needs review change streams end before the server waits on them.
|
||||
host_app.on_shutdown.append(api.close_review_events)
|
||||
@@ -120,6 +133,7 @@ except Exception as e:
|
||||
logger = get_logger("prompt_manager.init")
|
||||
logger.error(f"Failed to register API routes: {e}")
|
||||
except Exception:
|
||||
# logging failed too; a report must never stop the node loading
|
||||
pass
|
||||
|
||||
# Shared database, set up with the monitoring system below. Stays None when that
|
||||
@@ -279,11 +293,11 @@ def _teardown_logger():
|
||||
|
||||
|
||||
def _stop_background_work():
|
||||
"""Stop the image monitor, the preview worker and the prompt cleanup
|
||||
thread against one TEARDOWN_SECONDS deadline, once.
|
||||
"""Stop the image monitor, the preview worker, the job worker threads
|
||||
and the prompt cleanup thread against one TEARDOWN_SECONDS deadline, once.
|
||||
|
||||
Returns ``{"monitor", "thumbnails", "tracker"}`` booleans (True: it
|
||||
stopped), leaving out parts that never started. Later calls return a
|
||||
Returns ``{"monitor", "thumbnails", "workers", "tracker"}`` booleans
|
||||
(True: it stopped), leaving out parts that never started. Later calls return a
|
||||
copy of the first result. A Ctrl+C inside a step is logged and the
|
||||
step is False; one between steps is re-raised after the result (parts
|
||||
not reached are False) is stored and the ``atexit`` entry removed.
|
||||
@@ -297,11 +311,14 @@ def _stop_background_work():
|
||||
api,
|
||||
_global_prompt_tracker,
|
||||
)
|
||||
from .utils import parallel
|
||||
|
||||
# A part whose global is set has started; False until its step says
|
||||
# otherwise, so an interrupt between steps leaves an honest result.
|
||||
started = (
|
||||
("monitor", monitor),
|
||||
("thumbnails", thumbnails),
|
||||
("workers", parallel.job_pool_started() or None),
|
||||
("tracker", tracker),
|
||||
)
|
||||
result = {name: False for name, part in started if part is not None}
|
||||
@@ -344,6 +361,16 @@ def _stop_background_work():
|
||||
result["thumbnails"] = _teardown_step(
|
||||
"the preview worker", close_previews, logger
|
||||
)
|
||||
if "workers" in result:
|
||||
|
||||
def stop_workers():
|
||||
from .utils.parallel import JOB_POOL_JOIN_SECONDS
|
||||
|
||||
return parallel.shutdown_job_pool(left(JOB_POOL_JOIN_SECONDS))
|
||||
|
||||
result["workers"] = _teardown_step(
|
||||
"the job worker threads", stop_workers, logger
|
||||
)
|
||||
if tracker is not None:
|
||||
|
||||
def stop_tracker():
|
||||
@@ -411,6 +438,7 @@ except Exception as e:
|
||||
f"Failed to register the exit signal: {e}"
|
||||
)
|
||||
except Exception:
|
||||
# logging failed too; a report must never stop the node loading
|
||||
pass
|
||||
|
||||
try:
|
||||
@@ -428,16 +456,14 @@ except Exception as e:
|
||||
f"Failed to register the background work teardown: {e}"
|
||||
)
|
||||
except Exception:
|
||||
# logging failed too; a report must never stop the node loading
|
||||
pass
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
|
||||
|
||||
# Print startup message with loaded tools
|
||||
print()
|
||||
print(f"\033[94m[ComfyUI-PromptManager] Version:\033[0m {get_version()}")
|
||||
for node_key, display_name in NODE_DISPLAY_NAME_MAPPINGS.items():
|
||||
# ASCII only: a Windows console using a legacy code page cannot print emoji,
|
||||
# and a UnicodeEncodeError here would abort loading the whole node.
|
||||
print(f" \033[94mLoaded:\033[0m {display_name}")
|
||||
print(f"\033[94mTotal: {len(NODE_CLASS_MAPPINGS)} tools loaded\033[0m")
|
||||
print()
|
||||
# One startup line. ASCII only: a Windows console using a legacy code page
|
||||
# cannot print emoji, and a UnicodeEncodeError here would abort loading the node.
|
||||
print(
|
||||
f"\033[94m[ComfyUI-PromptManager] Version:\033[0m {get_version()}"
|
||||
f" - {len(NODE_CLASS_MAPPINGS)} tools loaded"
|
||||
)
|
||||
|
||||
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 153 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 237 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 110 KiB |
@@ -78,6 +78,10 @@ MESSAGES = {
|
||||
),
|
||||
"same-folder": "The new folder is the node folder itself.",
|
||||
"target-invalid": "The new folder cannot hold the library.",
|
||||
"data-dir-served": (
|
||||
"The new folder is inside Prompt Manager's thumbnail folder. Nothing was "
|
||||
"moved."
|
||||
),
|
||||
"target-has-library": (
|
||||
"The new folder already holds a Prompt Manager library. Nothing was moved."
|
||||
),
|
||||
@@ -590,6 +594,7 @@ class _Move:
|
||||
try:
|
||||
os.rmdir(self.plan.target_dir)
|
||||
except OSError:
|
||||
# not empty or already gone: the undo leaves it alone
|
||||
pass
|
||||
|
||||
|
||||
|
||||
@@ -35,38 +35,51 @@ from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import operator
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import weakref
|
||||
from typing import Iterable, Iterator, List, Optional
|
||||
from collections import deque
|
||||
from typing import Any, Callable, Iterable, Iterator, List, Optional
|
||||
|
||||
import sqlite3
|
||||
|
||||
CLOSED_MESSAGE = "Cannot operate on a closed database."
|
||||
# Rows fetched per step when a cursor is iterated: the connection is marked
|
||||
# in use once per chunk instead of once per row (iteration costs about what
|
||||
# a plain cursor costs). A loop that stops early leaves up to this many rows
|
||||
# fetched; ``fetchone``/``fetchmany`` after a partial loop skip them.
|
||||
# a plain cursor costs). Prefetched rows belong to the cursor so iteration
|
||||
# and explicit fetches always advance the same position.
|
||||
ITER_CHUNK = 128
|
||||
|
||||
|
||||
class ManagedCursor(sqlite3.Cursor):
|
||||
"""A cursor whose calls into SQLite count as its connection being in use."""
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self._pending = deque()
|
||||
self._closed = False
|
||||
|
||||
def execute(self, sql, parameters=(), /):
|
||||
conn = self.connection
|
||||
conn._enter()
|
||||
try:
|
||||
return super().execute(sql, parameters)
|
||||
finally:
|
||||
conn._leave()
|
||||
return self._run_statement(super().execute, sql, parameters)
|
||||
|
||||
def executemany(self, sql, seq_of_parameters, /):
|
||||
return self._run_statement(super().executemany, sql, seq_of_parameters)
|
||||
|
||||
def _run_statement(self, operation: Callable, *args: Any) -> ManagedCursor:
|
||||
conn = self.connection
|
||||
conn._enter()
|
||||
description = self.description
|
||||
completed = False
|
||||
try:
|
||||
return super().executemany(sql, seq_of_parameters)
|
||||
result = operation(*args)
|
||||
completed = True
|
||||
return result
|
||||
finally:
|
||||
# Argument parsing can fail before SQLite replaces the old statement.
|
||||
if completed or self.description is not description:
|
||||
self._pending.clear()
|
||||
conn._leave()
|
||||
|
||||
def executescript(self, sql_script, /):
|
||||
@@ -81,6 +94,8 @@ class ManagedCursor(sqlite3.Cursor):
|
||||
conn = self.connection
|
||||
conn._enter()
|
||||
try:
|
||||
if self._pending and not self._closed:
|
||||
return self._pending.popleft()
|
||||
return super().fetchone()
|
||||
finally:
|
||||
conn._leave()
|
||||
@@ -89,7 +104,20 @@ class ManagedCursor(sqlite3.Cursor):
|
||||
conn = self.connection
|
||||
conn._enter()
|
||||
try:
|
||||
return super().fetchmany(*args, **kwargs)
|
||||
# Let SQLite validate the original arguments, including version-specific
|
||||
# handling of zero/negative sizes, before touching prefetched rows.
|
||||
rows = super().fetchmany(*args, **kwargs)
|
||||
if not self._pending:
|
||||
return rows
|
||||
size = operator.index(
|
||||
kwargs.get("size", args[0] if args else self.arraysize)
|
||||
)
|
||||
# Python 3.12 reads all rows for size=0; 3.13+ reads none.
|
||||
if size == 0 and sys.version_info >= (3, 13):
|
||||
return rows
|
||||
self._pending.extend(rows)
|
||||
count = len(self._pending) if size <= 0 else min(size, len(self._pending))
|
||||
return [self._pending.popleft() for _ in range(count)]
|
||||
finally:
|
||||
conn._leave()
|
||||
|
||||
@@ -97,25 +125,31 @@ class ManagedCursor(sqlite3.Cursor):
|
||||
conn = self.connection
|
||||
conn._enter()
|
||||
try:
|
||||
return super().fetchall()
|
||||
rows = super().fetchall()
|
||||
pending = list(self._pending)
|
||||
self._pending.clear()
|
||||
return pending + rows
|
||||
finally:
|
||||
conn._leave()
|
||||
|
||||
def __iter__(self) -> Iterator:
|
||||
return self._chunks()
|
||||
|
||||
def _chunks(self) -> Iterator:
|
||||
while True:
|
||||
rows = self.fetchmany(ITER_CHUNK)
|
||||
if not rows:
|
||||
return
|
||||
yield from rows
|
||||
return self
|
||||
|
||||
def __next__(self):
|
||||
conn = self.connection
|
||||
pending = self._pending
|
||||
# Buffered Python rows need no SQLite lock, but retirement and cursor
|
||||
# close must still stop an iterator obtained before either operation.
|
||||
if conn._retired or self._closed:
|
||||
raise sqlite3.ProgrammingError(CLOSED_MESSAGE)
|
||||
if pending:
|
||||
return pending.popleft()
|
||||
conn._enter()
|
||||
try:
|
||||
return super().__next__()
|
||||
pending.extend(super().fetchmany(ITER_CHUNK))
|
||||
if not pending:
|
||||
raise StopIteration
|
||||
return pending.popleft()
|
||||
finally:
|
||||
conn._leave()
|
||||
|
||||
@@ -126,7 +160,10 @@ class ManagedCursor(sqlite3.Cursor):
|
||||
except sqlite3.ProgrammingError:
|
||||
return super().close() # the connection is closed or closing
|
||||
try:
|
||||
return super().close()
|
||||
result = super().close()
|
||||
self._pending = deque()
|
||||
self._closed = True
|
||||
return result
|
||||
finally:
|
||||
conn._leave()
|
||||
|
||||
@@ -293,6 +330,9 @@ class ManagedConnection(sqlite3.Connection):
|
||||
cursors = list(self._cursors)
|
||||
try:
|
||||
for cursor in cursors:
|
||||
if isinstance(cursor, ManagedCursor):
|
||||
cursor._pending = deque()
|
||||
cursor._closed = True
|
||||
with contextlib.suppress(sqlite3.Error):
|
||||
sqlite3.Cursor.close(cursor)
|
||||
super().close()
|
||||
|
||||
@@ -34,7 +34,7 @@ from .history_reads import HistoryReadOperations
|
||||
from .health import HealthOperations, iter_consistency_issues
|
||||
from .duplicate_copies import DuplicateCopyOperations
|
||||
from .workflow_storage import WorkflowStorageOperations
|
||||
from .category_import import CategoryImportOperations, EmptyReplacement # noqa: F401
|
||||
from .category_import import CategoryImportOperations
|
||||
from .image_categories import ImageCategoryOperations
|
||||
from .workflow_documents import (
|
||||
DocumentReader,
|
||||
|
||||
@@ -112,8 +112,14 @@ class PairOperations:
|
||||
tags: Optional[List[str]] = None,
|
||||
rating: Optional[int] = None,
|
||||
notes: Optional[str] = None,
|
||||
fill_new_positive: bool = False,
|
||||
) -> int:
|
||||
"""Save an ordered pair atomically; repeat saves preserve its metadata."""
|
||||
"""Save an ordered pair atomically; repeat saves preserve its metadata.
|
||||
|
||||
``fill_new_positive`` also gives the positive prompt the category and
|
||||
tags, only if this save inserted its row (#546). An existing prompt
|
||||
never changes, so a cleared category stays cleared.
|
||||
"""
|
||||
with self._pair_transaction() as conn:
|
||||
return self._save_pair(
|
||||
conn,
|
||||
@@ -124,6 +130,7 @@ class PairOperations:
|
||||
tags=tags,
|
||||
rating=rating,
|
||||
notes=notes,
|
||||
fill_new_positive=fill_new_positive,
|
||||
)
|
||||
|
||||
def _save_pair(
|
||||
@@ -137,6 +144,7 @@ class PairOperations:
|
||||
tags: Optional[List[str]] = None,
|
||||
rating: Optional[int] = None,
|
||||
notes: Optional[str] = None,
|
||||
fill_new_positive: bool = False,
|
||||
) -> int:
|
||||
"""Save an ordered pair atomically; repeat saves preserve its metadata."""
|
||||
if not isinstance(positive_text, str) or not positive_text.strip():
|
||||
@@ -144,8 +152,13 @@ class PairOperations:
|
||||
if not isinstance(negative_text, str):
|
||||
raise ValueError("Negative prompt text must be a string; use '' for empty")
|
||||
# Validate metadata before any component insertion.
|
||||
_validate_metadata(title, category, tags, rating, notes)
|
||||
positive = self._pair_component(conn, positive_text)
|
||||
tags = _validate_metadata(title, category, tags, rating, notes)
|
||||
positive, created = self._pair_component_outcome(conn, positive_text)
|
||||
if created and fill_new_positive:
|
||||
conn.execute(
|
||||
"UPDATE prompts SET category = ? WHERE id = ?", (category, positive)
|
||||
)
|
||||
self._sync_prompt_tags(conn, positive, tags or [])
|
||||
negative = (
|
||||
self._pair_component(conn, negative_text) if negative_text.strip() else None
|
||||
)
|
||||
|
||||
@@ -5,8 +5,12 @@ A decision is one row of ``media_review_decisions`` for one item revision.
|
||||
ingester's compatible-only rules (``_ingest_resolved_pair_in``) in the same
|
||||
``_pair_transaction`` as the decision row, the item's new state and revision
|
||||
and the review index revision; any conflict or later failure rolls all of it
|
||||
back. ``unresolved`` records the decision and links nothing. No decision
|
||||
counts a run, touches usage or calls an execution writer.
|
||||
back. ``manual`` (#536) is the same but for a Library prompt the reviewer chose:
|
||||
its link's provenance names a manual choice and carries no prompt sides, so it
|
||||
cannot pose as file evidence, and the decision's ``candidate_hash`` (the table
|
||||
requires one) is the digest of that choice. ``unresolved`` records the decision
|
||||
and links nothing. No decision counts a run, touches usage or calls an execution
|
||||
writer.
|
||||
|
||||
Callers verify the file first (the capture must be the item's bytes and the
|
||||
current file must still hash to them); nothing here reads the filesystem.
|
||||
@@ -17,6 +21,7 @@ import sqlite3
|
||||
|
||||
from .annotation_schema import BYTES_TABLE
|
||||
from .annotations import _new_id
|
||||
from .library import _ELIGIBLE, _FROM, SHELVED
|
||||
from .library_commands import LibraryCommandError
|
||||
from .models import utc_now_iso
|
||||
from .review import (
|
||||
@@ -30,9 +35,28 @@ from .review import (
|
||||
same_name_rows,
|
||||
)
|
||||
from .review_changes import notify_review_change
|
||||
from .review_schema import DECISIONS_TABLE, ITEMS_TABLE, canonical_evidence
|
||||
from .review_schema import (
|
||||
DECISIONS_TABLE,
|
||||
ITEMS_TABLE,
|
||||
canonical_evidence,
|
||||
evidence_digest,
|
||||
)
|
||||
|
||||
DECISION_KIND = "user-selection"
|
||||
MANUAL_KIND = "manual-link"
|
||||
_MANUAL_POSITIVE = (
|
||||
f"SELECT p.id,p.text FROM {_FROM} " # nosec B608
|
||||
f"WHERE p.id=? AND {_ELIGIBLE} AND {SHELVED}"
|
||||
)
|
||||
_MANUAL_PAIR = (
|
||||
"SELECT COALESCE(n.text,'') AS negative FROM prompt_pairs pp "
|
||||
"LEFT JOIN prompts n ON n.id=pp.negative_prompt_id "
|
||||
"WHERE pp.id=? AND pp.positive_prompt_id=? AND pp.archived=0"
|
||||
)
|
||||
_ARCHIVED_BARE_PAIR = (
|
||||
"SELECT 1 FROM prompt_pairs WHERE positive_prompt_id=? "
|
||||
"AND negative_prompt_id IS NULL AND archived=1"
|
||||
)
|
||||
_INSERT_DECISION = (
|
||||
f"INSERT INTO {DECISIONS_TABLE}(id,item_id,decision_revision," # nosec B608
|
||||
"created_at,outcome,candidate_hash,evidence,pair_id,image_id) "
|
||||
@@ -59,6 +83,8 @@ class ReviewCommandError(LibraryCommandError):
|
||||
``fingerprint`` the current dependency fingerprint, when known;
|
||||
``changed`` marks an association conflict caused by links that changed
|
||||
since the reviewer read them (rather than an incompatible choice).
|
||||
``prompt-missing``: the Library prompt or recipe of a manual link is gone,
|
||||
archived or not that prompt's.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -93,6 +119,7 @@ class ReviewDecisionOperations:
|
||||
sha256: str,
|
||||
outcome: str,
|
||||
candidate: dict | None = None,
|
||||
manual: dict | None = None,
|
||||
image_path: str | None = None,
|
||||
metadata: dict | None = None,
|
||||
expected_fingerprint: str | None = None,
|
||||
@@ -101,7 +128,9 @@ class ReviewDecisionOperations:
|
||||
) -> dict:
|
||||
"""Record the decision; return ``{item, total, pair}``.
|
||||
|
||||
``selected`` needs the evidence ``candidate`` (with its ``hash``), the
|
||||
``selected`` needs the evidence ``candidate`` (with its ``hash``) or
|
||||
``manual`` (``{positive_id, pair_id}``; a null ``pair_id`` is no
|
||||
negative), the
|
||||
``image_path`` the scan and the monitor link under, the captured
|
||||
``metadata`` and the ``expected_fingerprint`` the reviewer saw.
|
||||
``aliases`` are the other spellings legacy rows of the same physical
|
||||
@@ -115,9 +144,12 @@ class ReviewDecisionOperations:
|
||||
if outcome not in ("selected", "unresolved"):
|
||||
raise ValueError("outcome must be selected or unresolved")
|
||||
if outcome == "selected" and (
|
||||
not isinstance(candidate, dict) or not image_path
|
||||
(not isinstance(candidate, dict)) == (not isinstance(manual, dict))
|
||||
or not image_path
|
||||
):
|
||||
raise ValueError("A selected decision needs a candidate and a file")
|
||||
raise ValueError(
|
||||
"A selected decision needs one candidate or manual choice and a file"
|
||||
)
|
||||
try:
|
||||
with self._pair_transaction() as conn:
|
||||
decided = self._decide_in(
|
||||
@@ -128,6 +160,7 @@ class ReviewDecisionOperations:
|
||||
sha256=sha256,
|
||||
outcome=outcome,
|
||||
candidate=candidate,
|
||||
manual=manual,
|
||||
image_path=image_path,
|
||||
metadata=metadata or {},
|
||||
expected_fingerprint=expected_fingerprint,
|
||||
@@ -158,13 +191,24 @@ class ReviewDecisionOperations:
|
||||
raise _Rollback(ReviewCommandError("file-changed", epoch))
|
||||
decision_id = _new_id()
|
||||
pair = None
|
||||
candidate = command["candidate"] if command["outcome"] == "selected" else None
|
||||
selected = command["outcome"] == "selected"
|
||||
candidate = command["candidate"] if selected else None
|
||||
manual = command["manual"] if selected else None
|
||||
candidate_hash = candidate["hash"] if candidate is not None else None
|
||||
if candidate is not None:
|
||||
pair = self._apply_candidate(
|
||||
conn, epoch, current, decision_id, total, **command
|
||||
)
|
||||
elif manual is not None:
|
||||
pair = self._apply_manual(
|
||||
conn, epoch, current, decision_id, total, **command
|
||||
)
|
||||
manual = {"positive_id": manual["positive_id"], "pair_id": pair["id"]}
|
||||
candidate_hash = evidence_digest(
|
||||
canonical_evidence({"kind": MANUAL_KIND, **manual})
|
||||
)
|
||||
evidence = {
|
||||
"kind": DECISION_KIND,
|
||||
"kind": MANUAL_KIND if manual is not None else DECISION_KIND,
|
||||
"decision_id": decision_id,
|
||||
"outcome": command["outcome"],
|
||||
"reviewed_digest": command["sha256"],
|
||||
@@ -174,6 +218,8 @@ class ReviewDecisionOperations:
|
||||
"dependency_fingerprint": command["expected_fingerprint"],
|
||||
"pair": pair,
|
||||
}
|
||||
if manual is not None:
|
||||
evidence["manual"] = manual
|
||||
now = utc_now_iso()
|
||||
self._insert_review_decision(
|
||||
conn,
|
||||
@@ -183,7 +229,7 @@ class ReviewDecisionOperations:
|
||||
current["revision"] + 1,
|
||||
now,
|
||||
command["outcome"],
|
||||
candidate["hash"] if candidate is not None else None,
|
||||
candidate_hash,
|
||||
canonical_evidence(evidence),
|
||||
pair["id"] if pair else None,
|
||||
pair["image_id"] if pair else None,
|
||||
@@ -204,6 +250,77 @@ class ReviewDecisionOperations:
|
||||
self, conn, epoch, current, decision_id, total, **command
|
||||
) -> dict:
|
||||
"""Link the candidate's pair, compatible-only; raise on any conflict."""
|
||||
candidate = command["candidate"]
|
||||
positive, negative = candidate["positive"], candidate["negative"]
|
||||
provenance = {
|
||||
"kind": DECISION_KIND,
|
||||
"decision_id": decision_id,
|
||||
"reviewed_digest": command["sha256"],
|
||||
"candidate_hash": candidate["hash"],
|
||||
"resolver_version": current["resolver_version"],
|
||||
"positive": positive,
|
||||
"negative": negative,
|
||||
}
|
||||
return self._link_compatible(
|
||||
conn, epoch, current, total, positive, negative, provenance, command
|
||||
)
|
||||
|
||||
def _apply_manual(self, conn, epoch, current, decision_id, total, **command):
|
||||
"""Link the chosen Library prompt, compatible-only; raise otherwise."""
|
||||
missing = _Rollback(
|
||||
ReviewCommandError("prompt-missing", epoch, item=current, total=total)
|
||||
)
|
||||
positive_id = command["manual"]["positive_id"]
|
||||
pair_id = command["manual"]["pair_id"]
|
||||
row = conn.execute(_MANUAL_POSITIVE, (positive_id,)).fetchone()
|
||||
if row is None:
|
||||
raise missing
|
||||
positive_text, negative_text = row[1], ""
|
||||
if pair_id is not None:
|
||||
chosen = conn.execute(_MANUAL_PAIR, (pair_id, positive_id)).fetchone()
|
||||
if chosen is None:
|
||||
raise missing
|
||||
negative_text = chosen[0]
|
||||
elif conn.execute(_ARCHIVED_BARE_PAIR, (positive_id,)).fetchone():
|
||||
raise missing
|
||||
provenance = {
|
||||
"kind": MANUAL_KIND,
|
||||
"decision_id": decision_id,
|
||||
"reviewed_digest": command["sha256"],
|
||||
"resolver_version": current["resolver_version"],
|
||||
"positive_id": positive_id,
|
||||
"pair_id": pair_id,
|
||||
}
|
||||
# The sides name no provenance: only the ingester reads them.
|
||||
pair = self._link_compatible(
|
||||
conn,
|
||||
epoch,
|
||||
current,
|
||||
total,
|
||||
{"text": positive_text},
|
||||
{"text": negative_text},
|
||||
provenance,
|
||||
command,
|
||||
)
|
||||
# A prompt whose stored hash is stale reaches another row: never link that.
|
||||
reached = conn.execute(
|
||||
"SELECT positive_prompt_id FROM prompt_pairs WHERE id=?", (pair["id"],)
|
||||
).fetchone()[0]
|
||||
if reached != positive_id or pair_id not in (None, pair["id"]):
|
||||
raise missing
|
||||
return pair
|
||||
|
||||
def _link_compatible(
|
||||
self,
|
||||
conn,
|
||||
epoch,
|
||||
current,
|
||||
total,
|
||||
positive,
|
||||
negative,
|
||||
provenance,
|
||||
command,
|
||||
) -> dict:
|
||||
# Imported here: pair_ingestion imports operations, which composes this.
|
||||
from .pair_ingestion import _ingest_resolved_pair_in
|
||||
|
||||
@@ -242,17 +359,6 @@ class ReviewDecisionOperations:
|
||||
# Enrich the one row of this file under the spelling it was
|
||||
# linked with, so the ingester's compatibility rules see it.
|
||||
image_path = rows[0][1]
|
||||
candidate = command["candidate"]
|
||||
positive, negative = candidate["positive"], candidate["negative"]
|
||||
provenance = {
|
||||
"kind": DECISION_KIND,
|
||||
"decision_id": decision_id,
|
||||
"reviewed_digest": command["sha256"],
|
||||
"candidate_hash": candidate["hash"],
|
||||
"resolver_version": current["resolver_version"],
|
||||
"positive": positive,
|
||||
"negative": negative,
|
||||
}
|
||||
result = _ingest_resolved_pair_in(
|
||||
conn,
|
||||
self,
|
||||
|
||||
@@ -46,6 +46,7 @@ def remove_quietly(*paths):
|
||||
if path and os.path.exists(path):
|
||||
os.remove(path)
|
||||
except OSError:
|
||||
# cleanup after a failure must not replace that failure
|
||||
pass
|
||||
|
||||
|
||||
|
||||
@@ -66,7 +66,6 @@ def _tag_rows(page: str, order: str = "page.id") -> str:
|
||||
)
|
||||
|
||||
|
||||
_TAG_PAGE = _tag_rows("SELECT id,name FROM tags WHERE id>? ORDER BY id LIMIT ?")
|
||||
# #444 tag sorts: sort -> (key expression on tags/counted, direction). Ties and
|
||||
# "created" follow the id, so every order is total and keyset pages are exact.
|
||||
TAG_SORTS = {
|
||||
|
||||
@@ -18,9 +18,13 @@ protocol and peer checks reject unrelated messages. Same-origin scripts are
|
||||
trusted; this transport is not authentication against other scripts on that origin.
|
||||
Unsupported messaging is reported without breaking existing ComfyUI nodes.
|
||||
|
||||
Apply works in one click: without a preview, the sender requests the ticket itself
|
||||
and applies it. Preview remains optional, and shows the exact change first.
|
||||
|
||||
An apply timeout or abort after posting means the outcome is unknown. It cannot
|
||||
prove the host cancelled the write. The sender must inspect a fresh destination
|
||||
preview before deciding to send again; automatic retry must not overwrite edits.
|
||||
preview or choose the destination again before sending again; automatic retry must
|
||||
not overwrite edits. An error the host returned is a refusal: nothing was written.
|
||||
|
||||
## PromptManager adapter
|
||||
|
||||
@@ -31,7 +35,9 @@ field. Unknown/ambiguous metadata must not become an empty string.
|
||||
|
||||
Previews show original and requested text plus positive prepend/append composition.
|
||||
Connected composition is marked unknown rather than guessing upstream values.
|
||||
Connected destination fields reject writes. Applying rechecks graph, node, widgets,
|
||||
Connected destination fields reject writes. The sender compares the destination's revision
|
||||
(node object and graph version) from discovery with the one at the preview, and refuses if the workflow changed.
|
||||
Applying rechecks graph, node, widgets,
|
||||
values and connections. Ordinary serialization cache synchronization is accepted
|
||||
only when it preserves live values. Both requested roles are checked before writing.
|
||||
|
||||
|
||||
@@ -230,6 +230,8 @@ class PromptManagerPair(PromptManagerFocused):
|
||||
negative_text,
|
||||
category=category.strip() or None,
|
||||
tags=self._parse_tags(tags),
|
||||
# The Library shows the prompt's own category and tags, not the pair's.
|
||||
fill_new_positive=True,
|
||||
)
|
||||
pair = self.db.get_prompt_pair(pair_id)
|
||||
prompt_id = pair["positive_prompt_id"]
|
||||
|
||||
@@ -11,6 +11,7 @@ Split into domain-specific mixins for maintainability:
|
||||
- JobRoutesMixin: the session job registry behind Activity (py/api/jobs.py)
|
||||
- ImageTagJobsMixin: bulk Add tags / Auto-tag as image-tags jobs
|
||||
- ThumbnailJobsMixin: thumbnail plan, thumbnail job and the gallery stream on it
|
||||
- ThumbnailLocationMixin: the thumbnail location check and payloads (#320)
|
||||
- ThumbnailConvertMixin: Convert outdated thumbnails (#317), a thumbnail job mode
|
||||
- AutoThumbnailMixin: previews made on first view, on capture and after a scan
|
||||
- ReviewRoutesMixin: the Needs review queue, item detail and decisions
|
||||
@@ -60,6 +61,11 @@ from .settings_routes import SettingsRoutesMixin
|
||||
from .job_routes import JobRoutesMixin
|
||||
from .image_tag_jobs import ImageTagJobsMixin
|
||||
from .thumbnail_jobs import ThumbnailJobsMixin
|
||||
from .thumbnail_location import ThumbnailLocationMixin
|
||||
from .thumbnail_move import ThumbnailMoveMixin
|
||||
from .thumbnail_move_plan import ThumbnailMovePlanMixin
|
||||
from .thumbnail_move_recovery import ThumbnailMoveRecoveryMixin
|
||||
from .thumbnail_prune import ThumbnailPruneMixin
|
||||
from .thumbnail_convert import ThumbnailConvertMixin
|
||||
from .thumbnail_auto import AutoThumbnailMixin
|
||||
from .system import SystemRoutesMixin
|
||||
@@ -85,6 +91,8 @@ try:
|
||||
from ...database.operations import PromptDatabase
|
||||
from ...utils.file_prompt import parse_comfyui_prompt
|
||||
from ...utils.logging_config import get_logger
|
||||
from ...utils.native_memory import release_free_memory
|
||||
from ...utils.png_text import png_text
|
||||
from ...utils.video_metadata import is_video_path, read_video_metadata
|
||||
except ImportError:
|
||||
import sys
|
||||
@@ -95,6 +103,8 @@ except ImportError:
|
||||
from database.operations import PromptDatabase
|
||||
from utils.file_prompt import parse_comfyui_prompt
|
||||
from utils.logging_config import get_logger
|
||||
from utils.native_memory import release_free_memory
|
||||
from utils.png_text import png_text
|
||||
from utils.video_metadata import is_video_path, read_video_metadata
|
||||
|
||||
|
||||
@@ -334,6 +344,11 @@ class PromptManagerAPI(
|
||||
JobRoutesMixin,
|
||||
ImageTagJobsMixin,
|
||||
ThumbnailJobsMixin,
|
||||
ThumbnailLocationMixin,
|
||||
ThumbnailMovePlanMixin,
|
||||
ThumbnailMoveMixin,
|
||||
ThumbnailMoveRecoveryMixin,
|
||||
ThumbnailPruneMixin,
|
||||
ThumbnailConvertMixin,
|
||||
AutoThumbnailMixin,
|
||||
SystemRoutesMixin,
|
||||
@@ -526,6 +541,14 @@ class PromptManagerAPI(
|
||||
return await loop.run_in_executor(None, call)
|
||||
return await loop.run_in_executor(None, func, *args)
|
||||
|
||||
async def _release_job_memory(self):
|
||||
"""After a scan or thumbnail job: glibc's free heap pages go back to
|
||||
the OS once (``malloc_trim(0)``; nothing elsewhere, #523)."""
|
||||
try:
|
||||
await self._run_in_executor(release_free_memory)
|
||||
except Exception: # best effort: the job's result already stands
|
||||
self.logger.debug("Releasing free memory failed", exc_info=True)
|
||||
|
||||
def _root_listing(self, root) -> RootListing:
|
||||
"""The shared listing record of one output root (created empty)."""
|
||||
return self._root_listings.setdefault(str(root), RootListing())
|
||||
@@ -704,6 +727,7 @@ class PromptManagerAPI(
|
||||
# ── Register domain-specific routes from mixins ───────────────
|
||||
|
||||
self._register_pair_routes(routes)
|
||||
self._register_thumbnail_move_routes(routes)
|
||||
self._register_library_routes(routes)
|
||||
self._register_library_delete_routes(routes)
|
||||
self._register_vocabulary_routes(routes)
|
||||
@@ -840,20 +864,11 @@ class PromptManagerAPI(
|
||||
f"{url_quote(rel_path.as_posix(), safe='/')}"
|
||||
)
|
||||
|
||||
# Any preview the file owns, the current format first (#317)
|
||||
from . import thumbnail_settings
|
||||
from .prompts import thumbnail_url_for
|
||||
# Any preview the file owns, the current format first
|
||||
# (#317); no store I/O here in folder mode (#320).
|
||||
from .images import listing_thumbnail_url
|
||||
|
||||
existing = thumbnail_settings.existing_thumbnail(
|
||||
output_path, rel_path
|
||||
)
|
||||
thumbnail_url = None
|
||||
if existing is not None:
|
||||
thumb_rel = (
|
||||
thumbnail_settings.thumbnail_stem_rel(rel_path)
|
||||
+ existing.suffix
|
||||
)
|
||||
thumbnail_url = thumbnail_url_for(thumb_rel, existing)
|
||||
thumbnail_url = listing_thumbnail_url(output_path, rel_path)
|
||||
if thumbnail_url:
|
||||
image["thumbnail_url"] = thumbnail_url
|
||||
break # Found matching root, stop searching
|
||||
@@ -1048,6 +1063,8 @@ class PromptManagerAPI(
|
||||
def _extract_comfyui_metadata(self, image_path):
|
||||
"""Embedded ComfyUI metadata of an image (PNG text chunks) or a video.
|
||||
|
||||
PNG text is read without decoding the pixels (``utils.png_text``, #523).
|
||||
|
||||
Videos go through ffprobe (see utils/video_metadata). Unreadable files
|
||||
are a warning, not an error: a scan over a large output tree meets
|
||||
truncated and foreign files, and it carries on past them.
|
||||
@@ -1056,7 +1073,7 @@ class PromptManagerAPI(
|
||||
return read_video_metadata(image_path)
|
||||
try:
|
||||
with Image.open(image_path) as img:
|
||||
return dict(getattr(img, "text", None) or {})
|
||||
return png_text(img)
|
||||
except Exception as e:
|
||||
self.logger.warning(
|
||||
f"Unreadable image {os.path.basename(str(image_path))}: {e}"
|
||||
|
||||
@@ -36,10 +36,18 @@ except ImportError:
|
||||
|
||||
try:
|
||||
from ...utils import exit_signal
|
||||
from ...utils.media_folders import is_skipped_folder
|
||||
from ...utils.media_folders import (
|
||||
in_trash_below_roots,
|
||||
in_trash_folder,
|
||||
is_skipped_folder,
|
||||
)
|
||||
except ImportError:
|
||||
from utils import exit_signal
|
||||
from utils.media_folders import is_skipped_folder
|
||||
from utils.media_folders import (
|
||||
in_trash_below_roots,
|
||||
in_trash_folder,
|
||||
is_skipped_folder,
|
||||
)
|
||||
|
||||
try:
|
||||
from ...database.image_removal import mark_hides
|
||||
@@ -168,7 +176,7 @@ def _physical_file_key(path, stat):
|
||||
volumes on Windows) falls back to the resolved, case-folded path.
|
||||
"""
|
||||
if stat.st_ino:
|
||||
return ("inode", stat.st_dev, stat.st_ino)
|
||||
return ("inode", (stat.st_dev, stat.st_ino))
|
||||
return ("path", os.path.normcase(os.path.realpath(str(path))))
|
||||
|
||||
|
||||
@@ -531,6 +539,7 @@ def _diagnose_output_dirs(configured_dirs, public_path=None):
|
||||
|
||||
candidates.append(folder_paths.get_output_directory())
|
||||
except ImportError:
|
||||
# outside ComfyUI there is no output folder to add
|
||||
pass
|
||||
|
||||
output_dirs = []
|
||||
@@ -1089,13 +1098,21 @@ class AdminRoutesMixin:
|
||||
return result
|
||||
|
||||
def _delete_one_duplicate(self, image_path, output_path):
|
||||
"""Delete one file and its thumbnail; return a failure reason or None."""
|
||||
"""Delete one file and its thumbnail; return a failure reason or None.
|
||||
|
||||
What is removed is exactly the folder's real path plus the name: a
|
||||
link is removed itself, never its target, and the preview removed is
|
||||
that name's. Both the link's place and what it leads to are checked.
|
||||
"""
|
||||
file_path = Path(image_path)
|
||||
if not file_path.is_absolute():
|
||||
file_path = output_path / file_path
|
||||
|
||||
try:
|
||||
rel_path = file_path.resolve().relative_to(output_path.resolve())
|
||||
real_root = output_path.resolve()
|
||||
rel_path = file_path.resolve().relative_to(real_root)
|
||||
exact = Path(os.path.realpath(file_path.parent)) / file_path.name
|
||||
exact_rel = exact.relative_to(real_root)
|
||||
except ValueError:
|
||||
self.logger.warning(
|
||||
f"Attempted to delete file outside output directory: {image_path}"
|
||||
@@ -1104,15 +1121,19 @@ class AdminRoutesMixin:
|
||||
|
||||
if file_path.suffix.lower() not in DELETABLE_MEDIA_EXTENSIONS:
|
||||
return "not a media file"
|
||||
if "thumbnails" in rel_path.parts:
|
||||
if "thumbnails" in rel_path.parts or "thumbnails" in exact_rel.parts:
|
||||
return "thumbnails are managed by the gallery"
|
||||
if in_trash_below_roots(file_path, [output_path]) or in_trash_folder(
|
||||
str(exact_rel)
|
||||
):
|
||||
return "it is in a trash folder" # never unlinked from a trash (#543)
|
||||
|
||||
if not file_path.is_file():
|
||||
return "file not found"
|
||||
|
||||
os.remove(file_path)
|
||||
os.remove(exact)
|
||||
self.logger.info(f"Deleted duplicate image: {image_path}")
|
||||
_remove_thumbnail(output_path.resolve(), rel_path, self.logger)
|
||||
_remove_thumbnail(real_root, exact_rel, self.logger)
|
||||
return None
|
||||
|
||||
async def get_statistics(self, request):
|
||||
@@ -1433,6 +1454,7 @@ class AdminRoutesMixin:
|
||||
verb = "cancelled" if state == "cancelled" else "finished"
|
||||
job.log_line(f"Scan {verb} · {_plural(job.processed, 'file')} processed")
|
||||
job.finish(state)
|
||||
await self._release_job_memory()
|
||||
if state == "finished":
|
||||
# After the scan, so the guarded thumbnail job is admitted (#249).
|
||||
await self._hand_off_scan_thumbnails(job, output_dirs)
|
||||
@@ -1647,9 +1669,10 @@ class AdminRoutesMixin:
|
||||
def _extract_batch_metadata_sync(self, file_batch):
|
||||
"""Extract metadata from a batch of files in one executor call.
|
||||
|
||||
Files are read on ``PromptManagerConfig.WORKER_THREADS`` threads:
|
||||
Pillow decodes the whole image to reach trailing text chunks and
|
||||
releases the GIL while doing so.
|
||||
Files are read on the long-lived job pool of
|
||||
``PromptManagerConfig.WORKER_THREADS`` threads (#523).
|
||||
PNG text is read chunk by chunk without decoding pixels (#523); file
|
||||
reads and ffprobe release the GIL.
|
||||
"""
|
||||
from ..config import PromptManagerConfig
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ import math
|
||||
from ..tagging_defaults import ( # noqa: F401 - re-exported for the route
|
||||
LLM_MODEL_TYPES as LLM_MODELS,
|
||||
MAX_INSTRUCTION_BYTES,
|
||||
MAX_INSTRUCTION_CHARS,
|
||||
MODEL_TYPES,
|
||||
WD14_MODEL_TYPES as WD14_MODELS,
|
||||
validate_template,
|
||||
|
||||
@@ -19,14 +19,14 @@ from .jobs import (
|
||||
)
|
||||
|
||||
from .maintenance import disk_plan
|
||||
from .prompts import thumbnail_url_for
|
||||
from . import thumbnail_settings
|
||||
from .images import listing_thumbnail_url
|
||||
from . import preview_store
|
||||
from .. import model_gate
|
||||
|
||||
try:
|
||||
from ...utils.media_folders import is_skipped_folder
|
||||
from ...utils.media_folders import in_trash_below_roots, is_skipped_folder
|
||||
except ImportError:
|
||||
from utils.media_folders import is_skipped_folder
|
||||
from utils.media_folders import in_trash_below_roots, is_skipped_folder
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
@@ -424,6 +424,7 @@ class AutotagRoutesMixin:
|
||||
title = f"Tagging model download · {label}"
|
||||
_download_titles.clear()
|
||||
_download_titles[owner] = title
|
||||
admitted = False
|
||||
try:
|
||||
job = self._jobs.start(
|
||||
MODEL_DOWNLOAD_JOB_KIND,
|
||||
@@ -439,13 +440,13 @@ class AutotagRoutesMixin:
|
||||
self._run_model_download, service, model_type, owner
|
||||
),
|
||||
)
|
||||
except BaseException as refused:
|
||||
# JobBusy, or anything else the registry raised: give the gate back.
|
||||
model_gate.release(owner)
|
||||
_download_titles.pop(owner, None)
|
||||
if isinstance(refused, JobBusy):
|
||||
return None, busy_response(refused)
|
||||
raise
|
||||
admitted = True
|
||||
except JobBusy as refused:
|
||||
return None, busy_response(refused)
|
||||
finally:
|
||||
if not admitted: # JobBusy, or anything else: give the gate back.
|
||||
model_gate.release(owner)
|
||||
_download_titles.pop(owner, None)
|
||||
|
||||
def ended(_task):
|
||||
model_gate.release(owner)
|
||||
@@ -849,9 +850,12 @@ class AutotagRoutesMixin:
|
||||
"""Return the real path when it is a media file inside an output directory."""
|
||||
if Path(str(raw_path)).suffix.lower() not in _MEDIA_EXTENSIONS:
|
||||
return None
|
||||
for output_dir in self._get_all_output_dirs():
|
||||
roots = list(self._get_all_output_dirs())
|
||||
for output_dir in roots:
|
||||
if path_is_within(raw_path, output_dir):
|
||||
real = os.path.realpath(raw_path)
|
||||
if in_trash_below_roots(real, roots):
|
||||
return None # thrown away, not an output (#543)
|
||||
return real if os.path.isfile(real) else None
|
||||
return None
|
||||
|
||||
@@ -1038,12 +1042,12 @@ class AutotagRoutesMixin:
|
||||
return self._bad_request('existing must be "skip" or "merge"')
|
||||
try:
|
||||
options = self._single_options(data, model_type)
|
||||
except OptionError as error:
|
||||
return self._bad_request(str(error))
|
||||
except (ValueError, TypeError):
|
||||
return self._bad_request(
|
||||
"general_threshold and character_threshold must be numeric"
|
||||
)
|
||||
except OptionError as error:
|
||||
return self._bad_request(str(error))
|
||||
target, error = await self._select_autotag_target(data)
|
||||
if error is not None:
|
||||
return error
|
||||
@@ -1367,23 +1371,21 @@ class AutotagRoutesMixin:
|
||||
files = await self._run_in_executor(
|
||||
self._without_removed, output_path, files
|
||||
)
|
||||
has_thumbnails = (output_path / "thumbnails").is_dir()
|
||||
location = preview_store.current()
|
||||
if location.mode == preview_store.FOLDER_MODE:
|
||||
has_thumbnails = location.available # no store I/O here (#320)
|
||||
else:
|
||||
store = preview_store.store_dir(output_path)
|
||||
has_thumbnails = store is not None and store.is_dir()
|
||||
|
||||
images = []
|
||||
for image_path in files:
|
||||
rel_path = image_path.relative_to(output_path)
|
||||
thumbnail_url = None
|
||||
if has_thumbnails:
|
||||
# Any preview the file owns, the current format first (#317)
|
||||
existing = thumbnail_settings.existing_thumbnail(
|
||||
output_path, rel_path
|
||||
)
|
||||
if existing is not None:
|
||||
thumb_rel = (
|
||||
thumbnail_settings.thumbnail_stem_rel(rel_path)
|
||||
+ existing.suffix
|
||||
)
|
||||
thumbnail_url = thumbnail_url_for(thumb_rel, existing)
|
||||
# Any preview the file owns, the current format first
|
||||
# (#317); in folder mode without store I/O (#320).
|
||||
thumbnail_url = listing_thumbnail_url(output_path, rel_path)
|
||||
images.append(
|
||||
{
|
||||
"filename": image_path.name,
|
||||
|
||||
@@ -971,6 +971,7 @@ class BackupRestoreRoutesMixin:
|
||||
line = restored_line(staged.summary)
|
||||
job.log_line(line)
|
||||
job.update(progress=100, status_line=line)
|
||||
return None
|
||||
|
||||
|
||||
async def _outlive_cancel(future):
|
||||
|
||||
@@ -574,12 +574,13 @@ class DuplicateFilesMixin:
|
||||
return _fail_job(job, "sources-changed", SOURCES_CHANGED)
|
||||
ok, backup = await self._safety_backup_step(job, db, backup_on)
|
||||
if not ok:
|
||||
return
|
||||
return None
|
||||
try:
|
||||
await self._remove_duplicates(job, db, plan, backup)
|
||||
finally:
|
||||
if backup:
|
||||
await self._prune_backup_pool(job, db)
|
||||
return None
|
||||
|
||||
async def _remove_duplicates(self, job, db, plan, backup):
|
||||
targets = plan.payload["targets"]
|
||||
|
||||
@@ -16,6 +16,11 @@ from pathlib import Path
|
||||
from .admin import DELETABLE_MEDIA_EXTENSIONS
|
||||
from .maintenance import DUPLICATE_FILES
|
||||
|
||||
try:
|
||||
from ...utils.media_folders import in_trash_below_roots, is_trash_folder
|
||||
except ImportError:
|
||||
from utils.media_folders import in_trash_below_roots, is_trash_folder
|
||||
|
||||
# Keep entries one review takes; with paths of a few hundred characters the
|
||||
# list stays inside the preview body cap (DUPLICATE_PREVIEW_BODY_MAX, 1 MiB).
|
||||
MAX_KEEP = 2000
|
||||
@@ -197,6 +202,8 @@ def file_problem(
|
||||
parts = Path(os.path.relpath(lexical, base)).parts
|
||||
if "thumbnails" in parts or ".." in parts:
|
||||
return OUTSIDE_SOURCE
|
||||
if any(is_trash_folder(part) for part in parts[:-1]):
|
||||
return OUTSIDE_SOURCE # never removed from a trash folder (#543)
|
||||
if os.path.splitext(lexical)[1].lower() not in DELETABLE_MEDIA_EXTENSIONS:
|
||||
return OUTSIDE_SOURCE
|
||||
try:
|
||||
@@ -212,6 +219,8 @@ def file_problem(
|
||||
try:
|
||||
if not _lexically_inside(_canonical_path(path), [_canonical_path(root)]):
|
||||
return OUTSIDE_SOURCE # a linked folder on the way
|
||||
if in_trash_below_roots(path, [root]):
|
||||
return OUTSIDE_SOURCE # a linked folder into a trash folder
|
||||
if keeper is not None:
|
||||
same = keeper_problem(path, root, info, keeper, keeper_root)
|
||||
if same:
|
||||
|
||||
@@ -27,7 +27,7 @@ def _identity(path):
|
||||
except OSError:
|
||||
return None
|
||||
if info.st_ino:
|
||||
return ("inode", info.st_dev, info.st_ino)
|
||||
return ("inode", (info.st_dev, info.st_ino))
|
||||
return ("path", os.path.normcase(os.path.realpath(str(path))))
|
||||
|
||||
|
||||
|
||||
@@ -843,7 +843,10 @@ class HistoryRoutesMixin:
|
||||
raise
|
||||
|
||||
async def execute():
|
||||
pending = asyncio.create_task(self._run_in_executor(project))
|
||||
# An inspection reads the file's header and text: the
|
||||
# interactive image pool (#523); media stays on the default.
|
||||
run = self._run_image if kind == "inspection" else self._run_in_executor
|
||||
pending = asyncio.create_task(run(project))
|
||||
try:
|
||||
return await asyncio.shield(pending)
|
||||
except asyncio.CancelledError:
|
||||
|
||||
@@ -15,9 +15,10 @@ from typing import BinaryIO
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from . import thumbnail_settings
|
||||
from . import preview_store, thumbnail_settings
|
||||
from .images import _canonical, _public_path, SERVABLE_EXTENSIONS, VIDEO_EXTENSIONS
|
||||
from .images import existing_media_thumbnail, media_thumbnail_rel
|
||||
from .images import in_trash_below_roots
|
||||
from .images import existing_media_thumbnail, media_preview_path
|
||||
|
||||
try:
|
||||
from ...utils.execution_outputs import (
|
||||
@@ -198,6 +199,9 @@ def resolve_current_candidate(
|
||||
if namespace is None or not namespace.is_dir():
|
||||
return _unavailable("root_unavailable")
|
||||
target = _canonical(lexical)
|
||||
if in_trash_below_roots(target, [namespace]):
|
||||
# Every kind and the outputs listing come through here (#543).
|
||||
return _unavailable("missing")
|
||||
if not _authorized(target, namespace, scope):
|
||||
return _unavailable("not_authorized")
|
||||
if target.suffix.lower() not in SERVABLE_EXTENSIONS:
|
||||
@@ -240,15 +244,36 @@ def resolve_current_candidate(
|
||||
return _unavailable("temporary_unavailable", error=True)
|
||||
|
||||
|
||||
def _target_authorized(target, namespace, scope, store_root=None) -> bool:
|
||||
"""A preview in the thumbnail folder (#320, folder mode) is authorized by
|
||||
the store of its source (``preview_store.owns``: inside the key folder,
|
||||
no link); everything else by the output namespace and the roots."""
|
||||
if (
|
||||
store_root is not None
|
||||
and preview_store.current().mode == preview_store.FOLDER_MODE
|
||||
):
|
||||
return preview_store.owns(store_root, target)
|
||||
return _authorized(target, namespace, scope)
|
||||
|
||||
|
||||
def _open_current_target(
|
||||
target: Path, namespace: Path, scope: CurrentOutputScope, preview: bool = False
|
||||
target: Path,
|
||||
namespace: Path,
|
||||
scope: CurrentOutputScope,
|
||||
preview: bool = False,
|
||||
store_root: Path | None = None,
|
||||
) -> BinaryIO:
|
||||
"""Open only the canonical target and verify authority against the actual handle.
|
||||
|
||||
A *preview* (a file under ``thumbnails/``) may also be JPEG XL (#317)."""
|
||||
A *preview* (a file under ``thumbnails/``) may also be JPEG XL (#317); in
|
||||
folder mode it is authorized by the store of *store_root* (#320)."""
|
||||
if _canonical(target) != target:
|
||||
raise CurrentLocationUnavailable("not_authorized", stale=True)
|
||||
if not _authorized(target, namespace, scope):
|
||||
if in_trash_below_roots(target, [namespace]):
|
||||
# Also at the open: the cover chooses its preview after the candidate
|
||||
# check, and a preview can be a link into a trash folder (#543).
|
||||
raise CurrentLocationUnavailable("missing")
|
||||
if not _target_authorized(target, namespace, scope, store_root):
|
||||
raise CurrentLocationUnavailable("not_authorized")
|
||||
suffix = target.suffix.lower()
|
||||
if suffix not in SERVABLE_EXTENSIONS and not (
|
||||
@@ -281,7 +306,7 @@ def _open_current_target(
|
||||
if (
|
||||
_canonical(target) != target
|
||||
or (observed.st_dev, observed.st_ino) != (current.st_dev, current.st_ino)
|
||||
or not _authorized(target, namespace, scope)
|
||||
or not _target_authorized(target, namespace, scope, store_root)
|
||||
):
|
||||
raise CurrentLocationUnavailable("not_authorized", stale=True)
|
||||
return os.fdopen(fd, "rb")
|
||||
@@ -318,12 +343,10 @@ class _OpenedCurrentMedia:
|
||||
|
||||
|
||||
def _preview_writable(root: Path, preview: Path) -> bool:
|
||||
"""A preview may be written: ``thumbnails`` and every link segment of the
|
||||
preview's folder resolve inside *root* (the serve route's rule, #249)."""
|
||||
folder = root / "thumbnails"
|
||||
return _canonical(folder).is_relative_to(_canonical(root)) and _canonical(
|
||||
preview
|
||||
).is_relative_to(_canonical(folder))
|
||||
"""A preview may be written: the store of *root* and every link segment
|
||||
of the preview's folder resolve inside it (the serve route's rule, #249;
|
||||
``preview_store.owns``)."""
|
||||
return preview_store.owns(root, preview)
|
||||
|
||||
|
||||
def _is_jxl(path) -> bool:
|
||||
@@ -373,8 +396,12 @@ def _cover_target(resolved, ensure_preview, placeholder, accepts_jxl):
|
||||
return _canonical(preview)
|
||||
if preview is not None:
|
||||
return _canonical(preview)
|
||||
preview = resolved.gallery_root / media_thumbnail_rel(relative)
|
||||
if ensure_preview is not None and _preview_writable(resolved.gallery_root, preview):
|
||||
preview = media_preview_path(resolved.gallery_root, relative, create=True)
|
||||
if (
|
||||
ensure_preview is not None
|
||||
and preview is not None
|
||||
and _preview_writable(resolved.gallery_root, preview)
|
||||
):
|
||||
ensure_preview(target, preview, is_video)
|
||||
if is_video and placeholder is not None:
|
||||
return _placeholder_media(placeholder)
|
||||
@@ -410,7 +437,13 @@ def _prepare_current_media(
|
||||
return chosen
|
||||
if chosen is not None:
|
||||
target, is_preview = chosen, True
|
||||
handle = _open_current_target(target, resolved.namespace, scope, is_preview)
|
||||
handle = _open_current_target(
|
||||
target,
|
||||
resolved.namespace,
|
||||
scope,
|
||||
is_preview,
|
||||
resolved.gallery_root if is_preview else None,
|
||||
)
|
||||
try:
|
||||
return _OpenedCurrentMedia(
|
||||
handle,
|
||||
|
||||
@@ -330,12 +330,13 @@ class ImageDeleteMixin:
|
||||
return _fail_job(job, "sources-changed", SOURCES_CHANGED)
|
||||
ok, backup = await self._safety_backup_step(job, db, backup_on)
|
||||
if not ok:
|
||||
return
|
||||
return None
|
||||
try:
|
||||
await self._delete_images(job, db, targets, mode, backup)
|
||||
finally:
|
||||
if backup:
|
||||
await self._prune_backup_pool(job, db)
|
||||
return None
|
||||
|
||||
async def _delete_images(self, job, db, targets, mode, backup):
|
||||
job.update(total=len(targets), status_line=TITLES[mode])
|
||||
|
||||
@@ -531,6 +531,7 @@ class ImageTagJobsMixin:
|
||||
run = AutotagRun(
|
||||
model_type, existing, options, keep_in_memory, backup_on, owner
|
||||
)
|
||||
admitted = False
|
||||
try:
|
||||
job = self._jobs.start(
|
||||
AUTOTAG_BATCH_JOB_KIND,
|
||||
@@ -541,11 +542,12 @@ class ImageTagJobsMixin:
|
||||
groups=AUTOTAG_GROUP_NAMES,
|
||||
runner=functools.partial(self._run_autotag_batch, run),
|
||||
)
|
||||
except BaseException as refused:
|
||||
model_gate.release(owner)
|
||||
if isinstance(refused, JobBusy):
|
||||
return None, _busy_refusal(refused)
|
||||
raise
|
||||
admitted = True
|
||||
except JobBusy as refused:
|
||||
return None, _busy_refusal(refused)
|
||||
finally:
|
||||
if not admitted:
|
||||
model_gate.release(owner)
|
||||
job.task.add_done_callback(lambda _task: model_gate.release(owner))
|
||||
return job, None
|
||||
|
||||
|
||||
@@ -34,15 +34,14 @@ from .gallery_listing import (
|
||||
WalkOutcome,
|
||||
walk_root,
|
||||
)
|
||||
from . import thumbnail_settings
|
||||
from . import preview_store, thumbnail_settings
|
||||
from .thumbnail_location import location_refusal
|
||||
from .thumbnail_encode import (
|
||||
PREVIEW_HELD,
|
||||
EncoderClosed,
|
||||
PreviewWriteHeld,
|
||||
replace_unless_held,
|
||||
run_encoder,
|
||||
sweep_stray_temps,
|
||||
temp_sibling,
|
||||
thumbnail_frame,
|
||||
writer_settings,
|
||||
write_thumbnail,
|
||||
@@ -66,7 +65,12 @@ try:
|
||||
from ...database.models import normalize_image_path
|
||||
from ...database.run_attribution import recorded_output_lookup
|
||||
from ...utils.card_seed import card_seed, header_params, parsed_positive
|
||||
from ...utils.media_folders import in_skipped_folder, is_skipped_folder
|
||||
from ...utils.media_folders import (
|
||||
in_skipped_folder,
|
||||
in_trash_below_roots,
|
||||
in_trash_folder,
|
||||
is_skipped_folder,
|
||||
)
|
||||
from ...utils.parallel import map_parallel
|
||||
from ...utils.json_strict import dumps_strict
|
||||
from ...utils.inspection_response import (
|
||||
@@ -80,7 +84,7 @@ try:
|
||||
OpenerUnavailable,
|
||||
RevealGate,
|
||||
RevealTargetChanged,
|
||||
is_local_request,
|
||||
is_same_origin_request,
|
||||
reveal_executor,
|
||||
reveal_in_file_manager,
|
||||
)
|
||||
@@ -92,7 +96,12 @@ except ImportError:
|
||||
from database.models import normalize_image_path
|
||||
from database.run_attribution import recorded_output_lookup
|
||||
from utils.card_seed import card_seed, header_params, parsed_positive
|
||||
from utils.media_folders import in_skipped_folder, is_skipped_folder
|
||||
from utils.media_folders import (
|
||||
in_skipped_folder,
|
||||
in_trash_below_roots,
|
||||
in_trash_folder,
|
||||
is_skipped_folder,
|
||||
)
|
||||
from utils.parallel import map_parallel
|
||||
from utils.json_strict import dumps_strict
|
||||
from utils.inspection_response import (
|
||||
@@ -106,7 +115,7 @@ except ImportError:
|
||||
OpenerUnavailable,
|
||||
RevealGate,
|
||||
RevealTargetChanged,
|
||||
is_local_request,
|
||||
is_same_origin_request,
|
||||
reveal_executor,
|
||||
reveal_in_file_manager,
|
||||
)
|
||||
@@ -277,10 +286,20 @@ def _forbidden(message="Access denied"):
|
||||
return web.json_response({"success": False, "error": message}, status=403)
|
||||
|
||||
|
||||
def _media_access_error(path, allowed_dirs):
|
||||
"""Return a 403 response when *path* must not be served, else None.
|
||||
def _file_not_found():
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Image file not found"},
|
||||
status=404,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
Fails closed: an empty *allowed_dirs* denies everything.
|
||||
|
||||
def _media_access_error(path, allowed_dirs):
|
||||
"""The response refusing *path*, or None when it may be read.
|
||||
|
||||
Fails closed: an empty *allowed_dirs* denies everything (403). A file in a
|
||||
trash folder below its root is thrown away, not an output: a 404, as if it
|
||||
were not there (#543), also through a link or another spelling.
|
||||
"""
|
||||
if Path(path).suffix.lower() not in SERVABLE_EXTENSIONS:
|
||||
return _forbidden("Only media files can be served")
|
||||
@@ -288,6 +307,8 @@ def _media_access_error(path, allowed_dirs):
|
||||
return _forbidden("No allowed image directories configured")
|
||||
if not any(_is_within(path, root) for root in allowed_dirs):
|
||||
return _forbidden()
|
||||
if in_trash_below_roots(path, allowed_dirs):
|
||||
return _file_not_found()
|
||||
return None
|
||||
|
||||
|
||||
@@ -317,11 +338,6 @@ def _thumbnail_batch_size(workers):
|
||||
return max(4, workers * 2)
|
||||
|
||||
|
||||
# Preview files are written by thumbnail_encode in the configured format
|
||||
# (thumbnail_settings, #317); these names stay importable from here.
|
||||
_temp_sibling = temp_sibling
|
||||
_replace_unless_held = replace_unless_held
|
||||
|
||||
# A preview that could not be replaced (#249 S5 M6). Windows refuses to rename
|
||||
# over a file another process has open (WinError 5 or 32), such as a preview
|
||||
# the gallery is serving; the old preview stays and the file counts as an error.
|
||||
@@ -374,6 +390,7 @@ def _video_placeholder_image(thumbnail_size):
|
||||
font=font,
|
||||
)
|
||||
except (OSError, AttributeError):
|
||||
# a placeholder without its label still works
|
||||
pass
|
||||
return img
|
||||
|
||||
@@ -477,6 +494,7 @@ def _preview_names(folders, folder):
|
||||
except OSError:
|
||||
continue
|
||||
except OSError:
|
||||
# an unreadable folder lists as empty; nothing there to cache
|
||||
pass
|
||||
folders[folder] = names
|
||||
return names
|
||||
@@ -795,12 +813,12 @@ def _reveal_not_found():
|
||||
)
|
||||
|
||||
|
||||
def _not_local():
|
||||
def _not_same_origin():
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"code": "not-local",
|
||||
"error": "Show in folder works only on the computer running ComfyUI.",
|
||||
"code": "not-same-origin",
|
||||
"error": "This page's address does not match the server's.",
|
||||
},
|
||||
status=403,
|
||||
)
|
||||
@@ -979,6 +997,8 @@ def _settled_entry(root, relative):
|
||||
return None
|
||||
if in_skipped_folder(folders) or len(folders) > MAX_SCAN_DEPTH:
|
||||
return None
|
||||
if in_trash_below_roots(path, [root]):
|
||||
return None # a linked folder on the way leads into a trash folder
|
||||
folder = os.fspath(root)
|
||||
for part in ("", *folders):
|
||||
folder = os.path.join(folder, part) if part else folder
|
||||
@@ -1049,13 +1069,24 @@ def _iter_media_files(output_path, max_files):
|
||||
|
||||
|
||||
def media_thumbnail_rel(rel_path, settings=None):
|
||||
"""``thumbnails/<rel>_thumb<ext>`` (POSIX) for the media file *rel_path*:
|
||||
the name the preview gets when it is written now (#317), images and
|
||||
videos alike (``writer_settings``' extension)."""
|
||||
"""``thumbnails/<rel>_thumb<ext>`` (POSIX URL text) for the media file
|
||||
*rel_path*: the name the preview gets when it is written now (#317),
|
||||
images and videos alike (``writer_settings``' extension). Where it is on
|
||||
disk is ``media_preview_path``."""
|
||||
settings = settings or writer_settings()
|
||||
return thumbnail_settings.thumbnail_rel(rel_path, settings.extension)
|
||||
|
||||
|
||||
def media_preview_path(output_path, rel_path, settings=None, *, create=False):
|
||||
"""Where the preview of *rel_path* is written now (``preview_store``);
|
||||
*create* for a writer (a new source gets its key). None when nothing
|
||||
may be written (the thumbnail folder is unavailable)."""
|
||||
settings = settings or writer_settings()
|
||||
return preview_store.preview_path(
|
||||
output_path, rel_path, settings.extension, create=create
|
||||
)
|
||||
|
||||
|
||||
def existing_media_thumbnail(output_path, rel_path):
|
||||
"""The existing preview *rel_path* owns that a listing or cover shows
|
||||
(the format written now first, then older ones), or None. The Images
|
||||
@@ -1065,6 +1096,27 @@ def existing_media_thumbnail(output_path, rel_path):
|
||||
)
|
||||
|
||||
|
||||
def listing_thumbnail_url(output_path, rel_path):
|
||||
"""The thumbnail URL of an older listing (``_enrich_images``, the
|
||||
auto-tag scan), or None. These run on the event loop: in folder mode
|
||||
(#320) the store is never touched there, so the URL names the preview
|
||||
as it is written now and the serve route finds or makes it off the loop
|
||||
(None while the thumbnail folder is unavailable)."""
|
||||
from .prompts import thumbnail_url_for
|
||||
|
||||
location = preview_store.current()
|
||||
if location.mode == preview_store.FOLDER_MODE:
|
||||
if not location.available:
|
||||
return None
|
||||
quoted = urllib.parse.quote(media_thumbnail_rel(rel_path), safe="/")
|
||||
return f"/prompt_manager/images/serve/{quoted}"
|
||||
existing = thumbnail_settings.existing_thumbnail(output_path, rel_path)
|
||||
if existing is None:
|
||||
return None
|
||||
thumb_rel = thumbnail_settings.thumbnail_stem_rel(rel_path) + existing.suffix
|
||||
return thumbnail_url_for(thumb_rel, existing)
|
||||
|
||||
|
||||
def media_thumbnail(output_path, rel_path, root_index, *, root_scope=None):
|
||||
"""``(thumbnail_url, ready)`` for *rel_path* under *output_path* (a few
|
||||
blocking stats). The Images listing, the Needs review queue cards and the
|
||||
@@ -1514,7 +1566,7 @@ class ImageRoutesMixin:
|
||||
async def clear_image_favorite_route(request):
|
||||
return await self.update_image_favorite(request, favorite=False)
|
||||
|
||||
# POST, local-only: opens this computer's file manager (D38).
|
||||
# POST, same-origin: opens the server computer's file manager (D38).
|
||||
@routes.post("/prompt_manager/images/reveal")
|
||||
async def reveal_image_route(request):
|
||||
return await self.reveal_image(request)
|
||||
@@ -2761,6 +2813,9 @@ class ImageRoutesMixin:
|
||||
if _has_traversal(filepath):
|
||||
return _forbidden()
|
||||
suffix = Path(filepath).suffix.lower()
|
||||
if in_trash_folder(filepath):
|
||||
# Before any file access, so no preview is made for it (#543).
|
||||
return _file_not_found()
|
||||
if suffix not in SERVABLE_EXTENSIONS and not (
|
||||
# A JPEG XL preview is served from thumbnails/ only (#317).
|
||||
suffix in thumbnail_settings.THUMBNAIL_EXTENSIONS
|
||||
@@ -2776,14 +2831,59 @@ class ImageRoutesMixin:
|
||||
output_dirs[0], filepath, int(root_param), len(outputs)
|
||||
)
|
||||
|
||||
# Preview text is mapped by the resolver (#320); any other text,
|
||||
# ``Thumbnails/...`` media on a case-sensitive disk included,
|
||||
# keeps the plain join.
|
||||
preview_parts = (
|
||||
tuple(filepath.split("/"))
|
||||
if preview_store.is_preview_text(filepath)
|
||||
else None
|
||||
)
|
||||
location = preview_store.current()
|
||||
if location.mode == preview_store.FOLDER_MODE:
|
||||
if preview_parts is None and await self._run_image(
|
||||
self._in_source_thumbnails, output_dirs, filepath
|
||||
):
|
||||
# Previews live in the thumbnail folder: anything else
|
||||
# under a source's thumbnails/ is a leftover (#320), in
|
||||
# any case on a case-insensitive disk.
|
||||
return _forbidden()
|
||||
if preview_parts is not None:
|
||||
# Store I/O never runs on the event loop (a share may
|
||||
# stall): the lookup runs on the image pool.
|
||||
found = await self._run_image(
|
||||
self._find_preview, output_dirs, preview_parts, location
|
||||
)
|
||||
if found == "forbidden":
|
||||
return _forbidden()
|
||||
if found is not None:
|
||||
return self._file_response(found)
|
||||
for output_path in output_dirs:
|
||||
image_path = output_path / filepath
|
||||
if (
|
||||
preview_parts is not None
|
||||
and location.mode == preview_store.FOLDER_MODE
|
||||
):
|
||||
break # looked up above
|
||||
if preview_parts is None:
|
||||
image_path = output_path / filepath
|
||||
else:
|
||||
image_path = preview_store.request_path(output_path, preview_parts)
|
||||
if image_path is None:
|
||||
continue
|
||||
if not image_path.exists():
|
||||
continue
|
||||
# A symlink that points outside the root is a hard deny, not
|
||||
# a fall-through to the next root.
|
||||
if not _is_within(image_path, output_path):
|
||||
# A symlink that points outside the root (a preview: outside
|
||||
# the folder holding its store) is a hard deny, not a
|
||||
# fall-through to the next root.
|
||||
container = (
|
||||
output_path
|
||||
if preview_parts is None
|
||||
else preview_store.store_container(output_path)
|
||||
)
|
||||
if not _is_within(image_path, container):
|
||||
return _forbidden()
|
||||
if in_trash_below_roots(image_path, output_dirs):
|
||||
return _file_not_found()
|
||||
if image_path.is_file():
|
||||
return self._file_response(image_path)
|
||||
|
||||
@@ -2806,6 +2906,48 @@ class ImageRoutesMixin:
|
||||
{"success": False, "error": _safe_error(e)}, status=500
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _in_source_thumbnails(output_dirs, filepath):
|
||||
"""Whether *filepath* lies in a source's ``thumbnails`` folder: its
|
||||
first part is ``thumbnails`` exactly, or another case of it that is
|
||||
the same folder (a case-insensitive disk). ``Thumbnails/`` media on
|
||||
a case-sensitive disk is another folder and stays served."""
|
||||
first = filepath.split("/", 1)[0]
|
||||
if first == "thumbnails":
|
||||
return True
|
||||
if first.casefold() != "thumbnails":
|
||||
return False
|
||||
for output_path in output_dirs:
|
||||
spelled = output_path / first
|
||||
cache = preview_store.source_folder(output_path)
|
||||
try:
|
||||
if spelled.is_dir() and cache.is_dir() and spelled.samefile(cache):
|
||||
return True
|
||||
except OSError:
|
||||
continue
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _find_preview(output_dirs, parts, location):
|
||||
"""The preview file *parts* names in folder mode, ``"forbidden"``
|
||||
(it resolves out of its store or through a link) or None (blocking;
|
||||
the first root whose store holds it, in ``_select_root`` order)."""
|
||||
for output_path in output_dirs:
|
||||
image_path = preview_store.request_path(output_path, parts, location)
|
||||
if image_path is None:
|
||||
continue
|
||||
try:
|
||||
with preview_store.store_io():
|
||||
if not os.path.lexists(image_path):
|
||||
continue
|
||||
if not preview_store.owns(output_path, image_path, location):
|
||||
return "forbidden"
|
||||
if image_path.is_file():
|
||||
return image_path
|
||||
except OSError:
|
||||
return None
|
||||
return None
|
||||
|
||||
def _is_recorded_media(self, path) -> bool:
|
||||
"""Whether the database records ``path`` as a linked image.
|
||||
|
||||
@@ -2835,8 +2977,9 @@ class ImageRoutesMixin:
|
||||
if _is_thumbnail_cache_path(filepath):
|
||||
return _forbidden()
|
||||
image_path = root / filepath
|
||||
if not _is_within(image_path, root):
|
||||
return _forbidden()
|
||||
denied = _media_access_error(image_path, [root])
|
||||
if denied is not None:
|
||||
return denied
|
||||
if not image_path.is_file():
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Image file not found"}, status=404
|
||||
@@ -2931,6 +3074,9 @@ class ImageRoutesMixin:
|
||||
data, error = await _json_body_or_error(request)
|
||||
if error is not None:
|
||||
return error
|
||||
refused = location_refusal()
|
||||
if refused is not None:
|
||||
return refused
|
||||
return await self._run_held(
|
||||
THUMBNAILS_JOB_KIND,
|
||||
THUMBNAIL_GENERATION_TITLE,
|
||||
@@ -2950,6 +3096,7 @@ class ImageRoutesMixin:
|
||||
result = await self._run_in_executor(
|
||||
self._generate_gallery_thumbnails_sync, output_dirs
|
||||
)
|
||||
await self._release_job_memory()
|
||||
return web.json_response(result)
|
||||
|
||||
except Exception as e:
|
||||
@@ -2962,6 +3109,7 @@ class ImageRoutesMixin:
|
||||
"""Blocking thumbnail generation loop (run in executor)."""
|
||||
settings = writer_settings(settings)
|
||||
start = _time.monotonic()
|
||||
thumbnails_dir = preview_store.given_store(output_path, thumbnails_dir)
|
||||
plan = self._plan_thumbnails(output_path, thumbnails_dir, settings=settings)
|
||||
payload = self._execute_thumbnail_plan(plan, settings, start)
|
||||
payload["thumbnails_path"] = _public_path(thumbnails_dir)
|
||||
@@ -2974,7 +3122,8 @@ class ImageRoutesMixin:
|
||||
start = _time.monotonic()
|
||||
plan = self._plan_gallery_thumbnails(output_dirs, settings=settings)
|
||||
payload = self._execute_thumbnail_plan(plan, settings, start)
|
||||
payload["thumbnails_path"] = _public_path(output_dirs[0] / "thumbnails")
|
||||
store = preview_store.store_dir(output_dirs[0])
|
||||
payload["thumbnails_path"] = None if store is None else _public_path(store)
|
||||
return payload
|
||||
|
||||
def _execute_thumbnail_plan(self, plan: dict, settings, start: float) -> dict:
|
||||
@@ -3017,9 +3166,12 @@ class ImageRoutesMixin:
|
||||
if stop is not None and stop():
|
||||
break
|
||||
root = Path(root)
|
||||
store = preview_store.ensure_store(root)
|
||||
if store is None:
|
||||
continue # the thumbnail folder is unavailable
|
||||
plan = self._plan_thumbnails(
|
||||
root,
|
||||
root / "thumbnails",
|
||||
store,
|
||||
mode=mode,
|
||||
stop=stop,
|
||||
settings=settings,
|
||||
@@ -3058,6 +3210,8 @@ class ImageRoutesMixin:
|
||||
"format": writer_settings().format,
|
||||
# Files whose automatic preview failed this session (#249).
|
||||
"failed": self._auto_thumbnail_failures(),
|
||||
# #320: where previews live (no path here, D6A).
|
||||
"location": self._thumbnail_location_payload(with_path=False),
|
||||
"collection_id": hashlib.sha256(collection.encode()).hexdigest()[
|
||||
:16
|
||||
],
|
||||
@@ -3103,9 +3257,11 @@ class ImageRoutesMixin:
|
||||
"""
|
||||
settings = writer_settings(settings)
|
||||
output_path = Path(output_path)
|
||||
thumbnails_dir = Path(thumbnails_dir)
|
||||
thumbnails_dir = preview_store.given_store(output_path, thumbnails_dir)
|
||||
plan = {key: 0 for key in _PLAN_COUNTS}
|
||||
plan.update(targets=[], fresh_files=[])
|
||||
if thumbnails_dir is None:
|
||||
return plan # the thumbnail folder is unavailable
|
||||
folders = {}
|
||||
removed = self._removal_check_under(output_path)
|
||||
for src in _iter_media_files(output_path, MAX_GALLERY_FILES):
|
||||
@@ -3195,6 +3351,9 @@ class ImageRoutesMixin:
|
||||
"dir": src.parent.name,
|
||||
"type": "video" if is_video else "image",
|
||||
}
|
||||
# Taken when the write starts: a thumbnail location switched since
|
||||
# then drops the write at its rename (#320).
|
||||
guard = preview_store.write_guard(guard)
|
||||
dst = Path(dst)
|
||||
if dst.suffix != settings.extension:
|
||||
dst = dst.with_suffix(settings.extension)
|
||||
@@ -3205,7 +3364,7 @@ class ImageRoutesMixin:
|
||||
return {**result, "action": "error", "error": "File no longer exists"}
|
||||
if not rebuild and _thumbnail_is_fresh(src, dst):
|
||||
return {**result, "action": "skipped", "path": str(dst)}
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
preview_store.make_parent(dst)
|
||||
if is_video:
|
||||
made = self._generate_video_thumbnail(
|
||||
src,
|
||||
@@ -3410,8 +3569,12 @@ class ImageRoutesMixin:
|
||||
|
||||
Invalid thumbnail directories return 400; unavailable gallery roots
|
||||
return 404, and unexpected filesystem failures return 500. Holds the
|
||||
registry as guarded work, like generation.
|
||||
registry as guarded work, like generation. Refused (409) while the
|
||||
thumbnail folder is unavailable (#320).
|
||||
"""
|
||||
refused = location_refusal()
|
||||
if refused is not None:
|
||||
return refused
|
||||
return await self._run_held(
|
||||
THUMBNAILS_JOB_KIND,
|
||||
THUMBNAIL_CLEAR_TITLE,
|
||||
@@ -3475,8 +3638,12 @@ class ImageRoutesMixin:
|
||||
self.logger.debug(f"No roots to sweep for preview temp files: {exc}")
|
||||
return 0
|
||||
for output_path in output_dirs:
|
||||
folder = Path(output_path) / "thumbnails"
|
||||
if folder.is_symlink() or not folder.is_dir():
|
||||
folder = preview_store.store_dir(output_path)
|
||||
if (
|
||||
folder is None
|
||||
or preview_store.store_is_link(folder)
|
||||
or not folder.is_dir()
|
||||
):
|
||||
continue
|
||||
removed += sweep_stray_temps(folder)[0]
|
||||
if removed:
|
||||
@@ -3494,13 +3661,16 @@ class ImageRoutesMixin:
|
||||
directories = []
|
||||
for output_path in output_dirs:
|
||||
output_path = Path(output_path)
|
||||
thumbnails_dir = output_path / "thumbnails"
|
||||
thumbnails_dir = preview_store.store_dir(output_path)
|
||||
if thumbnails_dir is None:
|
||||
continue
|
||||
if not thumbnails_dir.exists() and not thumbnails_dir.is_symlink():
|
||||
continue
|
||||
container = preview_store.store_container(output_path)
|
||||
if (
|
||||
thumbnails_dir.is_symlink()
|
||||
or not _is_within(thumbnails_dir, output_path)
|
||||
or _canonical(thumbnails_dir) == _canonical(output_path)
|
||||
preview_store.store_is_link(thumbnails_dir)
|
||||
or not _is_within(thumbnails_dir, container)
|
||||
or _canonical(thumbnails_dir) == _canonical(container)
|
||||
):
|
||||
raise ValueError(
|
||||
"Safety check failed: invalid thumbnails directory path"
|
||||
@@ -3515,7 +3685,15 @@ class ImageRoutesMixin:
|
||||
temps, temp_bytes = sweep_stray_temps(thumbnails_dir, min_age=0)
|
||||
cleared_count += temps
|
||||
cleared_size += temp_bytes
|
||||
for root, _dirs, files in os.walk(thumbnails_dir):
|
||||
folder_mode = preview_store.current().mode == preview_store.FOLDER_MODE
|
||||
for root, dirs, files in os.walk(thumbnails_dir):
|
||||
if folder_mode:
|
||||
# Never into a junction or reparse point (#320).
|
||||
dirs[:] = [
|
||||
d
|
||||
for d in dirs
|
||||
if not preview_store.store_is_link(Path(root) / d)
|
||||
]
|
||||
for name in files:
|
||||
path = Path(root) / name
|
||||
if (
|
||||
@@ -3523,6 +3701,7 @@ class ImageRoutesMixin:
|
||||
or path.suffix.lower()
|
||||
not in thumbnail_settings.THUMBNAIL_EXTENSIONS
|
||||
or path.is_symlink()
|
||||
or not preview_store.may_write(path)
|
||||
):
|
||||
continue
|
||||
try:
|
||||
@@ -3682,12 +3861,12 @@ class ImageRoutesMixin:
|
||||
async def reveal_image(self, request):
|
||||
"""POST ``{root_index, root_scope, path}``: show the file in its folder.
|
||||
|
||||
Local-only (a same-origin browser request from a loopback client, no
|
||||
forwarding header) and JSON-only: the browser must run on the
|
||||
computer whose file manager opens. One reveal at a time (429 busy).
|
||||
Same-origin and JSON-only, directly or through a proxy: the folder
|
||||
opens on the computer running ComfyUI, wherever the browser is
|
||||
(maintainer decision, #533). One reveal at a time (429 busy).
|
||||
"""
|
||||
if not is_local_request(request.remote, request.headers, request.scheme):
|
||||
return _not_local()
|
||||
if not is_same_origin_request(request.headers, request.scheme):
|
||||
return _not_same_origin()
|
||||
if request.content_type != "application/json":
|
||||
# A non-JSON POST is a CORS "simple" request with no preflight.
|
||||
return web.json_response(
|
||||
@@ -3765,7 +3944,9 @@ class ImageRoutesMixin:
|
||||
{
|
||||
"success": False,
|
||||
"code": "unavailable",
|
||||
"error": "No file manager is available on this computer.",
|
||||
"error": (
|
||||
"No file manager is available on the computer running ComfyUI."
|
||||
),
|
||||
},
|
||||
status=501,
|
||||
)
|
||||
@@ -3970,7 +4151,8 @@ class ImageRoutesMixin:
|
||||
**observed,
|
||||
)
|
||||
|
||||
body = await self._run_in_executor(inspect)
|
||||
# Header and text reads: the interactive image pool (#523).
|
||||
body = await self._run_image(inspect)
|
||||
if body is None:
|
||||
return web.json_response(
|
||||
{
|
||||
@@ -4183,6 +4365,22 @@ class ImageRoutesMixin:
|
||||
# (which Windows refuses on open files) is not held up by it.
|
||||
return _CardFacts(width, height, *self._card_parse(path, records))
|
||||
|
||||
def _cached_card_facts(self, entry: tuple) -> _CardFacts | None:
|
||||
path, mtime, root, index = entry[:4]
|
||||
return self._workspace_card_cache.get((index, str(root)), {}).get((path, mtime))
|
||||
|
||||
def _card_facts_of(self, entries: list, *, parse: bool) -> list[_CardFacts]:
|
||||
"""Card facts of *entries*, in order; files not cached yet are read on
|
||||
the interactive image pool (#523), cached ones answer here."""
|
||||
found = [self._cached_card_facts(entry) for entry in entries]
|
||||
missing = [i for i, facts in enumerate(found) if facts is None]
|
||||
read = self._map_image(
|
||||
lambda i: self._card_facts(entries[i], parse=parse), missing
|
||||
)
|
||||
for i, facts in zip(missing, read):
|
||||
found[i] = facts
|
||||
return found
|
||||
|
||||
def _card_facts(self, entry: tuple, *, parse: bool) -> _CardFacts:
|
||||
"""One listed image's card facts, read once per ``(path, mtime)``.
|
||||
|
||||
@@ -4191,11 +4389,11 @@ class ImageRoutesMixin:
|
||||
and drops the rest, and a roots change drops them all. Only files of
|
||||
the current scan are stored, so the cache is bounded by the listing.
|
||||
"""
|
||||
path, mtime, root, index = entry[:4]
|
||||
root_key, key = (index, str(root)), (path, mtime)
|
||||
cached = self._workspace_card_cache.get(root_key, {}).get(key)
|
||||
cached = self._cached_card_facts(entry)
|
||||
if cached is not None:
|
||||
return cached
|
||||
path, mtime, root, index = entry[:4]
|
||||
root_key, key = (index, str(root)), (path, mtime)
|
||||
facts = self._read_card_facts(path, parse=parse)
|
||||
if facts is None:
|
||||
# Not stored: a locked or half-written file may read next time.
|
||||
@@ -4221,15 +4419,17 @@ class ImageRoutesMixin:
|
||||
if linked
|
||||
else {}
|
||||
)
|
||||
# Videos: the card reads no video metadata. Library previews show
|
||||
# no card facts, so they skip the parse (``parse=linked``).
|
||||
stills = [image for image in images if not image["is_video"]]
|
||||
read = self._card_facts_of(
|
||||
[entries[(image["root_index"], image["path"])] for image in stills],
|
||||
parse=linked,
|
||||
)
|
||||
facts_of = {id(image): facts for image, facts in zip(stills, read)}
|
||||
for image in images:
|
||||
entry = entries[(image["root_index"], image["path"])]
|
||||
# Videos: the card reads no video metadata. Library previews show
|
||||
# no card facts, so they skip the parse (``parse=linked``).
|
||||
facts = (
|
||||
_NO_CARD_FACTS
|
||||
if image["is_video"]
|
||||
else self._card_facts(entry, parse=linked)
|
||||
)
|
||||
facts = facts_of.get(id(image), _NO_CARD_FACTS)
|
||||
image["width"], image["height"] = facts.width, facts.height
|
||||
if not linked:
|
||||
continue
|
||||
|
||||
@@ -250,7 +250,7 @@ class JobRoutesMixin:
|
||||
try:
|
||||
await response.write(_summary_event(job))
|
||||
async with contextlib.aclosing(job.events()) as events:
|
||||
async for _payload in events:
|
||||
async for _ in events:
|
||||
await response.write(_summary_event(job))
|
||||
# The last event is published before the job takes its final state.
|
||||
await response.write(_summary_event(job))
|
||||
|
||||
@@ -50,6 +50,7 @@ _REFUSAL_STATUS = {
|
||||
"not-movable": 400,
|
||||
"same-folder": 400,
|
||||
"target-invalid": 400,
|
||||
"data-dir-served": 400,
|
||||
"target-has-library": 409,
|
||||
"target-conflict": 409,
|
||||
"insufficient-space": 507,
|
||||
@@ -74,6 +75,24 @@ def _failure(message, status, code):
|
||||
)
|
||||
|
||||
|
||||
def _thumbnail_folder():
|
||||
"""The thumbnail folder in use, or None (previews inside each source)."""
|
||||
from . import preview_store
|
||||
|
||||
setting = preview_store.setting()
|
||||
return setting.path if setting.mode == preview_store.FOLDER_MODE else None
|
||||
|
||||
|
||||
def _inside_thumbnail_folder(path):
|
||||
"""Whether *path* lies in the thumbnail folder in use (#320)."""
|
||||
from . import preview_store
|
||||
|
||||
setting = preview_store.setting()
|
||||
if setting.mode != preview_store.FOLDER_MODE or not setting.path:
|
||||
return False
|
||||
return ll._inside_any(path, [setting.path])
|
||||
|
||||
|
||||
def _plural(count, one, many=None):
|
||||
return f"{count:,} {one if count == 1 else (many or one + 's')}"
|
||||
|
||||
@@ -207,6 +226,7 @@ class LibraryLocationRoutesMixin:
|
||||
try:
|
||||
size = os.path.getsize(location.db_path)
|
||||
except OSError:
|
||||
# no library file yet: report size 0
|
||||
pass
|
||||
return {
|
||||
"path": self._library_public_path(location.db_path),
|
||||
@@ -224,6 +244,8 @@ class LibraryLocationRoutesMixin:
|
||||
),
|
||||
"problem": location.problem or self._node_folder_risk(location),
|
||||
"size_bytes": size,
|
||||
# #320 D201: thumbnails kept in one folder stay there (D8A).
|
||||
"thumbnails_folder": _thumbnail_folder(),
|
||||
}
|
||||
|
||||
# The node folder (tests point it at a temp tree).
|
||||
@@ -271,6 +293,8 @@ class LibraryLocationRoutesMixin:
|
||||
def _plan_library_move(self):
|
||||
"""The checked plan (blocking); raises MoveFailed."""
|
||||
plan = lm.plan_move(self._live_location())
|
||||
if _inside_thumbnail_folder(plan.target_dir):
|
||||
raise lm.MoveFailed("data-dir-served")
|
||||
if lm.space_problem(plan) is not None:
|
||||
raise lm.MoveFailed("insufficient-space")
|
||||
return plan
|
||||
@@ -373,6 +397,7 @@ class LibraryLocationRoutesMixin:
|
||||
line += ". Logs move at the next restart."
|
||||
job.log_line(line)
|
||||
job.update(progress=100, status_line=line)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _fail_library_move(job, code, message):
|
||||
|
||||
@@ -205,3 +205,4 @@ class LogCleanupMixin:
|
||||
line += f", {failed} could not be deleted"
|
||||
job.log_line(line)
|
||||
job.update(processed=len(files), progress=100, status_line=line)
|
||||
return None
|
||||
|
||||
@@ -436,7 +436,7 @@ class LoraImportRoutesMixin:
|
||||
job.log_line(f"{CANCELLED} {CACHE_NOTE}")
|
||||
job.update(status_line=CANCELLED)
|
||||
job.finish("cancelled")
|
||||
return
|
||||
return None
|
||||
except Exception:
|
||||
self.logger.exception("LoRA import staging failed")
|
||||
return _fail_job(job, "import-failed", f"{FAILED} {CACHE_NOTE}")
|
||||
@@ -455,10 +455,10 @@ class LoraImportRoutesMixin:
|
||||
job.log_line(f"{CANCELLED} {CACHE_NOTE}")
|
||||
job.update(status_line=CANCELLED)
|
||||
job.finish("cancelled")
|
||||
return
|
||||
return None
|
||||
ok, _backup = await self._safety_backup_step(job, db, backup_on)
|
||||
if not ok:
|
||||
return
|
||||
return None
|
||||
# I1: the files are listed and fingerprinted again right before the
|
||||
# replace, so a file changed, removed or added while the downloads ran
|
||||
# (LoraManager refreshing its CivitAI info) aborts with nothing deleted.
|
||||
@@ -466,6 +466,7 @@ class LoraImportRoutesMixin:
|
||||
return _fail_job(job, "plan-changed", f"{SOURCE_CHANGED} {CACHE_NOTE}")
|
||||
job.update(status_line="Replacing the LoRA collection", progress=95)
|
||||
await self._replace_in_job(job, db, plan, staged)
|
||||
return None
|
||||
|
||||
def _count_downloads(self, job, staged):
|
||||
downloaded = staged.stats.get("downloaded", 0)
|
||||
@@ -518,3 +519,4 @@ class LoraImportRoutesMixin:
|
||||
staged.stats.get("download_errors", 0),
|
||||
)
|
||||
job.update(progress=100, processed=job.total, status_line=line)
|
||||
return None
|
||||
|
||||
@@ -44,6 +44,7 @@ _FILES_CHANGED = (
|
||||
"The LoRA metadata files changed during the scan; the previous imports "
|
||||
"were kept. Run the scan again."
|
||||
)
|
||||
_LORA_SCAN_FAILED = "The LoRA import failed. Check the server log for details."
|
||||
_IMPORTS_CHANGED = (
|
||||
"The imported LoRA prompts changed during the scan; the previous imports "
|
||||
"were kept. Run the scan again."
|
||||
@@ -407,10 +408,21 @@ class LoraIntegrationMixin:
|
||||
return
|
||||
if isinstance(error, PlanChanged):
|
||||
message = _IMPORTS_CHANGED
|
||||
elif isinstance(error, (LoraFilesChanged, LoraSettingsUnusable)):
|
||||
elif isinstance(
|
||||
error,
|
||||
(
|
||||
LoraFilesChanged,
|
||||
LoraSettingsUnusable,
|
||||
EmptyReplacement,
|
||||
PairDependencyError,
|
||||
),
|
||||
):
|
||||
message = str(error)
|
||||
else:
|
||||
elif isinstance(error, OSError):
|
||||
# Reduced to its reason and the file's basename.
|
||||
message = self._public_error(error)
|
||||
else:
|
||||
message = _LORA_SCAN_FAILED
|
||||
if isinstance(
|
||||
error,
|
||||
(
|
||||
@@ -536,6 +548,7 @@ class LoraIntegrationMixin:
|
||||
except asyncio.CancelledError:
|
||||
interrupted = True
|
||||
except Exception:
|
||||
# the await after this loop raises it for the caller
|
||||
pass
|
||||
if interrupted:
|
||||
raise asyncio.CancelledError
|
||||
|
||||
@@ -673,6 +673,7 @@ class MaintenanceRoutesMixin:
|
||||
line = _changed_line(plan.operation, result["changed"], result.get("marks", 0))
|
||||
job.log_line(line)
|
||||
job.update(progress=100, status_line=f"{line} · {backup_note(backup)}")
|
||||
return None
|
||||
|
||||
def _maintenance_rows(self, operation, described):
|
||||
"""Outcome rows for the changed records (root-relative paths only)."""
|
||||
|
||||
@@ -9,13 +9,15 @@ by the caller.
|
||||
import os
|
||||
import re
|
||||
|
||||
from .images import _forbidden, _has_traversal
|
||||
from .images import _file_not_found, _forbidden, _has_traversal
|
||||
from .prompts import bad_request
|
||||
|
||||
try:
|
||||
from ...utils.media_capture import CAPTURE_EXTENSIONS
|
||||
from ...utils.media_folders import in_trash_folder
|
||||
except ImportError:
|
||||
from utils.media_capture import CAPTURE_EXTENSIONS
|
||||
from utils.media_folders import in_trash_folder
|
||||
|
||||
MAX_TARGET_PATH = 1024
|
||||
TARGET_FIELDS = frozenset({"root_index", "root_scope", "path"})
|
||||
@@ -29,7 +31,9 @@ def media_target(data: object, *, media_message: str = TAGGING_MEDIA_MESSAGE):
|
||||
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*).
|
||||
shape, a 403 for an unsafe or non-media path, carrying *media_message*, a
|
||||
404 for a path in a trash folder, #543). A link or a short-name spelling
|
||||
is refused later by the capture store's alias check.
|
||||
"""
|
||||
if not isinstance(data, dict) or set(data) != TARGET_FIELDS:
|
||||
return None, bad_request("target must name root_index, root_scope and path")
|
||||
@@ -51,4 +55,6 @@ def media_target(data: object, *, media_message: str = TAGGING_MEDIA_MESSAGE):
|
||||
return None, _forbidden()
|
||||
if os.path.splitext(parts[-1])[1].lower() not in CAPTURE_EXTENSIONS:
|
||||
return None, _forbidden(media_message)
|
||||
if in_trash_folder(relative):
|
||||
return None, _file_not_found()
|
||||
return (index, scope, relative), None
|
||||
|
||||
@@ -102,7 +102,12 @@ class PairRoutesMixin:
|
||||
validate_prompt_text(negative)
|
||||
fields = metadata_fields(data)
|
||||
pair_id = await self._run_in_executor(
|
||||
self.db.save_prompt_pair, positive, negative, **fields
|
||||
self.db.save_prompt_pair,
|
||||
positive,
|
||||
negative,
|
||||
# The node's Save pair button: as a run, a new prompt gets the metadata.
|
||||
fill_new_positive=True,
|
||||
**fields,
|
||||
)
|
||||
pair = await self._run_in_executor(self.db.get_prompt_pair, pair_id)
|
||||
return web.json_response({"success": True, "pair": pair})
|
||||
|
||||
@@ -0,0 +1,728 @@
|
||||
"""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
|
||||
@@ -0,0 +1,706 @@
|
||||
"""Where previews live on disk (#320): the one resolver.
|
||||
|
||||
Every mapping between an image source (a "root"), one of its media files and
|
||||
that file's preview on disk goes through this module. URL text stays in
|
||||
``thumbnail_settings`` (``thumbnail_rel``: ``thumbnails/<rel>_thumb<ext>``,
|
||||
the shape the serve route, ``inspection-state.mjs`` and ``admin.js`` share);
|
||||
this module answers where such a preview is on disk.
|
||||
|
||||
The store of a source is where its previews live. With the default location
|
||||
(mode ``source``, "Inside each source") it is ``<source>/thumbnails``, exactly
|
||||
the folder every caller joined by hand before, so nothing moves.
|
||||
|
||||
Writes and the switch: every preview writer takes ``write_guard`` when it
|
||||
starts (it records the location's ``generation``); the final rename runs
|
||||
under ``write_lock`` and only when the generation is unchanged and no hold
|
||||
applies (``thumbnail_encode.replace_unless_held``). Changing the location
|
||||
bumps the generation under the same lock, so no write that began before can
|
||||
land in the old store afterwards. Nothing reads or commits config while
|
||||
``write_lock`` is held. The hold check runs under it too, so hold checks take
|
||||
no lock (the job registry's ``running()`` is a lock-free snapshot) and
|
||||
``bump_generation`` is never called with a registry or config lock held.
|
||||
|
||||
All functions are blocking: call them off the event loop.
|
||||
"""
|
||||
|
||||
import errno
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass, replace
|
||||
from pathlib import Path
|
||||
|
||||
from . import preview_location, thumbnail_settings
|
||||
|
||||
try:
|
||||
from ...utils.media_folders import PREVIEW_FOLDER
|
||||
except ImportError: # tests import the package top-level
|
||||
from utils.media_folders import PREVIEW_FOLDER
|
||||
|
||||
logger = logging.getLogger("prompt_manager.thumbnails")
|
||||
|
||||
SOURCE_MODE = preview_location.SOURCE_MODE
|
||||
FOLDER_MODE = preview_location.FOLDER_MODE
|
||||
# The probe checks the folder this often, and a check (or a store call)
|
||||
# taking longer than the budget marks it unreachable.
|
||||
PROBE_INTERVAL = 5.0
|
||||
IO_BUDGET = 2.0
|
||||
PROBE_THREAD = "pm-preview-probe"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Location:
|
||||
"""One snapshot of the thumbnail location (take one per request or job)."""
|
||||
|
||||
mode: str = SOURCE_MODE
|
||||
path: str | None = None
|
||||
available: bool = True
|
||||
problem: str | None = None
|
||||
generation: int = 0
|
||||
|
||||
|
||||
write_lock = threading.Lock()
|
||||
_generation = 0
|
||||
# The runtime location: the parsed setting, availability and the marker keys
|
||||
# last read. Swapped as a whole; readers take one snapshot.
|
||||
_state_lock = threading.Lock()
|
||||
_parsed = preview_location.ParsedLocation()
|
||||
_available = True
|
||||
_problem = None
|
||||
_keys = {}
|
||||
_key_cache = {}
|
||||
_clock = time.monotonic
|
||||
|
||||
|
||||
def current():
|
||||
"""The thumbnail location now."""
|
||||
with _state_lock:
|
||||
parsed, available, problem, generation_now = (
|
||||
_parsed,
|
||||
_available,
|
||||
_problem,
|
||||
_generation,
|
||||
)
|
||||
if parsed.mode == SOURCE_MODE:
|
||||
return Location(generation=generation_now)
|
||||
return Location(FOLDER_MODE, parsed.path, available, problem, generation_now)
|
||||
|
||||
|
||||
def setting():
|
||||
"""The parsed setting in use (``preview_location.ParsedLocation``)."""
|
||||
return _parsed
|
||||
|
||||
|
||||
def generation():
|
||||
return _generation
|
||||
|
||||
|
||||
def bump_generation():
|
||||
"""Invalidate every write that started before (under ``write_lock``)."""
|
||||
global _generation
|
||||
with write_lock:
|
||||
_generation += 1
|
||||
return _generation
|
||||
|
||||
|
||||
def _canonical(path):
|
||||
"""Resolved and case-folded, so paths compare on every OS."""
|
||||
return Path(os.path.normcase(os.path.realpath(str(path))))
|
||||
|
||||
|
||||
def _within(path, folder):
|
||||
return _canonical(path).is_relative_to(_canonical(folder))
|
||||
|
||||
|
||||
# ── the setting and availability ─────────────────────────────────────
|
||||
|
||||
|
||||
def configure(value, *, context=None, check=True):
|
||||
"""Use the ``thumbnails.location`` *value* (config.json) from now on.
|
||||
|
||||
A folder location is validated against *context* (``startup`` rules: a
|
||||
problem other than an unreachable folder makes it ``invalid``), then
|
||||
checked once; the probe keeps checking it. Swapped under ``write_lock``
|
||||
with a generation bump, so no write that started before lands.
|
||||
"""
|
||||
global _parsed, _available, _problem, _generation
|
||||
parsed = preview_location.parse_location(value)
|
||||
problem = parsed.problem
|
||||
if parsed.mode == FOLDER_MODE and problem is None and context is not None:
|
||||
code = preview_location.location_problem(
|
||||
parsed.path, purpose="startup", context=context
|
||||
)
|
||||
if code == "location-unavailable":
|
||||
problem = "unreachable"
|
||||
elif code is not None:
|
||||
problem = "invalid"
|
||||
parsed = preview_location.ParsedLocation(
|
||||
mode=FOLDER_MODE,
|
||||
path=parsed.path,
|
||||
id=parsed.id,
|
||||
install=parsed.install,
|
||||
cleanup=parsed.cleanup,
|
||||
problem="invalid",
|
||||
)
|
||||
with write_lock:
|
||||
with _state_lock:
|
||||
_parsed = parsed
|
||||
_available = parsed.mode == SOURCE_MODE
|
||||
_problem = problem if parsed.mode == FOLDER_MODE else None
|
||||
_keys.clear()
|
||||
_key_cache.clear()
|
||||
_generation += 1
|
||||
if parsed.mode == FOLDER_MODE:
|
||||
if check and parsed.problem is None:
|
||||
refresh()
|
||||
_probe.start()
|
||||
else:
|
||||
_probe.stop()
|
||||
return current()
|
||||
|
||||
|
||||
def refresh():
|
||||
"""Check the folder now (bounded by IO_BUDGET); returns availability."""
|
||||
parsed = _parsed
|
||||
if parsed.mode != FOLDER_MODE or parsed.problem is not None:
|
||||
return parsed.mode == SOURCE_MODE
|
||||
try:
|
||||
available, problem, marker = _probe.check(parsed.path, parsed.id)
|
||||
except (OSError, TimeoutError, RuntimeError):
|
||||
available, problem, marker = False, "unreachable", None
|
||||
_set_state(parsed, available, problem, marker)
|
||||
return available
|
||||
|
||||
|
||||
def _set_keys(parsed, marker):
|
||||
"""Take the keys of a marker just written (availability unchanged)."""
|
||||
with _state_lock:
|
||||
if _parsed is not parsed:
|
||||
return
|
||||
keys = dict((marker or {}).get("keys") or {})
|
||||
if keys != _keys:
|
||||
_keys.clear()
|
||||
_keys.update(keys)
|
||||
_key_cache.clear()
|
||||
|
||||
|
||||
def set_cleanup(entries):
|
||||
"""The setting's ``cleanup`` after a removal was committed."""
|
||||
global _parsed
|
||||
with _state_lock:
|
||||
_parsed = replace(_parsed, cleanup=tuple(entries))
|
||||
|
||||
|
||||
def mark_invalid():
|
||||
"""The setting cannot be used (a startup check that failed): folder
|
||||
mode stays in force, unavailable, so nothing is written anywhere."""
|
||||
global _parsed, _available, _problem
|
||||
with _state_lock:
|
||||
if _parsed.mode != FOLDER_MODE:
|
||||
_parsed = preview_location.ParsedLocation(
|
||||
mode=FOLDER_MODE, problem="invalid"
|
||||
)
|
||||
else:
|
||||
_parsed = replace(_parsed, problem="invalid")
|
||||
_available, _problem = False, "invalid"
|
||||
|
||||
|
||||
def _set_state(parsed, available, problem, marker):
|
||||
global _available, _problem
|
||||
with _state_lock:
|
||||
if _parsed is not parsed:
|
||||
return # the setting changed meanwhile
|
||||
_available, _problem = available, problem
|
||||
keys = dict((marker or {}).get("keys") or {})
|
||||
if keys != _keys:
|
||||
_keys.clear()
|
||||
_keys.update(keys)
|
||||
_key_cache.clear()
|
||||
|
||||
|
||||
def note_failure():
|
||||
"""A store call failed or stalled: unreachable until the probe finds the
|
||||
folder again (requests do no store I/O meanwhile)."""
|
||||
global _available, _problem
|
||||
if _parsed.mode != FOLDER_MODE:
|
||||
return
|
||||
with _state_lock:
|
||||
_available, _problem = False, "unreachable"
|
||||
_probe.wake()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def store_io():
|
||||
"""Around a store call in folder mode: an I/O error (other than a file
|
||||
that is not there) or a call over IO_BUDGET marks the folder
|
||||
unreachable."""
|
||||
if _parsed.mode != FOLDER_MODE:
|
||||
yield
|
||||
return
|
||||
started = _clock()
|
||||
try:
|
||||
yield
|
||||
except OSError as error:
|
||||
if not isinstance(error, (FileNotFoundError, NotADirectoryError)):
|
||||
note_failure()
|
||||
raise
|
||||
finally:
|
||||
if _clock() - started > IO_BUDGET:
|
||||
note_failure()
|
||||
|
||||
|
||||
class _Probe:
|
||||
"""The background check of the thumbnail folder (one thread, one check
|
||||
at a time; a stuck check is left to finish on its own thread)."""
|
||||
|
||||
def __init__(self):
|
||||
self._lock = threading.Lock()
|
||||
self._wake = threading.Event()
|
||||
self._stop = threading.Event()
|
||||
self._thread = None
|
||||
self._checking = None
|
||||
|
||||
def check(self, path, expected_id):
|
||||
"""``folder_state`` within IO_BUDGET; raises TimeoutError.
|
||||
|
||||
The answer comes from a check that started after this call: one
|
||||
already running may have looked before what the caller just did, so
|
||||
it is waited for (never run twice at once; a stuck one times out here)
|
||||
and followed by a new one."""
|
||||
key = (path, expected_id)
|
||||
with self._lock:
|
||||
earlier = self._running(key)
|
||||
pending = None if earlier else self._start_check(path, expected_id)
|
||||
if earlier:
|
||||
self._wait(earlier)
|
||||
with self._lock:
|
||||
# One another caller started meanwhile is new enough to join.
|
||||
pending = self._running(key) or self._start_check(path, expected_id)
|
||||
self._wait(pending)
|
||||
with self._lock:
|
||||
if self._checking is pending:
|
||||
self._checking = None
|
||||
if "error" in pending[2]:
|
||||
raise pending[2]["error"]
|
||||
return pending[2]["value"]
|
||||
|
||||
def _running(self, key):
|
||||
"""The check of *key* still running (lock held), else None."""
|
||||
pending = self._checking
|
||||
if pending is None or pending[0] != key or pending[1].is_set():
|
||||
return None
|
||||
return pending
|
||||
|
||||
@staticmethod
|
||||
def _wait(pending):
|
||||
if not pending[1].wait(IO_BUDGET):
|
||||
raise TimeoutError("the thumbnail folder did not answer in time")
|
||||
|
||||
def _start_check(self, path, expected_id):
|
||||
"""Start one check on its own thread (lock held)."""
|
||||
result = {}
|
||||
done = threading.Event()
|
||||
|
||||
def run():
|
||||
try:
|
||||
result["value"] = preview_location.folder_state(path, expected_id)
|
||||
except BaseException as error: # check() re-raises it in the caller
|
||||
result["error"] = error
|
||||
finally:
|
||||
done.set()
|
||||
|
||||
pending = ((path, expected_id), done, result)
|
||||
self._checking = pending
|
||||
threading.Thread(target=run, name=PROBE_THREAD + "-check", daemon=True).start()
|
||||
return pending
|
||||
|
||||
def start(self):
|
||||
with self._lock:
|
||||
if self._thread is not None and self._thread.is_alive():
|
||||
return
|
||||
# Each thread has its own stop: a stopped one never resumes.
|
||||
stop = self._stop = threading.Event()
|
||||
thread = threading.Thread(
|
||||
target=self._run, args=(stop,), name=PROBE_THREAD, daemon=True
|
||||
)
|
||||
try:
|
||||
thread.start()
|
||||
except RuntimeError:
|
||||
return # refused at interpreter exit: no probe
|
||||
self._thread = thread
|
||||
|
||||
def _run(self, stop):
|
||||
while not stop.is_set():
|
||||
self._wake.wait(PROBE_INTERVAL)
|
||||
self._wake.clear()
|
||||
if stop.is_set():
|
||||
return
|
||||
try:
|
||||
refresh()
|
||||
except Exception: # one failed check never stops the probe
|
||||
logger.debug("Thumbnail folder check failed", exc_info=True)
|
||||
|
||||
def wake(self):
|
||||
self._wake.set()
|
||||
|
||||
def stop(self, timeout=2.0):
|
||||
"""Stop the probe; True when its thread ended."""
|
||||
with self._lock:
|
||||
thread, self._thread = self._thread, None
|
||||
self._stop.set()
|
||||
self._wake.set()
|
||||
if thread is None or thread is threading.current_thread():
|
||||
return True
|
||||
thread.join(timeout)
|
||||
return not thread.is_alive()
|
||||
|
||||
|
||||
_probe = _Probe()
|
||||
|
||||
|
||||
def stop_probe(timeout=2.0):
|
||||
"""Stop the folder probe (the package teardown, tests)."""
|
||||
return _probe.stop(timeout)
|
||||
|
||||
|
||||
# ── stores ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def source_folder(root):
|
||||
"""``<source>/thumbnails``: the store in source mode, a leftover in
|
||||
folder mode."""
|
||||
return Path(root) / PREVIEW_FOLDER
|
||||
|
||||
|
||||
def store_container(root, location=None):
|
||||
"""The folder a store of *root* must stay inside: the source itself, or
|
||||
the thumbnail folder in folder mode."""
|
||||
location = location or current()
|
||||
if location.mode == FOLDER_MODE:
|
||||
return Path(location.path) if location.path else None
|
||||
return Path(root)
|
||||
|
||||
|
||||
def _identity(root):
|
||||
return os.path.normcase(os.path.realpath(str(root)))
|
||||
|
||||
|
||||
def _lookup_key(root):
|
||||
spelling = str(root)
|
||||
with _state_lock:
|
||||
if spelling in _key_cache:
|
||||
return _key_cache[spelling]
|
||||
keys = dict(_keys)
|
||||
key = preview_location.find_key(
|
||||
keys,
|
||||
spelling,
|
||||
_identity(root),
|
||||
preview_location.file_identity(root),
|
||||
)
|
||||
if key is not None:
|
||||
with _state_lock:
|
||||
_key_cache[spelling] = key
|
||||
return key
|
||||
|
||||
|
||||
def store_dir(root, location=None):
|
||||
"""The store of *root*: ``<source>/thumbnails``, or in folder mode
|
||||
``<folder>/<key>`` (None while the folder is unavailable or before the
|
||||
source has a key: a reader then finds no preview)."""
|
||||
location = location or current()
|
||||
if location.mode == SOURCE_MODE:
|
||||
return Path(root) / PREVIEW_FOLDER
|
||||
if not location.available or not location.path:
|
||||
return None
|
||||
key = _lookup_key(root)
|
||||
return None if key is None else _key_folder(location.path, key)
|
||||
|
||||
|
||||
def _key_folder(folder, key):
|
||||
"""``<folder>/<key>``, only for a key that is one folder name directly
|
||||
below the thumbnail folder (a marker is data, never a path)."""
|
||||
if not preview_location.valid_key(key):
|
||||
return None
|
||||
store = Path(folder) / key
|
||||
if store.parent != Path(folder) or store.name != key:
|
||||
return None
|
||||
return store
|
||||
|
||||
|
||||
def ensure_store(root, location=None, *, create=False):
|
||||
"""``store_dir`` for a writer: in folder mode a source without a key gets
|
||||
one (recorded in the marker); with *create* its key folder is made (never
|
||||
the thumbnail folder itself). None while the folder is unavailable, and
|
||||
for a key folder that is a link, junction or reparse point: nothing is
|
||||
planned, written or removed through it."""
|
||||
location = location or current()
|
||||
store = store_dir(root, location)
|
||||
if location.mode == SOURCE_MODE or (store is None and not location.available):
|
||||
return store
|
||||
if store is None:
|
||||
store = _assign_key(root, location)
|
||||
if store is None:
|
||||
return None
|
||||
try:
|
||||
with store_io():
|
||||
linked = preview_location.is_link_like(store)
|
||||
except OSError:
|
||||
return None
|
||||
if linked:
|
||||
logger.warning(f"Skipping the thumbnail folder {store}: it is a link")
|
||||
return None
|
||||
if create:
|
||||
try:
|
||||
with store_io():
|
||||
os.mkdir(store)
|
||||
except FileExistsError:
|
||||
pass # already there: another writer made the key folder
|
||||
except OSError:
|
||||
return None
|
||||
return store
|
||||
|
||||
|
||||
def _assign_key(root, location):
|
||||
parsed = _parsed
|
||||
if location.path != parsed.path:
|
||||
return None
|
||||
spelling = str(root)
|
||||
identity = _identity(root)
|
||||
dev_ino = preview_location.file_identity(root)
|
||||
assigned = {}
|
||||
|
||||
def add(marker):
|
||||
if marker.get("id") != parsed.id:
|
||||
raise ValueError("another folder's marker") # marker-mismatch
|
||||
keys = marker["keys"]
|
||||
key = preview_location.find_key(keys, spelling, identity, dev_ino)
|
||||
if key is None:
|
||||
taken = {k: v.get("identity") for k, v in keys.items()}
|
||||
key = preview_location.source_key(spelling, identity, taken)
|
||||
keys[key] = {
|
||||
"path": spelling,
|
||||
"identity": identity,
|
||||
"dev_ino": dev_ino,
|
||||
"install": parsed.install,
|
||||
}
|
||||
assigned["key"] = key
|
||||
|
||||
try:
|
||||
with write_lock, store_io():
|
||||
marker = preview_location.write_marker(location.path, add)
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
_set_keys(parsed, marker)
|
||||
return _key_folder(location.path, assigned["key"])
|
||||
|
||||
|
||||
def may_write(path, location=None):
|
||||
"""The one ownership check every preview write and delete passes.
|
||||
|
||||
Source mode: True (the callers' own root rules apply, as before). Folder
|
||||
mode: *path* lies below a source key folder of the thumbnail folder,
|
||||
lexically and resolved, and no link, junction or reparse point sits
|
||||
anywhere from the key folder down to it (the file itself included).
|
||||
"""
|
||||
location = location or current()
|
||||
if location.mode == SOURCE_MODE:
|
||||
return True
|
||||
if not location.path:
|
||||
return False
|
||||
folder = Path(os.path.abspath(location.path))
|
||||
path = Path(os.path.abspath(path))
|
||||
try:
|
||||
relative = path.relative_to(folder)
|
||||
except ValueError:
|
||||
return False
|
||||
if len(relative.parts) < 2 or ".." in relative.parts:
|
||||
return False
|
||||
store = _key_folder(folder, relative.parts[0])
|
||||
if store is None or not _within(path, folder):
|
||||
return False
|
||||
try:
|
||||
return _no_link_below(store, path)
|
||||
except OSError:
|
||||
note_failure()
|
||||
return False
|
||||
|
||||
|
||||
def make_parent(dst, location=None):
|
||||
"""Make the folders a preview *dst* is written into. In folder mode only
|
||||
folders below the thumbnail folder are made, never the folder itself,
|
||||
and only while it holds this install's marker (an unplugged drive or an
|
||||
unmounted mount point raises FileNotFoundError); a *dst* that does not
|
||||
pass ``may_write`` raises PermissionError."""
|
||||
location = location or current()
|
||||
dst = Path(dst)
|
||||
if location.mode == SOURCE_MODE or not location.path:
|
||||
if location.mode != SOURCE_MODE:
|
||||
raise FileNotFoundError(errno.ENOENT, "thumbnail folder unavailable")
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
return
|
||||
folder = Path(os.path.abspath(location.path))
|
||||
with store_io():
|
||||
if not location.available or not folder.is_dir():
|
||||
raise FileNotFoundError(errno.ENOENT, "thumbnail folder unavailable")
|
||||
marker = preview_location.read_marker(str(folder))
|
||||
if marker is None or marker.get("id") != _parsed.id:
|
||||
note_failure()
|
||||
raise FileNotFoundError(errno.ENOENT, "thumbnail folder unavailable")
|
||||
if not may_write(dst, location):
|
||||
raise PermissionError(errno.EACCES, "outside the thumbnail folder")
|
||||
current_folder = folder
|
||||
for part in Path(os.path.abspath(dst.parent)).relative_to(folder).parts:
|
||||
current_folder = current_folder / part
|
||||
try:
|
||||
os.mkdir(current_folder)
|
||||
except FileExistsError:
|
||||
pass # already there: another writer made this folder
|
||||
if preview_location.is_link_like(current_folder):
|
||||
raise PermissionError(errno.EACCES, "a link in the thumbnail folder")
|
||||
|
||||
|
||||
def store_is_link(store, location=None):
|
||||
"""Whether a store folder is a link (a junction or reparse point too in
|
||||
folder mode): it is skipped by sweeps and refused by clears."""
|
||||
location = location or current()
|
||||
if location.mode == SOURCE_MODE:
|
||||
return Path(store).is_symlink()
|
||||
return preview_location.is_link_like(store)
|
||||
|
||||
|
||||
def given_store(root, store):
|
||||
"""A caller's explicit store argument: the default spelling
|
||||
(``<root>/thumbnails``) means the resolver's store; another folder (only
|
||||
tests pass one) is used as given."""
|
||||
store = Path(store)
|
||||
if store == Path(root) / PREVIEW_FOLDER:
|
||||
return store_dir(root)
|
||||
return store
|
||||
|
||||
|
||||
def preview_path(root, media_rel, ext=None, location=None, *, create=False):
|
||||
"""Where the preview of *media_rel* (relative to *root*) is, or is to be
|
||||
written (*create*: a writer, which gives a new source its key), in *ext*
|
||||
(the extension written now when None)."""
|
||||
if create:
|
||||
store = ensure_store(root, location)
|
||||
else:
|
||||
store = store_dir(root, location)
|
||||
if store is None:
|
||||
return None
|
||||
if ext is None:
|
||||
from .thumbnail_encode import writer_settings
|
||||
|
||||
ext = writer_settings().extension
|
||||
return store / (thumbnail_settings.preview_stem(media_rel) + ext)
|
||||
|
||||
|
||||
def request_path(root, parts, location=None):
|
||||
"""The disk path of the URL parts ``("thumbnails", ...)``, or None when
|
||||
*parts* is not under an exact ``thumbnails`` first part or the store has
|
||||
no place for it. The text is checked before (``is_preview_text``)."""
|
||||
parts = tuple(parts)
|
||||
if len(parts) < 2 or parts[0] != PREVIEW_FOLDER:
|
||||
return None
|
||||
store = store_dir(root, location)
|
||||
if store is None:
|
||||
return None
|
||||
return store.joinpath(*parts[1:])
|
||||
|
||||
|
||||
def _no_link_below(store, path):
|
||||
"""No folder from *store* down to *path* (and *store* itself) is a link,
|
||||
junction or reparse point (lexical walk, one lstat each)."""
|
||||
store = Path(os.path.abspath(store))
|
||||
path = Path(os.path.abspath(path))
|
||||
try:
|
||||
relative = path.relative_to(store)
|
||||
except ValueError:
|
||||
return False
|
||||
current = store
|
||||
for part in ("",) + relative.parts:
|
||||
current = current / part if part else current
|
||||
if preview_location.is_link_like(current):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def owns(root, path, location=None):
|
||||
"""Whether *path* lies inside the store of *root*, resolved: the store
|
||||
stays inside its container and no link takes *path* out of the store.
|
||||
In folder mode no link, junction or reparse point may sit anywhere from
|
||||
the store down to *path*."""
|
||||
location = location or current()
|
||||
store = store_dir(root, location)
|
||||
if store is None:
|
||||
return False
|
||||
container = store_container(root, location)
|
||||
if not (_within(store, container) and _within(path, store)):
|
||||
return False
|
||||
if location.mode == SOURCE_MODE:
|
||||
return True
|
||||
return may_write(path, location)
|
||||
|
||||
|
||||
# ── preview text ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def is_preview_text(filepath):
|
||||
"""URL text naming a preview: ``thumbnails/<...>/<name>_thumb<ext>``.
|
||||
|
||||
Text only, before any join: no backslash, drive or stream colon, and no
|
||||
empty, ``.`` or ``..`` segment; ``thumbnails`` exactly as the first part
|
||||
and nowhere else among the folders; the extension, in any case, one a
|
||||
preview may carry (``X_thumb.JPG`` of an older release included).
|
||||
"""
|
||||
if not filepath or "\\" in filepath or ":" in filepath:
|
||||
return False
|
||||
parts = filepath.split("/")
|
||||
if len(parts) < 2 or parts[0] != PREVIEW_FOLDER:
|
||||
return False
|
||||
if any(part in ("", ".", "..") for part in parts):
|
||||
return False
|
||||
if PREVIEW_FOLDER in parts[1:-1]:
|
||||
return False
|
||||
stem, extension = os.path.splitext(parts[-1])
|
||||
return (
|
||||
stem.endswith(thumbnail_settings.THUMB_SUFFIX)
|
||||
and len(stem) > len(thumbnail_settings.THUMB_SUFFIX)
|
||||
and extension.lower() in thumbnail_settings.THUMBNAIL_EXTENSIONS
|
||||
)
|
||||
|
||||
|
||||
# ── writes ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class _WriteGuard:
|
||||
"""``guard()`` for one preview write: true while the location has not
|
||||
changed since the write started and *hold* (if any) allows it."""
|
||||
|
||||
__slots__ = ("generation", "_hold")
|
||||
|
||||
def __init__(self, hold=None):
|
||||
self.generation = _generation
|
||||
self._hold = hold
|
||||
|
||||
def __call__(self):
|
||||
if self.generation != _generation:
|
||||
return False
|
||||
return self._hold is None or bool(self._hold())
|
||||
|
||||
|
||||
def write_guard(hold=None):
|
||||
"""Take when a preview write starts; hand to its final rename."""
|
||||
if isinstance(hold, _WriteGuard):
|
||||
return hold
|
||||
return _WriteGuard(hold)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def committed(guard=None):
|
||||
"""Around a preview's final rename: holds ``write_lock`` and yields
|
||||
whether the write may land (``guard()``, re-checked under the lock)."""
|
||||
with write_lock:
|
||||
yield guard is None or bool(guard())
|
||||
@@ -9,7 +9,10 @@ candidates and the dependency fingerprint. ``POST .../items/{id}/decision``
|
||||
records one explicit decision: Apply links the chosen sampler's pair only
|
||||
when the capture is the item's bytes, the current file still hashes to them,
|
||||
the evidence they yield is unchanged and the ingester's compatible-only rules
|
||||
allow it; Leave unresolved links nothing.
|
||||
allow it; Leave unresolved links nothing. ``POST .../items/{id}/link`` (#536)
|
||||
links a Library prompt the reviewer chose, under the same capture and
|
||||
compatibility guards; it is their choice, so what the bytes resolve to is not
|
||||
asked.
|
||||
|
||||
Targets are formatted from the recorded spelling against the current roots
|
||||
(no file access beyond the roots' real paths); a file under no current root
|
||||
@@ -28,12 +31,13 @@ import urllib.parse
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from .annotations import _HEX64, _TOKEN, _UUID, _capture_error, _error
|
||||
from .annotations import _capture_error, _error
|
||||
from .images import media_thumbnail_url
|
||||
from .inspection_service import VIDEO_EXTENSIONS
|
||||
from .job_routes import busy_response
|
||||
from .jobs import JobBusy
|
||||
from .prompts import bad_request
|
||||
from .review_guards import UNRESOLVED, _decision_guards, _link_guards
|
||||
|
||||
try:
|
||||
from ...database.library import display_title
|
||||
@@ -59,17 +63,6 @@ MAX_CURSOR = 4096
|
||||
MAX_BODY_BYTES = 64 * 1024
|
||||
# The serialized detail stays under this; past it the detail is ``limited``.
|
||||
MAX_DETAIL_BYTES = 8 * 1024 * 1024
|
||||
UNRESOLVED = "unresolved"
|
||||
_DECISION_FIELDS = frozenset(
|
||||
{
|
||||
"expected_epoch",
|
||||
"expected_revision",
|
||||
"capture_token",
|
||||
"sha256",
|
||||
"dependency_fingerprint",
|
||||
"choice",
|
||||
}
|
||||
)
|
||||
_CURSOR_SECRET = os.urandom(32)
|
||||
# The real path of a spelling; module-level so a test can fake a junction.
|
||||
_realpath = os.path.realpath
|
||||
@@ -84,6 +77,7 @@ _STATUS = {
|
||||
"storage-changed": 409,
|
||||
"source-changed": 409,
|
||||
"capture-expired": 409,
|
||||
"prompt-missing": 409,
|
||||
}
|
||||
_MESSAGES = {
|
||||
"review-missing": "This review item no longer exists. Reload the queue.",
|
||||
@@ -95,6 +89,9 @@ _MESSAGES = {
|
||||
"sampler path cannot be attached safely. The existing links were "
|
||||
"preserved."
|
||||
),
|
||||
"prompt-missing": (
|
||||
"That prompt or recipe is no longer in the Library. Search again."
|
||||
),
|
||||
"dependencies-changed": (
|
||||
"The image's saved links changed since this item was opened. Reopen it "
|
||||
"to see the current association."
|
||||
@@ -251,6 +248,7 @@ class ReviewRoutesMixin:
|
||||
routes.get(ITEMS_URL)(self.list_review_items)
|
||||
routes.get(ITEMS_URL + "/{item_id}")(self.get_review_item)
|
||||
routes.post(ITEMS_URL + "/{item_id}/decision")(self.decide_review_item)
|
||||
routes.post(ITEMS_URL + "/{item_id}/link")(self.link_review_item)
|
||||
|
||||
# -- shared ----------------------------------------------------------------
|
||||
|
||||
@@ -464,6 +462,43 @@ class ReviewRoutesMixin:
|
||||
except JobBusy as busy:
|
||||
return busy_response(busy)
|
||||
|
||||
async def link_review_item(self, request: web.Request) -> web.Response:
|
||||
"""POST the Library prompt the reviewer chose for one pending item.
|
||||
|
||||
Under the same lease as a decision. The capture must be the item's
|
||||
current bytes; the choice and the file's links are checked in the
|
||||
decision's transaction.
|
||||
"""
|
||||
try:
|
||||
async with self._jobs.lease(REVIEW_DECISION_LEASE):
|
||||
return await self._link_review_item(request)
|
||||
except JobBusy as busy:
|
||||
return busy_response(busy)
|
||||
|
||||
async def _link_review_item(self, request: web.Request) -> web.Response:
|
||||
data, error = await self._read_json_body(request, max_bytes=MAX_BODY_BYTES)
|
||||
if error is not None:
|
||||
return error
|
||||
guards, error = _link_guards(data)
|
||||
if error is not None:
|
||||
return error
|
||||
try:
|
||||
item = await self._run_in_executor(
|
||||
self.db.review_item, request.match_info["item_id"]
|
||||
)
|
||||
except Exception:
|
||||
self.logger.exception("Review item read failed")
|
||||
return _failed()
|
||||
if item is None:
|
||||
return _coded("review-missing")
|
||||
verified, error = await self._verified_review_capture(
|
||||
item, guards, need_metadata=True
|
||||
)
|
||||
if error is not None:
|
||||
return error
|
||||
manual = {"positive_id": guards["positive_id"], "pair_id": guards["pair_id"]}
|
||||
return await self._record_decision(item, guards, None, verified, manual)
|
||||
|
||||
async def _decide_review_item(self, request: web.Request) -> web.Response:
|
||||
data, error = await self._read_json_body(request, max_bytes=MAX_BODY_BYTES)
|
||||
if error is not None:
|
||||
@@ -549,22 +584,25 @@ class ReviewRoutesMixin:
|
||||
return None, _coded("file-changed")
|
||||
metadata = None
|
||||
if need_metadata:
|
||||
if captured is None or not _candidate_still_offered(
|
||||
captured[1], guards["choice"]
|
||||
# A manual link has no ``choice``: it rests on the bytes alone.
|
||||
if captured is None or (
|
||||
"choice" in guards
|
||||
and not _candidate_still_offered(captured[1], guards["choice"])
|
||||
):
|
||||
return None, _coded("review-conflict", "evidence-changed")
|
||||
metadata = captured[0]
|
||||
image_path = os.path.join(str(root), *relative.split("/"))
|
||||
return (capture, facts, image_path, metadata), None
|
||||
|
||||
def _decide_sync(self, item, guards, candidate, verified):
|
||||
def _decide_sync(self, item, guards, candidate, verified, manual=None):
|
||||
"""Worker thread: resolve the file's spellings, decide, build the view.
|
||||
|
||||
Returns ``(result, view)``; a refusal raises with ``exc.view`` set.
|
||||
"""
|
||||
_capture, facts, image_path, metadata = verified or (None, None, None, None)
|
||||
aliases, known = (), None
|
||||
if candidate is not None:
|
||||
selected = candidate is not None or manual is not None
|
||||
if selected:
|
||||
aliases, known = physical_spellings(self.db, image_path, item["locator"])
|
||||
try:
|
||||
result = self.db.decide_review_item(
|
||||
@@ -573,8 +611,9 @@ class ReviewRoutesMixin:
|
||||
expected_revision=guards["expected_revision"],
|
||||
# The bytes the re-hash proved, or the body's for Leave unresolved.
|
||||
sha256=facts.sha256 if facts is not None else guards["sha256"],
|
||||
outcome="selected" if candidate is not None else UNRESOLVED,
|
||||
outcome="selected" if selected else UNRESOLVED,
|
||||
candidate=candidate,
|
||||
manual=manual,
|
||||
image_path=image_path,
|
||||
metadata=metadata,
|
||||
expected_fingerprint=guards["dependency_fingerprint"],
|
||||
@@ -589,11 +628,11 @@ class ReviewRoutesMixin:
|
||||
roots, real_roots = self._review_roots()
|
||||
return result, self._review_view(result["item"], roots, real_roots)
|
||||
|
||||
async def _record_decision(self, item, guards, candidate, verified):
|
||||
async def _record_decision(self, item, guards, candidate, verified, manual=None):
|
||||
capture = verified[0] if verified else None
|
||||
try:
|
||||
result, view = await self._run_in_executor(
|
||||
self._decide_sync, item, guards, candidate, verified
|
||||
self._decide_sync, item, guards, candidate, verified, manual
|
||||
)
|
||||
except ReviewCommandError as exc:
|
||||
return self._decision_refused(exc)
|
||||
@@ -629,36 +668,6 @@ class ReviewRoutesMixin:
|
||||
return _coded(exc.code, message, **extra)
|
||||
|
||||
|
||||
def _decision_guards(data: dict):
|
||||
"""The validated decision fields, or ``(None, error)``."""
|
||||
if set(data) != _DECISION_FIELDS:
|
||||
return None, bad_request(
|
||||
"Request must contain exactly " + ", ".join(sorted(_DECISION_FIELDS))
|
||||
)
|
||||
epoch, revision = data["expected_epoch"], data["expected_revision"]
|
||||
if not isinstance(epoch, str) or _UUID.fullmatch(epoch) is None:
|
||||
return None, bad_request("expected_epoch must be a canonical UUID")
|
||||
if type(revision) is not int or revision < 0:
|
||||
return None, bad_request("expected_revision must be a non-negative integer")
|
||||
token = data["capture_token"]
|
||||
if token is not None and (
|
||||
not isinstance(token, str) or _TOKEN.fullmatch(token) is None
|
||||
):
|
||||
return None, bad_request("Invalid capture token")
|
||||
for name in ("sha256", "dependency_fingerprint"):
|
||||
value = data[name]
|
||||
if not isinstance(value, str) or _HEX64.fullmatch(value) is None:
|
||||
return None, bad_request(
|
||||
f"{name} must be 64 lowercase hexadecimal characters"
|
||||
)
|
||||
choice = data["choice"]
|
||||
if not isinstance(choice, str) or (
|
||||
choice != UNRESOLVED and _HEX64.fullmatch(choice) is None
|
||||
):
|
||||
return None, bad_request("choice must be a candidate hash or unresolved")
|
||||
return {**data}, None
|
||||
|
||||
|
||||
def _verify_and_read(store, capture, item, root, need_metadata: bool):
|
||||
"""Worker thread: ``(relative, facts, captured)``, or None off the root.
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Body validation of Needs review decisions and manual links (#249 E3, #536)."""
|
||||
|
||||
from .annotations import _HEX64, _TOKEN, _UUID
|
||||
from .prompts import bad_request
|
||||
|
||||
UNRESOLVED = "unresolved"
|
||||
_GUARD_FIELDS = frozenset(
|
||||
{
|
||||
"expected_epoch",
|
||||
"expected_revision",
|
||||
"capture_token",
|
||||
"sha256",
|
||||
"dependency_fingerprint",
|
||||
}
|
||||
)
|
||||
_DECISION_FIELDS = _GUARD_FIELDS | {"choice"}
|
||||
_LINK_FIELDS = _GUARD_FIELDS | {"positive_id", "pair_id"}
|
||||
|
||||
|
||||
def _guard_error(data: dict, fields: frozenset):
|
||||
"""The first problem with a decision's shared fields, as a response."""
|
||||
if set(data) != fields:
|
||||
return bad_request("Request must contain exactly " + ", ".join(sorted(fields)))
|
||||
epoch, revision = data["expected_epoch"], data["expected_revision"]
|
||||
if not isinstance(epoch, str) or _UUID.fullmatch(epoch) is None:
|
||||
return bad_request("expected_epoch must be a canonical UUID")
|
||||
if type(revision) is not int or revision < 0:
|
||||
return bad_request("expected_revision must be a non-negative integer")
|
||||
token = data["capture_token"]
|
||||
if token is not None and (
|
||||
not isinstance(token, str) or _TOKEN.fullmatch(token) is None
|
||||
):
|
||||
return bad_request("Invalid capture token")
|
||||
for name in ("sha256", "dependency_fingerprint"):
|
||||
value = data[name]
|
||||
if not isinstance(value, str) or _HEX64.fullmatch(value) is None:
|
||||
return bad_request(f"{name} must be 64 lowercase hexadecimal characters")
|
||||
return None
|
||||
|
||||
|
||||
def _decision_guards(data: dict):
|
||||
"""The validated decision fields, or ``(None, error)``."""
|
||||
error = _guard_error(data, _DECISION_FIELDS)
|
||||
if error is not None:
|
||||
return None, error
|
||||
choice = data["choice"]
|
||||
if not isinstance(choice, str) or (
|
||||
choice != UNRESOLVED and _HEX64.fullmatch(choice) is None
|
||||
):
|
||||
return None, bad_request("choice must be a candidate hash or unresolved")
|
||||
return {**data}, None
|
||||
|
||||
|
||||
def _link_guards(data: dict):
|
||||
"""The validated manual link fields, or ``(None, error)``."""
|
||||
error = _guard_error(data, _LINK_FIELDS)
|
||||
if error is not None:
|
||||
return None, error
|
||||
if data["capture_token"] is None:
|
||||
return None, bad_request("A link needs a capture of the file")
|
||||
positive, pair = data["positive_id"], data["pair_id"]
|
||||
if type(positive) is not int or positive < 1:
|
||||
return None, bad_request("positive_id must be a positive integer")
|
||||
if pair is not None and (type(pair) is not int or pair < 1):
|
||||
return None, bad_request("pair_id must be a positive integer or null")
|
||||
return {**data}, None
|
||||
@@ -30,6 +30,7 @@ from . import thumbnail_settings
|
||||
from .admin import _current_image_monitor
|
||||
from .job_routes import busy_response
|
||||
from .jobs import THUMBNAILS_JOB_KIND, JobBusy
|
||||
from .thumbnail_location import needs_move_response
|
||||
|
||||
try:
|
||||
from ...utils.validators import validate_result_timeout, validate_worker_threads
|
||||
@@ -264,6 +265,12 @@ class SettingsRoutesMixin:
|
||||
"sources": source_status,
|
||||
"monitor": monitor,
|
||||
"thumbnail_encoders": encoders,
|
||||
# #320: the full path only here (D6A).
|
||||
"thumbnail_location": await self._run_in_executor(
|
||||
functools.partial(
|
||||
self._thumbnail_location_payload, with_path=True
|
||||
)
|
||||
),
|
||||
"settings": {
|
||||
"monitoring_enabled": GalleryConfig.MONITORING_ENABLED,
|
||||
"save_foreign_runs": GalleryConfig.SAVE_FOREIGN_RUNS,
|
||||
@@ -377,6 +384,9 @@ class SettingsRoutesMixin:
|
||||
if error_response is not None:
|
||||
return error_response
|
||||
|
||||
if "thumbnail_location" in data:
|
||||
# #320: only the Move thumbnails job changes the location.
|
||||
return needs_move_response()
|
||||
expected_revision = data.get("expected_revision")
|
||||
if expected_revision is not None and not isinstance(expected_revision, str):
|
||||
return web.json_response(
|
||||
@@ -481,7 +491,8 @@ class SettingsRoutesMixin:
|
||||
for key in _THUMBNAIL_SETTINGS
|
||||
)
|
||||
job = self._jobs.active(THUMBNAILS_JOB_KIND) if changed else None
|
||||
if job is None or job.scope.get("mode") != "convert":
|
||||
# A move copies previews of the current settings (#320).
|
||||
if job is None or job.scope.get("mode") not in ("convert", "move"):
|
||||
return None
|
||||
return web.json_response(
|
||||
{
|
||||
|
||||
@@ -41,6 +41,13 @@ def _coded(status: int, code: str, message: str) -> web.Response:
|
||||
)
|
||||
|
||||
|
||||
def _missing_if_not_found(response: web.Response) -> web.Response:
|
||||
"""A file in a trash folder answers as a missing file does (#543)."""
|
||||
if response.status == 404:
|
||||
return _coded(404, "file-missing", "Image file not found")
|
||||
return response
|
||||
|
||||
|
||||
def _no_store(response: web.Response) -> web.Response:
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
@@ -71,7 +78,7 @@ class SourceRescopeMixin:
|
||||
path = root / relative
|
||||
denied = _media_access_error(path, [root])
|
||||
if denied is not None:
|
||||
return denied
|
||||
return _missing_if_not_found(denied)
|
||||
try:
|
||||
mtime = path.stat().st_mtime if path.is_file() else None
|
||||
except OSError:
|
||||
@@ -89,7 +96,7 @@ class SourceRescopeMixin:
|
||||
return bad_request("Send exactly one target")
|
||||
target, error = media_target(data["target"])
|
||||
if error is not None:
|
||||
return error
|
||||
return _missing_if_not_found(error)
|
||||
old_index, scope, relative = target
|
||||
if old_index > MAX_ROOT_INDEX:
|
||||
return bad_request("Invalid gallery root")
|
||||
|
||||
@@ -30,6 +30,8 @@ import ntpath
|
||||
import os
|
||||
import re
|
||||
|
||||
from .preview_location import HOLDS_THUMBNAILS_REASON
|
||||
|
||||
logger = logging.getLogger("prompt_manager.api")
|
||||
|
||||
MAX_SOURCE_PATH = 4096 # characters
|
||||
@@ -114,7 +116,26 @@ def source_problem(text):
|
||||
code = source_form_error(text)
|
||||
if code is not None:
|
||||
return code, FORM_REASONS[code]
|
||||
return GalleryConfig.gallery_root_problem(text)
|
||||
code, reason = GalleryConfig.gallery_root_problem(text)
|
||||
if code is None and _holds_thumbnails(GalleryConfig.resolve_gallery_root(text)):
|
||||
return "holds-thumbnails", HOLDS_THUMBNAILS_REASON
|
||||
return code, reason
|
||||
|
||||
|
||||
def _holds_thumbnails(path):
|
||||
"""Whether *path* is inside or around the thumbnail folder in use (#320):
|
||||
a source there would list Prompt Manager's own previews."""
|
||||
from . import preview_store
|
||||
|
||||
try:
|
||||
from ...utils.library_location import _inside_any
|
||||
except ImportError: # tests import the package top-level
|
||||
from utils.library_location import _inside_any
|
||||
|
||||
setting = preview_store.setting()
|
||||
if setting.mode != preview_store.FOLDER_MODE or not setting.path:
|
||||
return False
|
||||
return _inside_any(path, [setting.path]) or _inside_any(setting.path, [path])
|
||||
|
||||
|
||||
def public_spelling(text):
|
||||
|
||||
@@ -8,7 +8,8 @@ previews behind those URLs are made here, in three ways:
|
||||
- **On first view.** ``GET /prompt_manager/images/serve/thumbnails/<rel>_thumb
|
||||
<ext>`` for a preview that does not exist yet is admitted on the event loop
|
||||
(``_PreviewGate``): at most ``ON_DEMAND_LIMIT`` previews are written at once,
|
||||
on this API's own small pool (never the default executor), one flight runs
|
||||
on this API's interactive image pool (``IMAGE_POOL_SIZE`` threads, shared
|
||||
with card facts and inspector reads; never the default executor), one flight runs
|
||||
per file, and the requests waiting are capped (``MAX_WAITING``); past the
|
||||
cap a request gets its fallback at once. Inside the admission the preview is
|
||||
mapped back to its media file under the same root (the request text is
|
||||
@@ -24,7 +25,8 @@ previews behind those URLs are made here, in three ways:
|
||||
- **On capture.** The image monitor hands every new output it settles to
|
||||
``queue_capture_thumbnail``, which queues its preview for one background
|
||||
worker thread (``AutoThumbnailer``). Each wake takes at most Worker threads
|
||||
(``WORKER_THREADS``) files and writes them on that many threads; enqueuing
|
||||
(``WORKER_THREADS``) files and writes them on the long-lived job pool
|
||||
(``utils.parallel``, shared with scans and thumbnail jobs); enqueuing
|
||||
is O(1), so the monitor and ComfyUI's queue never wait. A file already
|
||||
queued is not queued again, the queue is bounded (``MAX_QUEUED``) and a
|
||||
preview left out is made on its first view instead.
|
||||
@@ -49,7 +51,7 @@ write starts (after its slot) and again just before its rename.
|
||||
|
||||
Shutdown: ``close_auto_thumbnails`` stops the worker at a file boundary,
|
||||
kills a running ffmpeg, waits a bounded time (and logs when the wait ran
|
||||
out) and shuts the view pool down; after the app's cleanup
|
||||
out) and shuts the image pool down; after the app's cleanup
|
||||
(``final=True``) nothing starts a worker again. Importing this module again
|
||||
(a package reload) stops the workers the previous import left.
|
||||
"""
|
||||
@@ -60,13 +62,14 @@ import functools
|
||||
import io
|
||||
import os
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from concurrent.futures import CancelledError, ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from . import thumbnail_settings
|
||||
from . import preview_store, thumbnail_settings
|
||||
from .images import (
|
||||
in_trash_below_roots,
|
||||
MEDIA_EXTENSIONS,
|
||||
THUMBNAIL_CLEAR_TITLE,
|
||||
VIDEO_EXTENSIONS,
|
||||
@@ -75,7 +78,7 @@ from .images import (
|
||||
_thumbnail_is_fresh,
|
||||
_video_placeholder_image,
|
||||
_worker_threads,
|
||||
media_thumbnail_rel,
|
||||
media_preview_path,
|
||||
)
|
||||
from .jobs import (
|
||||
DUPLICATE_CLEANUP_JOB_KIND,
|
||||
@@ -103,8 +106,11 @@ PLACEHOLDER_SIZE = (thumbnail_settings.DEFAULT_EDGE, thumbnail_settings.DEFAULT_
|
||||
PLACEHOLDER_QUALITY = 85
|
||||
# Source extensions in the order a shared preview name looks for them (R2).
|
||||
_SOURCE_ORDER = thumbnail_settings.IMAGE_SOURCE_ORDER + tuple(sorted(VIDEO_EXTENSIONS))
|
||||
# Previews views may write at once (the size of the view pool).
|
||||
ON_DEMAND_LIMIT = 3
|
||||
# Threads of the interactive image pool (#523): view previews, card facts and
|
||||
# inspector reads. Fixed, so image buffers stay in a few threads' arenas.
|
||||
IMAGE_POOL_SIZE = 3
|
||||
# Previews views may write at once (the whole image pool).
|
||||
ON_DEMAND_LIMIT = IMAGE_POOL_SIZE
|
||||
# View requests that may wait on the gate; one more gets its fallback at once.
|
||||
MAX_WAITING = 64
|
||||
# How long a pool thread waits for the capture worker's flight of the same file.
|
||||
@@ -123,7 +129,7 @@ AUTO_REFUSED_KINDS = frozenset(
|
||||
{RESTORE_JOB_KIND, DUPLICATE_CLEANUP_JOB_KIND, SOURCE_CHANGE_JOB_KIND}
|
||||
)
|
||||
WORKER_NAME = "PromptManagerThumbnailWorker"
|
||||
POOL_PREFIX = "pm-preview"
|
||||
POOL_PREFIX = "pm-image"
|
||||
# Identifies this import's workers: a reload stops the ones it did not start.
|
||||
_MODULE_TOKEN = object()
|
||||
|
||||
@@ -152,6 +158,36 @@ def _dst_key(dst):
|
||||
return flight_key(Path(dst).parent, (Path(dst).name,))
|
||||
|
||||
|
||||
class DeferredPreview:
|
||||
"""The preview of *rel* under *root*, resolved when its write starts
|
||||
(#320): a queued capture follows the thumbnail location at write time,
|
||||
not the one when it was queued."""
|
||||
|
||||
__slots__ = ("root", "rel", "strict")
|
||||
|
||||
def __init__(self, root, rel, *, strict=True):
|
||||
self.root = Path(root)
|
||||
self.rel = Path(rel)
|
||||
# A capture refuses a store linked out of its container (the serve
|
||||
# route's rule); a planned target keeps the plan's rule: inside the
|
||||
# store, as the thumbnail job writes it.
|
||||
self.strict = strict
|
||||
|
||||
def resolve(self):
|
||||
"""The preview's path now, or None when nothing may be written."""
|
||||
dst = media_preview_path(self.root, self.rel, create=True)
|
||||
if dst is None:
|
||||
return None
|
||||
if self.strict:
|
||||
return dst if preview_store.owns(self.root, dst) else None
|
||||
store = preview_store.store_dir(self.root)
|
||||
return dst if _is_within(dst, store) else None
|
||||
|
||||
|
||||
def _resolved(dst):
|
||||
return dst.resolve() if isinstance(dst, DeferredPreview) else Path(dst)
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def video_placeholder_jpeg():
|
||||
"""The placeholder frame as JPEG bytes (a video whose preview is missing)."""
|
||||
@@ -209,10 +245,19 @@ def _candidates(parts):
|
||||
def _current_preview(root, parts):
|
||||
"""The preview *parts* names, in the format written now (#317): a request
|
||||
for an older name (``_thumb.png`` before a conversion) is answered with
|
||||
the current file."""
|
||||
the current file. None when the store of *root* has no place for it."""
|
||||
_folder, base, _wanted = _candidates(parts)
|
||||
name = base + thumbnail_settings.THUMB_SUFFIX + writer_settings().extension
|
||||
return Path(root).joinpath(*parts[:-1], name)
|
||||
return preview_store.request_path(root, (*parts[:-1], name))
|
||||
|
||||
|
||||
def _request_key(root, parts):
|
||||
"""The flight key of the preview *parts* under *root*: the key of its
|
||||
disk path (so the serve and history routes share one flight)."""
|
||||
path = preview_store.request_path(root, parts)
|
||||
if path is None:
|
||||
return flight_key(root, parts)
|
||||
return _dst_key(path)
|
||||
|
||||
|
||||
def _quick_source(root, parts):
|
||||
@@ -224,6 +269,8 @@ def _quick_source(root, parts):
|
||||
for name in (base + extension, base + extension.upper()):
|
||||
path = folder / name
|
||||
if _regular_file(path) and _is_within(path, root):
|
||||
if in_trash_below_roots(path, [root]):
|
||||
return None # also the fallback past the gate's cap (#543)
|
||||
return path, extension in VIDEO_EXTENSIONS
|
||||
return None
|
||||
|
||||
@@ -248,13 +295,17 @@ def _thumbnail_source(root, parts):
|
||||
found = _scan_folder(root.joinpath(*folder_parts), base, set(wanted))
|
||||
if found is None or not _is_within(found, root):
|
||||
return None
|
||||
if in_trash_below_roots(found, [root]):
|
||||
return None
|
||||
rel = Path(*folder_parts, found.name)
|
||||
dst = root / media_thumbnail_rel(rel)
|
||||
dst = media_preview_path(root, rel, create=True)
|
||||
if dst is None:
|
||||
# Nowhere to write (the thumbnail folder is unavailable): the
|
||||
# original is served and nothing is written.
|
||||
return found, None, found.suffix.lower() in VIDEO_EXTENSIONS
|
||||
if dst != _current_preview(root, parts):
|
||||
return None
|
||||
if not _is_within(root / "thumbnails", root) or not _is_within(
|
||||
dst, root / "thumbnails"
|
||||
):
|
||||
if not preview_store.owns(root, dst):
|
||||
dst = None
|
||||
return found, dst, found.suffix.lower() in VIDEO_EXTENSIONS
|
||||
|
||||
@@ -274,6 +325,21 @@ def _scan_folder(folder, base, extensions):
|
||||
return None
|
||||
|
||||
|
||||
def _deferred(roots, src, dst):
|
||||
"""The ``DeferredPreview`` of a planned ``(src, dst)``: the root whose
|
||||
store the plan wrote *dst* into (nested roots plan one each); *dst* as
|
||||
planned when none matches."""
|
||||
src = Path(src)
|
||||
for root in roots:
|
||||
root = Path(root)
|
||||
if not src.is_relative_to(root):
|
||||
continue
|
||||
rel = src.relative_to(root)
|
||||
if media_preview_path(root, rel) == Path(dst):
|
||||
return DeferredPreview(root, rel, strict=False)
|
||||
return dst
|
||||
|
||||
|
||||
def _stop_stale_workers():
|
||||
"""Stop capture workers a previous import of this module started."""
|
||||
for thread in threading.enumerate():
|
||||
@@ -289,7 +355,7 @@ class AutoThumbnailer:
|
||||
``generate(src, dst, is_video, *, stop, guard, track)`` writes one
|
||||
preview and returns ``_generate_one``'s result dict; ``may_write()``
|
||||
says whether automatic previews may be written now. Views call
|
||||
``ensure`` on the view pool, the monitor calls ``enqueue`` from its
|
||||
``ensure`` on the image pool, the monitor calls ``enqueue`` from its
|
||||
thread.
|
||||
"""
|
||||
|
||||
@@ -415,16 +481,23 @@ class AutoThumbnailer:
|
||||
|
||||
def enqueue(self, src, dst, is_video):
|
||||
"""Queue *dst*'s preview for the worker; False when already queued,
|
||||
full, closed or its worker is refused at interpreter exit. Never
|
||||
blocks on a write."""
|
||||
key = _dst_key(dst)
|
||||
full, closed, nowhere to write it or its worker is refused at
|
||||
interpreter exit. Never blocks on a write. *dst* may be a
|
||||
``DeferredPreview``: its path is resolved again when the write
|
||||
starts."""
|
||||
now = _resolved(dst)
|
||||
if now is None:
|
||||
return False
|
||||
key = _dst_key(now)
|
||||
if not isinstance(dst, DeferredPreview):
|
||||
dst = now
|
||||
with self._changed:
|
||||
if self._closed or key in self._queued:
|
||||
return False
|
||||
if len(self._queue) >= self._max_queued:
|
||||
return False
|
||||
self._queued.add(key)
|
||||
self._queue.append((key, Path(src), Path(dst), bool(is_video)))
|
||||
self._queue.append((key, Path(src), dst, bool(is_video)))
|
||||
self._busy = True
|
||||
started = True
|
||||
if self._start_worker and not self._worker_running:
|
||||
@@ -486,12 +559,15 @@ class AutoThumbnailer:
|
||||
self._worker_running = False
|
||||
self._changed.notify_all()
|
||||
return
|
||||
take = min(len(self._queue), max(1, int(self._workers() or 1)))
|
||||
workers = max(1, int(self._workers() or 1))
|
||||
take = min(len(self._queue), workers)
|
||||
batch = [self._queue.popleft() for _ in range(take)]
|
||||
self._batch = len(batch)
|
||||
self._changed.notify_all()
|
||||
try:
|
||||
map_parallel(self._write_queued, batch, len(batch))
|
||||
# The job pool's size is the setting; a smaller batch uses
|
||||
# fewer of its threads (#523: one long-lived pool).
|
||||
map_parallel(self._write_queued, batch, workers)
|
||||
finally:
|
||||
with self._changed:
|
||||
self._batch = 0
|
||||
@@ -501,6 +577,9 @@ class AutoThumbnailer:
|
||||
def _write_queued(self, item):
|
||||
_key, src, dst, is_video = item
|
||||
try:
|
||||
dst = _resolved(dst)
|
||||
if dst is None:
|
||||
return # nowhere to write it now: made on its first view
|
||||
self.ensure(src, dst, is_video)
|
||||
except Exception: # one file never stops the worker
|
||||
self._logger.warning(
|
||||
@@ -528,6 +607,7 @@ class AutoThumbnailer:
|
||||
try:
|
||||
process.kill()
|
||||
except OSError:
|
||||
# the process has already exited
|
||||
pass
|
||||
if worker is None:
|
||||
return True
|
||||
@@ -561,7 +641,7 @@ class _PreviewGate:
|
||||
"""Admission of view requests on the event loop (#249 I2).
|
||||
|
||||
Waiters are coroutines: at most ``limit`` previews are written at once
|
||||
on the view pool, one flight runs per key, and past ``MAX_WAITING``
|
||||
on the image pool, one flight runs per key, and past ``MAX_WAITING``
|
||||
waiting requests ``run`` answers ``OVER`` at once.
|
||||
"""
|
||||
|
||||
@@ -616,13 +696,17 @@ class _PreviewGate:
|
||||
flight.set_result(result)
|
||||
|
||||
|
||||
def _on_image_thread():
|
||||
return threading.current_thread().name.startswith(POOL_PREFIX + "_")
|
||||
|
||||
|
||||
class AutoThumbnailMixin:
|
||||
"""Automatic previews on the API: views, the monitor's queue, scans."""
|
||||
|
||||
_auto_thumbnailer = None
|
||||
_auto_thumbnails_shut = False
|
||||
_preview_gate_obj = None
|
||||
_preview_pool_obj = None
|
||||
_image_pool_obj = None
|
||||
_auto_thumbnailer_lock = threading.Lock()
|
||||
|
||||
def _auto_thumbnails(self):
|
||||
@@ -645,7 +729,7 @@ class AutoThumbnailMixin:
|
||||
self._auto_thumbnails_shut = True
|
||||
|
||||
def close_auto_thumbnails(self, final=False, timeout=None):
|
||||
"""Stop the capture worker and the view pool, and forget them.
|
||||
"""Stop the capture worker and the image pool, and forget them.
|
||||
|
||||
Bounded by ``timeout`` (see AutoThumbnailer.close). ``final`` (the
|
||||
package teardown) also keeps a late monitor event from starting a
|
||||
@@ -656,7 +740,7 @@ class AutoThumbnailMixin:
|
||||
if final:
|
||||
self._auto_thumbnails_shut = True
|
||||
thumbnailer, self._auto_thumbnailer = self._auto_thumbnailer, None
|
||||
pool, self._preview_pool_obj = self._preview_pool_obj, None
|
||||
pool, self._image_pool_obj = self._image_pool_obj, None
|
||||
self._preview_gate_obj = None
|
||||
closed = True
|
||||
if thumbnailer is not None:
|
||||
@@ -665,6 +749,8 @@ class AutoThumbnailMixin:
|
||||
pool.shutdown(wait=False, cancel_futures=True)
|
||||
if final:
|
||||
shutdown_encoders() # kill JPEG XL / ffmpeg encoders still running
|
||||
# The thumbnail folder's probe (#320), bounded like the rest.
|
||||
closed = preview_store.stop_probe() and closed
|
||||
return closed
|
||||
|
||||
def _auto_thumbnail_failures(self):
|
||||
@@ -689,6 +775,9 @@ class AutoThumbnailMixin:
|
||||
return job
|
||||
if job.kind == THUMBNAILS_JOB_KIND and job.title == THUMBNAIL_CLEAR_TITLE:
|
||||
return job
|
||||
# Move thumbnails holds automatic previews for its whole run (D2A).
|
||||
if job.kind == THUMBNAILS_JOB_KIND and job.scope.get("mode") == "move":
|
||||
return job
|
||||
return None
|
||||
|
||||
# ── the view admission ────────────────────────────────────────────
|
||||
@@ -700,16 +789,67 @@ class AutoThumbnailMixin:
|
||||
gate = self._preview_gate_obj = _PreviewGate(ON_DEMAND_LIMIT, self.logger)
|
||||
return gate
|
||||
|
||||
def _preview_pool(self):
|
||||
with AutoThumbnailMixin._auto_thumbnailer_lock:
|
||||
if self._preview_pool_obj is None:
|
||||
self._preview_pool_obj = ThreadPoolExecutor(
|
||||
ON_DEMAND_LIMIT, thread_name_prefix=POOL_PREFIX
|
||||
)
|
||||
return self._preview_pool_obj
|
||||
def _image_pool(self):
|
||||
"""The interactive image pool (#523): IMAGE_POOL_SIZE threads for view
|
||||
previews (behind their gate), card facts and inspector reads, so
|
||||
image buffers never spread over the default executor's threads.
|
||||
|
||||
def _preview_pool_threads(self):
|
||||
pool = self._preview_pool_obj
|
||||
None after the package teardown (``close_auto_thumbnails(final=True)``):
|
||||
a late request then runs on the default executor (``run_in_executor``
|
||||
with None) and starts no thread the teardown no longer stops.
|
||||
"""
|
||||
with AutoThumbnailMixin._auto_thumbnailer_lock:
|
||||
if self._image_pool_obj is None and not self._auto_thumbnails_shut:
|
||||
self._image_pool_obj = ThreadPoolExecutor(
|
||||
IMAGE_POOL_SIZE, thread_name_prefix=POOL_PREFIX
|
||||
)
|
||||
return self._image_pool_obj
|
||||
|
||||
async def _run_image(self, func, *args, **kwargs):
|
||||
"""``_run_in_executor`` for interactive image work: the image pool.
|
||||
|
||||
After the teardown, or with the pool shut down in between (a test
|
||||
reset), the call runs on the default executor as before.
|
||||
"""
|
||||
call = functools.partial(func, *args, **kwargs)
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
future = loop.run_in_executor(self._image_pool(), call)
|
||||
except RuntimeError: # submit refused: the pool was shut down
|
||||
future = loop.run_in_executor(None, call)
|
||||
return await future
|
||||
|
||||
def _map_image(self, func, items):
|
||||
"""``func`` over *items* on the image pool, in order (blocking).
|
||||
|
||||
Called from an image pool thread, after the teardown, or with the
|
||||
pool shut down in between (its queued items are cancelled), the
|
||||
items it did not run run on the calling thread.
|
||||
"""
|
||||
items = list(items)
|
||||
pool = None if not items or _on_image_thread() else self._image_pool()
|
||||
if pool is None:
|
||||
return [func(item) for item in items]
|
||||
futures = []
|
||||
try:
|
||||
for item in items:
|
||||
futures.append(pool.submit(func, item))
|
||||
except RuntimeError:
|
||||
pass # shut down in between: the rest run here
|
||||
results = []
|
||||
for index, item in enumerate(items):
|
||||
future = futures[index] if index < len(futures) else None
|
||||
if future is None or future.cancelled():
|
||||
results.append(func(item))
|
||||
continue
|
||||
try:
|
||||
results.append(future.result())
|
||||
except CancelledError: # cancelled while this caller waited
|
||||
results.append(func(item))
|
||||
return results
|
||||
|
||||
def _image_pool_threads(self):
|
||||
pool = self._image_pool_obj
|
||||
return [] if pool is None else list(getattr(pool, "_threads", ()))
|
||||
|
||||
def _video_placeholder_bytes(self):
|
||||
@@ -724,7 +864,7 @@ class AutoThumbnailMixin:
|
||||
return await self._preview_gate().run(
|
||||
flight_key(Path(dst).parent, (Path(dst).name,)),
|
||||
functools.partial(self._ensure_view, src, dst, is_video),
|
||||
self._preview_pool(),
|
||||
self._image_pool(),
|
||||
)
|
||||
|
||||
def _ensure_view(self, src, dst, is_video):
|
||||
@@ -768,17 +908,17 @@ class AutoThumbnailMixin:
|
||||
The flight answers only the outcome (a history flight may be the one
|
||||
joined); the file it previews is derived here: the preview path is
|
||||
the request's own, and a fallback looks its source up again on the
|
||||
view pool (bounded by it, never the default executor).
|
||||
image pool (bounded by it, never the default executor).
|
||||
"""
|
||||
if _has_traversal(filepath) or not _is_preview_request(filepath):
|
||||
return None
|
||||
parts = tuple(filepath.split("/"))
|
||||
gate = self._preview_gate()
|
||||
pool = self._preview_pool()
|
||||
pool = self._image_pool()
|
||||
for root in output_dirs:
|
||||
root = Path(root)
|
||||
outcome = await gate.run(
|
||||
flight_key(root, parts),
|
||||
_request_key(root, parts),
|
||||
functools.partial(self._preview_on_demand, root, parts),
|
||||
pool,
|
||||
)
|
||||
@@ -791,7 +931,7 @@ class AutoThumbnailMixin:
|
||||
continue
|
||||
return self._preview_response(BUSY, quick[0], None, quick[1])
|
||||
dst = _current_preview(root, parts)
|
||||
if outcome == READY:
|
||||
if outcome == READY and dst is not None:
|
||||
return self._file_response(dst)
|
||||
located = await gate.loop.run_in_executor(
|
||||
pool, _thumbnail_source, root, parts
|
||||
@@ -814,17 +954,31 @@ class AutoThumbnailMixin:
|
||||
"""
|
||||
if self._auto_thumbnails_shut:
|
||||
return "skipped"
|
||||
located = self._capture_target(image_path)
|
||||
located = self._capture_source(image_path)
|
||||
if located is None:
|
||||
return "skipped"
|
||||
src, dst, is_video = located
|
||||
src, root, rel, is_video = located
|
||||
dst = DeferredPreview(root, rel).resolve()
|
||||
if dst is None:
|
||||
return "skipped"
|
||||
if _thumbnail_is_fresh(src, dst):
|
||||
return "fresh"
|
||||
if self._auto_thumbnails().enqueue(src, dst, is_video):
|
||||
if self._auto_thumbnails().enqueue(src, DeferredPreview(root, rel), is_video):
|
||||
return "queued"
|
||||
return "skipped"
|
||||
|
||||
def _capture_target(self, image_path):
|
||||
"""``(src, dst, is_video)`` of a new output's preview now, or None."""
|
||||
located = self._capture_source(image_path)
|
||||
if located is None:
|
||||
return None
|
||||
src, root, rel, is_video = located
|
||||
dst = DeferredPreview(root, rel).resolve()
|
||||
return None if dst is None else (src, dst, is_video)
|
||||
|
||||
def _capture_source(self, image_path):
|
||||
"""``(src, root, rel, is_video)`` of a media file under a current
|
||||
root (not a preview, a link or in a trash folder), or None."""
|
||||
path = Path(os.path.abspath(str(image_path)))
|
||||
if path.suffix.lower() not in MEDIA_EXTENSIONS or not _regular_file(path):
|
||||
return None
|
||||
@@ -835,14 +989,9 @@ class AutoThumbnailMixin:
|
||||
if not folded.startswith(base) or not _is_within(path, root):
|
||||
continue
|
||||
rel = Path(os.path.relpath(path, root))
|
||||
if "thumbnails" in rel.parts[:-1]:
|
||||
if "thumbnails" in rel.parts[:-1] or in_trash_below_roots(path, [root]):
|
||||
return None
|
||||
dst = root / media_thumbnail_rel(rel)
|
||||
if not _is_within(root / "thumbnails", root) or not _is_within(
|
||||
dst, root / "thumbnails"
|
||||
):
|
||||
return None
|
||||
return path, dst, path.suffix.lower() in VIDEO_EXTENSIONS
|
||||
return path, root, rel, path.suffix.lower() in VIDEO_EXTENSIONS
|
||||
return None
|
||||
|
||||
# ── after a scan ──────────────────────────────────────────────────
|
||||
@@ -884,7 +1033,7 @@ class AutoThumbnailMixin:
|
||||
return "none"
|
||||
thumbnailer = self._auto_thumbnails()
|
||||
for src, dst, is_video in plan["targets"]:
|
||||
thumbnailer.enqueue(src, dst, is_video)
|
||||
thumbnailer.enqueue(src, _deferred(output_dirs, src, dst), is_video)
|
||||
self.logger.info(f"{missing:,} previews missing after the scan; queued")
|
||||
return "queued"
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from . import thumbnail_settings
|
||||
from . import preview_store, thumbnail_settings
|
||||
from .thumbnail_encode import ThumbnailVerifyError, verify_thumbnail, writer_settings
|
||||
from .admin import _plural, _scan_location, _scan_roots, _scan_status_line
|
||||
from .images import _thumbnail_is_fresh, _worker_threads, _thumbnail_batch_size
|
||||
@@ -153,6 +153,7 @@ class ThumbnailConvertMixin:
|
||||
job.log_line("Conversion failed · internal error")
|
||||
state = "failed"
|
||||
job.finish(state)
|
||||
await self._release_job_memory()
|
||||
|
||||
async def _convert_job_body(self, job, options):
|
||||
output_dirs = await self._run_in_executor(self._get_all_output_dirs)
|
||||
@@ -392,6 +393,12 @@ class ThumbnailConvertMixin:
|
||||
removed, with no bytes (another run or a sibling removed it)."""
|
||||
removed, kept = 0, []
|
||||
for path, size in old:
|
||||
if not preview_store.may_write(path):
|
||||
# Folder mode: never a file outside a key folder, or one
|
||||
# reached through a link (checked at the moment of the delete).
|
||||
self.logger.warning(f"Not removing {path}: outside the thumbnails")
|
||||
kept.append(path)
|
||||
continue
|
||||
try:
|
||||
os.remove(path)
|
||||
except FileNotFoundError:
|
||||
|
||||
@@ -46,7 +46,7 @@ from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from . import thumbnail_settings
|
||||
from . import preview_location, preview_store, thumbnail_settings
|
||||
|
||||
try:
|
||||
from ...utils.logging_config import get_logger
|
||||
@@ -101,14 +101,20 @@ def temp_sibling(dst, tag="part"):
|
||||
|
||||
|
||||
def replace_unless_held(tmp, dst, guard=None):
|
||||
"""Rename *tmp* over *dst*, unless ``guard()`` is false (PreviewWriteHeld)."""
|
||||
if guard is not None and not guard():
|
||||
raise PreviewWriteHeld(PREVIEW_HELD)
|
||||
os.replace(tmp, dst)
|
||||
"""Rename *tmp* over *dst*, unless ``guard()`` is false (PreviewWriteHeld).
|
||||
|
||||
The check and the rename run under ``preview_store.write_lock`` (#320): a
|
||||
guard from ``preview_store.write_guard`` also refuses a write that started
|
||||
before the thumbnail location changed."""
|
||||
with preview_store.committed(guard) as allowed:
|
||||
if not allowed:
|
||||
raise PreviewWriteHeld(PREVIEW_HELD)
|
||||
os.replace(tmp, dst)
|
||||
|
||||
|
||||
# ``temp_sibling``'s names: ``.<stem>.<tag>-<pid>-<thread id><ext>``.
|
||||
_TEMP_NAME = re.compile(r"^\..+\.(?:part|src)-(\d+)-\d+\.[A-Za-z0-9]+$")
|
||||
_MOVE_TEMP_NAME = re.compile(r"\.pm-move-[0-9a-f]{8}\.tmp$")
|
||||
# A temp at least this old (seconds) outlived its write: no encode runs longer.
|
||||
STRAY_TEMP_AGE = 4 * ENCODE_TIMEOUT
|
||||
# When this process started (this module's import): a temp carrying its PID
|
||||
@@ -120,7 +126,10 @@ def sweep_stray_temps(folder, min_age=None):
|
||||
"""Remove the temp files a crashed write left under *folder* (a preview
|
||||
folder, walked without following links); returns ``(count, bytes)``.
|
||||
|
||||
Only ``temp_sibling`` names are touched, never a link. With *min_age*
|
||||
Only ``temp_sibling`` names are touched, never a link: a folder that is a
|
||||
link, junction or reparse point is not walked into (Windows walks into a
|
||||
junction even without ``followlinks``), and each file passes
|
||||
``preview_store.may_write`` before it goes. With *min_age*
|
||||
(seconds, the startup sweep) a temp younger than that, or one this
|
||||
process wrote since it started (it may still be writing it), is left;
|
||||
``min_age=0`` (the clear, which holds every preview write) removes them
|
||||
@@ -130,19 +139,39 @@ def sweep_stray_temps(folder, min_age=None):
|
||||
now = time.time()
|
||||
own = str(os.getpid())
|
||||
count = size = 0
|
||||
for root, _dirs, files in os.walk(folder):
|
||||
try:
|
||||
if preview_location.is_link_like(folder):
|
||||
return count, size
|
||||
except OSError as exc:
|
||||
logger.debug(f"Not sweeping {folder}: {exc}")
|
||||
return count, size
|
||||
for root, dirs, files in os.walk(folder):
|
||||
dirs[:] = [d for d in dirs if not _link_like(Path(root) / d)]
|
||||
for name in files:
|
||||
match = _TEMP_NAME.match(name)
|
||||
if match is None:
|
||||
# A move's copy temp (#320): ``<name>.pm-move-<8 hex>.tmp``.
|
||||
move_temp = match is None and _MOVE_TEMP_NAME.search(name)
|
||||
if match is None and not move_temp:
|
||||
continue
|
||||
path = Path(root) / name
|
||||
try:
|
||||
stat = path.lstat()
|
||||
if path.is_symlink() or not path.is_file():
|
||||
if (
|
||||
path.is_symlink()
|
||||
or not path.is_file()
|
||||
or _link_like(path)
|
||||
or not preview_store.may_write(path)
|
||||
):
|
||||
continue
|
||||
if min_age and (
|
||||
now - stat.st_mtime < min_age
|
||||
or (match.group(1) == own and stat.st_mtime >= PROCESS_STARTED)
|
||||
or move_temp
|
||||
and stat.st_mtime >= PROCESS_STARTED
|
||||
or (
|
||||
match is not None
|
||||
and match.group(1) == own
|
||||
and stat.st_mtime >= PROCESS_STARTED
|
||||
)
|
||||
):
|
||||
continue
|
||||
path.unlink()
|
||||
@@ -154,10 +183,19 @@ def sweep_stray_temps(folder, min_age=None):
|
||||
return count, size
|
||||
|
||||
|
||||
def _link_like(path):
|
||||
"""``is_link_like``, with a path that cannot be checked counted as one."""
|
||||
try:
|
||||
return preview_location.is_link_like(path)
|
||||
except OSError:
|
||||
return True
|
||||
|
||||
|
||||
def _unlink_quietly(path):
|
||||
try:
|
||||
Path(path).unlink()
|
||||
except FileNotFoundError:
|
||||
# already gone, which is what was wanted
|
||||
pass
|
||||
except OSError as exc:
|
||||
logger.debug(f"Could not remove temp file {Path(path).name}: {exc}")
|
||||
@@ -221,6 +259,7 @@ def shutdown_encoders():
|
||||
try:
|
||||
process.kill()
|
||||
except OSError:
|
||||
# the process has already exited
|
||||
pass
|
||||
|
||||
|
||||
@@ -359,10 +398,12 @@ def thumbnail_frame(img, edge):
|
||||
try:
|
||||
img.seek(0)
|
||||
except (EOFError, AttributeError, ValueError):
|
||||
# not seekable or a single frame: use the current one
|
||||
pass
|
||||
try:
|
||||
img.draft("RGB", (edge, edge)) # JPEG: decode at a reduced scale
|
||||
except (AttributeError, ValueError, OSError):
|
||||
# draft is only a speedup; the full decode below still works
|
||||
pass
|
||||
frame = img.convert("RGB") if img.mode != "RGB" else img.copy()
|
||||
frame.thumbnail((edge, edge), Image.Resampling.LANCZOS)
|
||||
|
||||
@@ -49,6 +49,7 @@ from .images import (
|
||||
_worker_threads,
|
||||
)
|
||||
from .job_routes import busy_response
|
||||
from .thumbnail_location import location_refusal
|
||||
from .jobs import THUMBNAIL_GROUP_NAMES, THUMBNAILS_JOB_KIND, JobBusy
|
||||
from .thumbnail_convert import CONVERT_JOB_TITLE
|
||||
from .thumbnail_encode import writer_settings
|
||||
@@ -131,7 +132,10 @@ class ThumbnailJobsMixin:
|
||||
"""The thumbnail plan, the thumbnail job and the legacy stream on it."""
|
||||
|
||||
async def get_thumbnail_plan(self, request):
|
||||
"""What a thumbnail run would do (read-only; capped like a run)."""
|
||||
"""What a thumbnail run would do (read-only; capped like a run).
|
||||
``mode=prune`` lists the removed sources' thumbnails (#320)."""
|
||||
if request.query.get("mode") == "prune":
|
||||
return await self.get_prune_plan()
|
||||
options, error = thumbnail_options(request.query, from_query=True)
|
||||
if error:
|
||||
return _invalid(error)
|
||||
@@ -179,15 +183,39 @@ class ThumbnailJobsMixin:
|
||||
)
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
return _invalid(_OPTIONS_ERROR)
|
||||
if isinstance(body, dict) and body.get("mode") in ("move", "prune"):
|
||||
return await self._start_location_job(body)
|
||||
options, error = thumbnail_options(body)
|
||||
if error:
|
||||
return _invalid(error)
|
||||
refused = location_refusal()
|
||||
if refused is not None:
|
||||
return refused
|
||||
try:
|
||||
job = self._start_thumbnail_job(options)
|
||||
except JobBusy as busy:
|
||||
return busy_response(busy)
|
||||
return web.json_response({"success": True, "job": job.summary()}, status=202)
|
||||
|
||||
async def _start_location_job(self, body):
|
||||
"""Move thumbnails or Remove thumbnails of removed sources (#320)."""
|
||||
if body["mode"] == "prune":
|
||||
refused = location_refusal()
|
||||
if refused is not None:
|
||||
return refused
|
||||
try:
|
||||
if body["mode"] == "move":
|
||||
started = self.start_thumbnail_move(body)
|
||||
else:
|
||||
started = self.start_thumbnail_prune()
|
||||
except JobBusy as busy:
|
||||
return busy_response(busy)
|
||||
if isinstance(started, web.Response):
|
||||
return started
|
||||
return web.json_response(
|
||||
{"success": True, "job": started.summary()}, status=202
|
||||
)
|
||||
|
||||
def _start_thumbnail_job(self, options):
|
||||
"""Register the thumbnail job synchronously; raises JobBusy.
|
||||
|
||||
@@ -226,6 +254,7 @@ class ThumbnailJobsMixin:
|
||||
)
|
||||
state = "failed"
|
||||
job.finish(state)
|
||||
await self._release_job_memory()
|
||||
|
||||
async def _thumbnail_job_body(self, job, options):
|
||||
"""Plan, then generate in worker batches; returns the final state.
|
||||
@@ -458,8 +487,12 @@ class ThumbnailJobsMixin:
|
||||
or attaches to the running thumbnail job, and streams its frames. A
|
||||
legacy hold (the blocking generate or clear) or a record-changing
|
||||
job refuses it with the stream's error frame. The job goes on when
|
||||
the client goes away.
|
||||
the client goes away. Refused (409) while the thumbnail folder is
|
||||
unavailable (#320).
|
||||
"""
|
||||
refused = location_refusal()
|
||||
if refused is not None:
|
||||
return refused
|
||||
response = web.StreamResponse(
|
||||
status=200,
|
||||
headers={
|
||||
@@ -506,8 +539,12 @@ class ThumbnailJobsMixin:
|
||||
the way.
|
||||
"""
|
||||
running = self._jobs.active(THUMBNAILS_JOB_KIND)
|
||||
if running is not None and running.scope.get("mode") == "convert":
|
||||
return None, False, running # no legacy frames to follow (#317)
|
||||
if running is not None and running.scope.get("mode") in (
|
||||
"convert",
|
||||
"move",
|
||||
"prune",
|
||||
):
|
||||
return None, False, running # no legacy frames to follow (#317, #320)
|
||||
if running is not None and running.cancellable:
|
||||
return running, False, None
|
||||
busy = self._jobs.blocker(THUMBNAILS_JOB_KIND, admission="guarded")
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"""The thumbnail location on the API (#320): startup, the settings payload
|
||||
and the refusals while the thumbnail folder is unavailable.
|
||||
|
||||
The setting itself is config.json ``thumbnails.location``
|
||||
(``preview_location``); the runtime state is ``preview_store``. POST
|
||||
``/settings`` never changes it (409 ``location-needs-move``): the Move
|
||||
thumbnails job is its only writer.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from . import preview_location, preview_store
|
||||
|
||||
try:
|
||||
from ...utils import library_location
|
||||
except ImportError: # tests import the package top-level
|
||||
from utils import library_location
|
||||
|
||||
# The node folder (its web/ is served and a reinstall wipes it).
|
||||
NODE_FOLDER = os.path.dirname(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
)
|
||||
|
||||
|
||||
def default_path():
|
||||
"""``<data folder>/data/thumbnails`` (D7B), or None when unknown."""
|
||||
try:
|
||||
return library_location.cache_dir("thumbnails")
|
||||
except Exception: # no data folder can be decided: no default
|
||||
return None
|
||||
|
||||
|
||||
def location_refusal(location=None):
|
||||
"""The 409 ``location-unavailable`` response while the thumbnail folder
|
||||
is unavailable, else None (thumbnail generate, convert, clear, prune)."""
|
||||
location = location or preview_store.current()
|
||||
if location.mode != preview_store.FOLDER_MODE or location.available:
|
||||
return None
|
||||
status, text = preview_location.message("location-unavailable")
|
||||
return web.json_response(
|
||||
{"success": False, "code": "location-unavailable", "error": text},
|
||||
status=status,
|
||||
)
|
||||
|
||||
|
||||
def needs_move_response():
|
||||
status, text = preview_location.message("location-needs-move")
|
||||
return web.json_response(
|
||||
{"success": False, "code": "location-needs-move", "error": text},
|
||||
status=status,
|
||||
)
|
||||
|
||||
|
||||
class ThumbnailLocationMixin:
|
||||
"""Startup check and payloads of the thumbnail location."""
|
||||
|
||||
def _all_source_roots(self):
|
||||
"""Every image source of every root list: the Images roots, the
|
||||
configured sources and the extra gallery roots (as strings)."""
|
||||
from ..config import GalleryConfig, _extra_gallery_roots
|
||||
|
||||
roots = [str(root) for root in self._get_all_output_dirs()]
|
||||
roots += [str(root) for root in GalleryConfig.MONITORING_DIRECTORIES]
|
||||
roots += list(_extra_gallery_roots())
|
||||
return tuple(dict.fromkeys(root for root in roots if root))
|
||||
|
||||
def _thumbnail_location_context(self):
|
||||
"""What a thumbnail folder is validated against (blocking)."""
|
||||
contained = []
|
||||
try:
|
||||
import folder_paths
|
||||
|
||||
for name in (
|
||||
"get_output_directory",
|
||||
"get_input_directory",
|
||||
"get_temp_directory",
|
||||
):
|
||||
getter = getattr(folder_paths, name, None)
|
||||
folder = getter() if getter is not None else None
|
||||
if folder:
|
||||
contained.append(str(folder))
|
||||
except ImportError:
|
||||
pass # folder_paths exists only inside ComfyUI (tests run without it)
|
||||
try:
|
||||
contained.append(library_location.config_location()[1])
|
||||
except Exception: # no data folder can be decided
|
||||
self.logger.debug("No library folder for the thumbnail check")
|
||||
setting = preview_store.setting()
|
||||
return preview_location.Context(
|
||||
roots=self._all_source_roots(),
|
||||
contained=tuple(contained),
|
||||
node_folder=NODE_FOLDER,
|
||||
default_path=default_path(),
|
||||
current_path=(
|
||||
setting.path if setting.mode == preview_store.FOLDER_MODE else None
|
||||
),
|
||||
)
|
||||
|
||||
def use_preview_location(self):
|
||||
"""Put the configured location in force at once, unchecked (the
|
||||
package's import, before any request): folder mode is unavailable
|
||||
until ``start_preview_location`` has checked it, so nothing is ever
|
||||
written to the sources meanwhile. No filesystem access."""
|
||||
from ..config import PromptManagerConfig
|
||||
|
||||
try:
|
||||
return preview_store.configure(
|
||||
PromptManagerConfig.THUMBNAIL_LOCATION, check=False
|
||||
)
|
||||
except Exception:
|
||||
self.logger.warning("The thumbnail location is unusable", exc_info=True)
|
||||
preview_store.mark_invalid()
|
||||
return preview_store.current()
|
||||
|
||||
def start_preview_location(self):
|
||||
"""Parse and check the configured location once (startup thread);
|
||||
the probe takes over. Never raises; a check that fails leaves a
|
||||
folder location unavailable (``invalid``), never the sources."""
|
||||
from ..config import PromptManagerConfig
|
||||
|
||||
try:
|
||||
value = PromptManagerConfig.THUMBNAIL_LOCATION
|
||||
parsed = preview_location.parse_location(value)
|
||||
context = None
|
||||
if parsed.mode == preview_store.FOLDER_MODE:
|
||||
context = self._thumbnail_location_context()
|
||||
location = preview_store.configure(value, context=context)
|
||||
except Exception:
|
||||
self.logger.warning("The thumbnail location check failed", exc_info=True)
|
||||
if (
|
||||
preview_location.parse_location(
|
||||
PromptManagerConfig.THUMBNAIL_LOCATION
|
||||
).mode
|
||||
== preview_store.FOLDER_MODE
|
||||
):
|
||||
preview_store.mark_invalid()
|
||||
return preview_store.current()
|
||||
if location.mode == preview_store.FOLDER_MODE and not location.available:
|
||||
self.logger.warning(
|
||||
"The thumbnail folder is unavailable "
|
||||
f"({location.problem or 'checking'}): originals are shown and "
|
||||
"no thumbnails are written until it is back"
|
||||
)
|
||||
return location
|
||||
|
||||
def _thumbnail_location_payload(self, *, with_path):
|
||||
"""``{mode, available, problem}`` plus, for the settings GET only,
|
||||
``path`` and ``default_path`` (D6A)."""
|
||||
location = preview_store.current()
|
||||
payload = {
|
||||
"mode": location.mode,
|
||||
"available": location.available,
|
||||
"problem": location.problem,
|
||||
}
|
||||
if with_path:
|
||||
payload["path"] = location.path
|
||||
payload["default_path"] = default_path()
|
||||
# Old copies a move still has to remove (#320 Part 3).
|
||||
payload["cleanup_pending"] = len(preview_store.setting().cleanup)
|
||||
return payload
|
||||
@@ -0,0 +1,579 @@
|
||||
"""Move thumbnails (#320 Part 3): the reviewed job that changes where previews
|
||||
live, the removal of old copies after it, recovery at startup and the
|
||||
"Remove thumbnails of removed sources" scope (Part 5).
|
||||
|
||||
Kind ``thumbnails`` with ``mode: "move"`` (D1A): admission ``guarded``, so
|
||||
scans, deletes, tagging, source changes and every other thumbnail job wait
|
||||
for it, while the monitor's Needs review recording runs beside it.
|
||||
Automatic previews are held for the whole job (D2A, ``_auto_thumbnail_blocker``).
|
||||
|
||||
Phases: review (``POST /images/thumbnails/plan``, no writes but the
|
||||
validation probe), start (a plan token), hold (generation bump, journal,
|
||||
target folder), copy (cancellable at every file boundary, on the job pool
|
||||
in small chunks, D3A), switch (one config commit sets the location and
|
||||
records the old stores under ``cleanup``; not cancellable from here),
|
||||
remove old copies, end. Per-file errors switch anyway (D10A); a folder-wide
|
||||
error or a cancel before the switch removes what the job wrote and changes
|
||||
nothing. The move is the only writer of the setting (D4B).
|
||||
|
||||
Moving away from a thumbnail folder also removes the previews of this
|
||||
install's sources that are no longer listed (Q320-8: remove).
|
||||
"""
|
||||
|
||||
import functools
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from . import preview_location, preview_store
|
||||
from . import thumbnail_move_files as files
|
||||
from .jobs import THUMBNAILS_JOB_KIND
|
||||
from .thumbnail_move_plan import (
|
||||
MOVE_GROUPS,
|
||||
MOVE_TITLE,
|
||||
PLAN_TTL,
|
||||
_number,
|
||||
_plural,
|
||||
_remove,
|
||||
_roots_revision,
|
||||
_volume,
|
||||
_is_network,
|
||||
_write_json,
|
||||
)
|
||||
from . import thumbnail_move_plan as move_plan
|
||||
|
||||
try:
|
||||
from ...utils import exit_signal
|
||||
from ...utils.parallel import map_parallel
|
||||
except ImportError: # tests import the package top-level
|
||||
from utils import exit_signal
|
||||
from utils.parallel import map_parallel
|
||||
|
||||
|
||||
class ThumbnailMoveMixin:
|
||||
"""Start and run the move job; the removal of old copies."""
|
||||
|
||||
# ── start ─────────────────────────────────────────────────────────
|
||||
|
||||
def _take_move_plan(self, body):
|
||||
"""The plan a start names, or ``(status, code)``."""
|
||||
token = body.get("token")
|
||||
plan = (self._move_plans or {}).get(token) if isinstance(token, str) else None
|
||||
if plan is None or time.monotonic() - plan.created > PLAN_TTL:
|
||||
return 409, "plan-stale"
|
||||
roots = [str(root) for root in self._get_all_output_dirs()]
|
||||
if _roots_revision(roots) != plan.revision:
|
||||
return 409, "plan-stale"
|
||||
if preview_store.setting() != plan.old:
|
||||
return 409, "plan-stale"
|
||||
if plan.confirm_required and body.get("confirmed") is not True:
|
||||
return 400, "confirmation-required"
|
||||
return plan
|
||||
|
||||
def start_thumbnail_move(self, body):
|
||||
"""Start the move job (sync, on the loop); raises JobBusy. Returns
|
||||
the job or a refusal response."""
|
||||
if not self._move_recovered:
|
||||
blocker = self._jobs.active(THUMBNAILS_JOB_KIND)
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"code": "busy",
|
||||
"error": "Prompt Manager is still checking an earlier move. "
|
||||
"Try again in a moment.",
|
||||
"running": None if blocker is None else {"id": blocker.id},
|
||||
},
|
||||
status=409,
|
||||
)
|
||||
taken = self._take_move_plan(body)
|
||||
if isinstance(taken, tuple):
|
||||
status, code = taken
|
||||
text = {
|
||||
"plan-stale": "The review is out of date. Review the move again.",
|
||||
"confirmation-required": "Confirm that Prompt Manager may "
|
||||
"create its folders there.",
|
||||
}[code]
|
||||
return web.json_response(
|
||||
{"success": False, "code": code, "error": text}, status=status
|
||||
)
|
||||
plan = taken
|
||||
self._move_plans.pop(plan.token, None)
|
||||
return self._jobs.start(
|
||||
THUMBNAILS_JOB_KIND,
|
||||
MOVE_TITLE,
|
||||
admission="guarded",
|
||||
cancellable=True,
|
||||
scope={
|
||||
"mode": "move",
|
||||
"from": plan.old.mode,
|
||||
"to": plan.target["mode"],
|
||||
"to_path": plan.target["path"],
|
||||
},
|
||||
groups=MOVE_GROUPS,
|
||||
runner=functools.partial(self._run_thumbnail_move, plan=plan),
|
||||
)
|
||||
|
||||
# ── the job ───────────────────────────────────────────────────────
|
||||
|
||||
async def _run_thumbnail_move(self, job, *, plan):
|
||||
state = {"written": [], "created": [], "created_folder": False}
|
||||
try:
|
||||
await self._run_in_executor(self._move_hold, job, plan, state)
|
||||
except (OSError, ValueError) as error:
|
||||
await self._run_in_executor(self._move_rollback, plan, state)
|
||||
self._end_move(
|
||||
job, "failed", "The new folder could not be made; nothing changed."
|
||||
)
|
||||
self.logger.warning(f"Move thumbnails could not start: {error}")
|
||||
return
|
||||
left = [
|
||||
{
|
||||
"path": "/".join(item),
|
||||
"root": s.label,
|
||||
"detail": "Not a thumbnail; left in place",
|
||||
}
|
||||
for s in plan.sources
|
||||
if s.scan is not None
|
||||
for item in s.scan.left
|
||||
]
|
||||
if left:
|
||||
job.record_counted("left", len(left), left)
|
||||
items = [
|
||||
(source, item)
|
||||
for source in plan.sources
|
||||
if source.scan is not None
|
||||
for item in source.scan.copy
|
||||
]
|
||||
total = len(items)
|
||||
job.update(total=total, processed=0, progress=0)
|
||||
chunk = await self._run_in_executor(self._move_chunk, plan)
|
||||
workers = self._move_workers()
|
||||
done = 0
|
||||
for start in range(0, total, chunk):
|
||||
if job.cancelled:
|
||||
await self._move_cancelled(job, plan, state)
|
||||
return
|
||||
batch = items[start : start + chunk]
|
||||
try:
|
||||
results = await self._run_in_executor(
|
||||
map_parallel,
|
||||
functools.partial(self._move_copy_one, state=state),
|
||||
batch,
|
||||
workers,
|
||||
)
|
||||
except exit_signal.ExitRequested:
|
||||
return # exiting: the next start undoes the copies (journal)
|
||||
except files.FolderGone as error:
|
||||
self.logger.warning(f"Move thumbnails stopped: {error}")
|
||||
await self._run_in_executor(self._move_rollback, plan, state)
|
||||
self._end_move(
|
||||
job,
|
||||
"failed",
|
||||
"The new folder could not be reached or is full; nothing changed.",
|
||||
)
|
||||
return
|
||||
except Exception:
|
||||
# Anything else before the switch: undo, never half a move.
|
||||
self.logger.exception("Move thumbnails failed while copying")
|
||||
await self._run_in_executor(self._move_rollback, plan, state)
|
||||
self._end_move(job, "failed", "The copy failed; nothing changed.")
|
||||
return
|
||||
for (source, item), (outcome, detail) in zip(batch, results):
|
||||
row = {
|
||||
"path": "/".join(item[0]),
|
||||
"root": source.label,
|
||||
"detail": detail,
|
||||
}
|
||||
job.record(
|
||||
"moved" if outcome == "moved" else outcome,
|
||||
row,
|
||||
f"{outcome}: {source.label} · {'/'.join(item[0])}",
|
||||
)
|
||||
done += len(batch)
|
||||
label = batch[-1][0].label
|
||||
job.update(
|
||||
processed=done,
|
||||
progress=int(done * 90 / total) if total else 90,
|
||||
status_line=(
|
||||
f"Copying {_number(done)} of {_number(total)} previews · {label}"
|
||||
),
|
||||
)
|
||||
job.publish({"type": "progress", "summary": job.summary()})
|
||||
if job.cancelled:
|
||||
await self._move_cancelled(job, plan, state)
|
||||
return
|
||||
# The switch: not cancellable from here (an exit still stops between
|
||||
# files and leaves the cleanup to the next start).
|
||||
job.cancellable = False
|
||||
job.update(status_line="Switching to the new location", progress=92)
|
||||
job.publish({"type": "progress", "summary": job.summary()})
|
||||
try:
|
||||
await self._run_in_executor(self._move_switch, plan)
|
||||
except Exception:
|
||||
self.logger.exception("Move thumbnails could not switch")
|
||||
await self._run_in_executor(self._move_rollback, plan, state)
|
||||
self._end_move(
|
||||
job, "failed", "The setting could not be saved; nothing changed."
|
||||
)
|
||||
return
|
||||
await self._remove_old_copies(job, plan)
|
||||
moved = job.counters.get("moved", 0) + job.counters.get("skipped", 0)
|
||||
errors = job.counters.get("errors", 0)
|
||||
where = plan.target["path"] or "inside each source"
|
||||
parts = [f"{_number(moved)} moved"]
|
||||
if errors:
|
||||
parts.append(_plural(errors, "error"))
|
||||
parts.append(
|
||||
"old copies removed"
|
||||
if not job.scope.get("cleanup_left")
|
||||
else "some old copies are removed at the next start"
|
||||
)
|
||||
self._end_move(
|
||||
job, "finished", f"Thumbnails moved to {where}: " + " · ".join(parts) + "."
|
||||
)
|
||||
|
||||
def _end_move(self, job, state, line):
|
||||
job.update(status_line=line, progress=100)
|
||||
job.log_line(line)
|
||||
job.publish({"type": "complete", "summary": job.summary()})
|
||||
job.finish(state)
|
||||
|
||||
def _move_workers(self):
|
||||
from .images import _worker_threads
|
||||
|
||||
return max(1, int(_worker_threads() or 1))
|
||||
|
||||
def _move_chunk(self, plan):
|
||||
"""2 files at a time across volumes or to/from a share, else 4, never
|
||||
more than Worker threads (D3A: the pool keeps its size)."""
|
||||
sides = [plan.target["path"] or ""] + [s.old_store or "" for s in plan.sources]
|
||||
sides += [s.new_store for s in plan.sources]
|
||||
network = any(_is_network(side) for side in sides if side)
|
||||
volumes = {_volume(side) for side in sides if side}
|
||||
size = 2 if network or len(volumes) > 1 else 4
|
||||
return max(1, min(size, self._move_workers()))
|
||||
|
||||
def _move_hold(self, job, plan, state):
|
||||
"""Phase 3: bump the generation, write the journal, make the target."""
|
||||
preview_store.bump_generation()
|
||||
if plan.to_folder:
|
||||
folder = plan.target["path"]
|
||||
if not os.path.isdir(folder):
|
||||
if plan.target_default:
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
else:
|
||||
os.mkdir(folder)
|
||||
state["created_folder"] = True
|
||||
plan.target_id = preview_location.init_folder(
|
||||
folder, created_by_pm=state["created_folder"]
|
||||
)
|
||||
install = plan.install
|
||||
|
||||
def add_keys(marker):
|
||||
for source in plan.sources:
|
||||
if source.key and source.key not in marker["keys"]:
|
||||
marker["keys"][source.key] = {
|
||||
"path": source.root,
|
||||
"identity": os.path.normcase(os.path.realpath(source.root)),
|
||||
"dev_ino": preview_location.file_identity(source.root),
|
||||
"install": install,
|
||||
}
|
||||
|
||||
with preview_store.write_lock:
|
||||
preview_location.write_marker(folder, add_keys)
|
||||
_write_json(
|
||||
move_plan.journal_path(),
|
||||
{
|
||||
"state": "copying",
|
||||
"from": plan.old.to_config(),
|
||||
"to": plan.target,
|
||||
"stores": [s.new_store for s in plan.sources],
|
||||
# Stores that do not exist yet are made by this job only:
|
||||
# after a crash everything in them goes (B4).
|
||||
"created_dirs": [
|
||||
s.new_store for s in plan.sources if not os.path.isdir(s.new_store)
|
||||
],
|
||||
"created_folder": state["created_folder"],
|
||||
"install": plan.install,
|
||||
},
|
||||
)
|
||||
job.log_line(
|
||||
f"Move thumbnails started: {plan.old.path or 'inside each source'} → "
|
||||
f"{plan.target['path'] or 'inside each source'}"
|
||||
)
|
||||
|
||||
def _move_copy_one(self, pair, *, state):
|
||||
"""Copy one preview (a pool thread); ``(group, detail)``."""
|
||||
source, (relative, size, mtime_ns) = pair
|
||||
destination = Path(source.new_store).joinpath(*relative)
|
||||
try:
|
||||
if not os.path.isdir(destination.parent):
|
||||
base = Path(source.new_store)
|
||||
if not base.is_dir():
|
||||
if plan_parent_missing(base):
|
||||
raise files.FolderGone("the new folder is gone")
|
||||
created = []
|
||||
files._make_folders(base.parent, (base.name,), created)
|
||||
state["created"].extend(created)
|
||||
created = []
|
||||
files._make_folders(base, relative[:-1], created)
|
||||
state["created"].extend(created)
|
||||
# Also when the folders already exist (Codex P1 on #592).
|
||||
files.refuse_links(source.new_store, relative[:-1])
|
||||
outcome = files.copy_one(
|
||||
Path(source.old_store).joinpath(*relative),
|
||||
destination,
|
||||
size,
|
||||
mtime_ns,
|
||||
state["created"],
|
||||
)
|
||||
except files.CopyFailed as error:
|
||||
return "errors", str(error)
|
||||
if outcome == "moved":
|
||||
state["written"].append(str(destination))
|
||||
return "moved", "Copied and checked"
|
||||
return "skipped", "Already there"
|
||||
|
||||
async def _move_cancelled(self, job, plan, state):
|
||||
if exit_signal.requested():
|
||||
return # the next start removes the partial copies (journal)
|
||||
await self._run_in_executor(self._move_rollback, plan, state)
|
||||
self._end_move(job, "cancelled", "Move cancelled: nothing changed.")
|
||||
|
||||
def _move_rollback(self, plan, state):
|
||||
"""Remove what this job wrote (files, folders it made, a folder and
|
||||
marker it created) and the journal: nothing changed."""
|
||||
for path in state["written"]:
|
||||
files.remove_file(path)
|
||||
for folder in reversed(state["created"]):
|
||||
files.remove_empty_folders(folder)
|
||||
if plan.to_folder and state.get("created_folder"):
|
||||
folder = plan.target["path"]
|
||||
try:
|
||||
names = os.listdir(folder)
|
||||
except OSError:
|
||||
names = None
|
||||
if names is not None and set(names) <= {preview_location.MARKER_NAME}:
|
||||
_remove(preview_location.marker_path(folder))
|
||||
files.remove_empty_folders(folder)
|
||||
_remove(move_plan.journal_path())
|
||||
|
||||
def _move_switch(self, plan):
|
||||
"""Phase 5: one config commit sets the location and records the old
|
||||
stores under ``cleanup``; the runtime follows under the write lock."""
|
||||
from ..config import PromptManagerConfig
|
||||
from ..config_store import get_config_store
|
||||
|
||||
entry = {
|
||||
"from": plan.old.mode,
|
||||
"folder": plan.old.path if plan.from_folder else None,
|
||||
"stores": [s.old_store for s in plan.sources if s.old_store],
|
||||
"drop_keys": self._old_keys_to_drop(plan),
|
||||
"install": plan.install,
|
||||
}
|
||||
# An older entry never removes a store the move just filled (a move
|
||||
# back while old copies still waited).
|
||||
filled = {os.path.normcase(s.new_store) for s in plan.sources}
|
||||
cleanup = []
|
||||
for old_entry in plan.old.cleanup:
|
||||
stores = [
|
||||
store
|
||||
for store in old_entry.get("stores") or []
|
||||
if isinstance(store, str) and os.path.normcase(store) not in filled
|
||||
]
|
||||
folder = old_entry.get("folder")
|
||||
drop = [
|
||||
key
|
||||
for key in old_entry.get("drop_keys") or []
|
||||
if not folder
|
||||
or os.path.normcase(os.path.join(folder, key)) not in filled
|
||||
]
|
||||
cleanup.append({**old_entry, "stores": stores, "drop_keys": drop})
|
||||
cleanup.append(entry)
|
||||
if plan.to_folder:
|
||||
value = {
|
||||
"mode": preview_store.FOLDER_MODE,
|
||||
"path": plan.target["path"],
|
||||
"id": plan.target_id,
|
||||
"install": plan.install,
|
||||
"cleanup": cleanup,
|
||||
}
|
||||
else:
|
||||
value = {
|
||||
"mode": preview_store.SOURCE_MODE,
|
||||
"install": plan.install,
|
||||
"cleanup": cleanup,
|
||||
}
|
||||
|
||||
def mutate(config):
|
||||
section = config.get("thumbnails")
|
||||
if not isinstance(section, dict):
|
||||
section = {}
|
||||
section["location"] = value
|
||||
config["thumbnails"] = section
|
||||
return config
|
||||
|
||||
def publish(_config):
|
||||
PromptManagerConfig.THUMBNAIL_LOCATION = value
|
||||
preview_store.configure(value, check=False)
|
||||
|
||||
get_config_store().commit(mutate, expected_revision=None, publish=publish)
|
||||
# The switch is made: what follows never undoes it.
|
||||
try:
|
||||
preview_store.refresh()
|
||||
_remove(move_plan.journal_path())
|
||||
except OSError as error:
|
||||
self.logger.warning(
|
||||
f"The thumbnail move journal stays until the next start: {error}"
|
||||
)
|
||||
|
||||
def _old_keys_to_drop(self, plan):
|
||||
if not plan.from_folder:
|
||||
return []
|
||||
keys = [s.old_store and os.path.basename(s.old_store) for s in plan.sources]
|
||||
keys = [key for key in keys if key]
|
||||
if plan.to_folder and os.path.normcase(plan.old.path or "") == os.path.normcase(
|
||||
plan.target["path"] or ""
|
||||
):
|
||||
return []
|
||||
return keys + [key for key, _store, _scan in plan.unlisted]
|
||||
|
||||
async def _remove_old_copies(self, job, plan):
|
||||
"""Phase 6: run the cleanup the switch recorded (and earlier ones)."""
|
||||
job.update(status_line="Removing old copies", progress=95)
|
||||
job.publish({"type": "progress", "summary": job.summary()})
|
||||
left = await self._run_in_executor(self._run_cleanup, job)
|
||||
if left:
|
||||
job.scope["cleanup_left"] = True
|
||||
|
||||
def _run_cleanup(self, job=None):
|
||||
"""Remove every recorded old store's previews, temps and empty
|
||||
folders; keep in ``cleanup`` what could not go. Returns how many
|
||||
entries are left (blocking)."""
|
||||
setting = preview_store.setting()
|
||||
remaining = []
|
||||
for entry in setting.cleanup:
|
||||
if not self._cleanup_entry(entry, job):
|
||||
remaining.append(entry)
|
||||
if list(setting.cleanup) != remaining:
|
||||
self._commit_cleanup(remaining)
|
||||
return len(remaining)
|
||||
|
||||
def _cleanup_entry(self, entry, job):
|
||||
"""One recorded removal; True when it is complete."""
|
||||
folder = entry.get("folder")
|
||||
stores = [
|
||||
store for store in entry.get("stores") or [] if isinstance(store, str)
|
||||
]
|
||||
if folder:
|
||||
if not os.path.isdir(folder):
|
||||
return False # the old folder is not reachable: kept for later
|
||||
folder_marker = preview_location.read_marker(folder) or {}
|
||||
keys = folder_marker.get("keys") or {}
|
||||
stores += [
|
||||
os.path.join(folder, key)
|
||||
for key in entry.get("drop_keys") or []
|
||||
if preview_location.valid_key(key) and key in keys
|
||||
]
|
||||
complete = True
|
||||
for store in dict.fromkeys(stores):
|
||||
if not self._remove_store(store, folder, job):
|
||||
complete = False
|
||||
if folder and complete:
|
||||
drop = set(entry.get("drop_keys") or [])
|
||||
|
||||
def forget(marker):
|
||||
for key in drop:
|
||||
marker["keys"].pop(key, None)
|
||||
|
||||
try:
|
||||
with preview_store.write_lock:
|
||||
marker = preview_location.write_marker(folder, forget)
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
if not marker["keys"] and marker.get("created_by_pm"):
|
||||
names = set(os.listdir(folder))
|
||||
rest = {n for n in names if not files.is_os_metadata(n)}
|
||||
if rest <= {preview_location.MARKER_NAME}:
|
||||
_remove(preview_location.marker_path(folder))
|
||||
files.remove_empty_folders(folder)
|
||||
return complete
|
||||
|
||||
def _remove_store(self, store, folder, job):
|
||||
"""Remove the previews and temps of one old store; True when done."""
|
||||
store = Path(store)
|
||||
if folder:
|
||||
# A key folder directly in the old thumbnail folder, nothing else.
|
||||
if not preview_location.valid_key(store.name) or os.path.normcase(
|
||||
str(store.parent)
|
||||
) != os.path.normcase(str(folder)):
|
||||
return True
|
||||
elif store.name != "thumbnails":
|
||||
return True # a hand-edited entry never names another folder
|
||||
if not store.exists():
|
||||
return True
|
||||
if preview_location.is_link_like(store):
|
||||
return True # never through a link; nothing of ours there
|
||||
complete = True
|
||||
removed = 0
|
||||
for current, dirs, names in os.walk(store):
|
||||
dirs[:] = [
|
||||
d for d in dirs if not preview_location.is_link_like(Path(current) / d)
|
||||
]
|
||||
for name in names:
|
||||
path = Path(current) / name
|
||||
if files.is_temp_name(name) or files._preview_parts(name):
|
||||
if preview_location.is_link_like(path):
|
||||
continue
|
||||
if exit_signal.requested():
|
||||
return False # exiting: the next start finishes it
|
||||
if files.remove_file(path):
|
||||
removed += 1
|
||||
else:
|
||||
complete = False
|
||||
relative = path.relative_to(store).as_posix()
|
||||
self.logger.warning(
|
||||
f"Could not remove the old thumbnail {path}; it is "
|
||||
"removed at the next start"
|
||||
)
|
||||
if job is not None:
|
||||
# D10A: a removal that failed is an error the job lists.
|
||||
job.record(
|
||||
"errors",
|
||||
{
|
||||
"path": relative,
|
||||
"root": store.name,
|
||||
"detail": "In use by another program; removed "
|
||||
"at the next start",
|
||||
},
|
||||
f"In use by another program; removed at the next "
|
||||
f"start: {store.name}/{relative}",
|
||||
)
|
||||
files.remove_empty_folders(store)
|
||||
if job is not None and removed:
|
||||
job.log_line(f"Removed {_number(removed)} old files from {store.name}")
|
||||
return complete
|
||||
|
||||
def _commit_cleanup(self, remaining):
|
||||
from ..config import PromptManagerConfig
|
||||
from ..config_store import get_config_store
|
||||
|
||||
def mutate(config):
|
||||
section = config.get("thumbnails")
|
||||
if isinstance(section, dict) and isinstance(section.get("location"), dict):
|
||||
section["location"]["cleanup"] = remaining
|
||||
return config
|
||||
|
||||
def publish(config):
|
||||
value = (config.get("thumbnails") or {}).get("location")
|
||||
PromptManagerConfig.THUMBNAIL_LOCATION = value
|
||||
preview_store.set_cleanup(remaining)
|
||||
|
||||
get_config_store().commit(mutate, expected_revision=None, publish=publish)
|
||||
|
||||
|
||||
def plan_parent_missing(store):
|
||||
"""A key folder's parent (the thumbnail folder) is gone mid-copy."""
|
||||
return not Path(store).parent.is_dir()
|
||||
@@ -0,0 +1,348 @@
|
||||
"""The file side of Move thumbnails (#320 Part 3): what is copied, how a
|
||||
copy is made and checked, and how old copies are removed.
|
||||
|
||||
No job, HTTP or config code lives here; ``thumbnail_move`` drives it.
|
||||
|
||||
Copy set (B5): each old store is walked with ``os.scandir``, never through a
|
||||
symlink, junction or reparse point (counted as "skipped link"). A regular
|
||||
file is
|
||||
|
||||
- copied when it has the preview grammar (``<name>_thumb<ext>``, any written
|
||||
or legacy extension) and its original still exists in the source;
|
||||
- removed with the old copies, not copied, when it is an orphan (no
|
||||
original), lies under a trash folder, or is a temp sibling of a write
|
||||
(``.<stem>.part-…``) or of a move (``….pm-move-<8 hex>.tmp``);
|
||||
- left in place otherwise ("Left in place"). OS metadata files (``Thumbs.db``,
|
||||
``desktop.ini``, ``.DS_Store``, ``._*``) are removed only when they alone
|
||||
keep a preview folder from being removed.
|
||||
|
||||
A copy goes to ``<name>.pm-move-<8 hex>.tmp`` beside its destination, its
|
||||
size is compared, its mtime is set to the source preview's rounded up to the
|
||||
next 2-second step (FAT/exFAT keep it fresh, a stale preview stays stale),
|
||||
then it is renamed into place. Every handle is closed before a rename or an
|
||||
unlink (Windows).
|
||||
"""
|
||||
|
||||
import errno
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import stat as stat_module
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from . import preview_location, thumbnail_settings
|
||||
from .thumbnail_encode import _TEMP_NAME
|
||||
|
||||
try:
|
||||
from ...utils.media_folders import in_trash_folder
|
||||
except ImportError: # tests import the package top-level
|
||||
from utils.media_folders import in_trash_folder
|
||||
|
||||
logger = logging.getLogger("prompt_manager.thumbnails")
|
||||
|
||||
MOVE_TEMP = re.compile(r"\.pm-move-[0-9a-f]{8}\.tmp$")
|
||||
MOVE_TEMP_SUFFIX = ".pm-move-{token}.tmp"
|
||||
# The temp name adds this much to a destination path (".pm-move-xxxxxxxx.tmp").
|
||||
TEMP_EXTRA = 21
|
||||
WINDOWS_FILE_LIMIT = 259
|
||||
MTIME_STEP_NS = 2_000_000_000
|
||||
# A file another program holds open (Windows) is retried this often.
|
||||
REMOVE_RETRIES = (0.5, 1.0, 2.0)
|
||||
_WINDOWS_IN_USE = {32, 33} # ERROR_SHARING_VIOLATION, ERROR_LOCK_VIOLATION
|
||||
|
||||
|
||||
def is_os_metadata(name):
|
||||
folded = name.lower()
|
||||
return folded in preview_location.OS_METADATA or folded.startswith("._")
|
||||
|
||||
|
||||
def is_temp_name(name):
|
||||
return bool(_TEMP_NAME.match(name) or MOVE_TEMP.search(name))
|
||||
|
||||
|
||||
def _preview_parts(name):
|
||||
"""``(base, extension)`` of a preview file name, or None."""
|
||||
stem, extension = os.path.splitext(name)
|
||||
suffix = thumbnail_settings.THUMB_SUFFIX
|
||||
if not stem.endswith(suffix) or len(stem) <= len(suffix):
|
||||
return None
|
||||
if extension.lower() not in thumbnail_settings.THUMBNAIL_EXTENSIONS:
|
||||
return None
|
||||
return stem[: -len(suffix)], extension
|
||||
|
||||
|
||||
def _has_original(root, rel_dir, base, extension):
|
||||
"""Whether a media file in ``<root>/<rel_dir>`` owns this preview."""
|
||||
folder = Path(root).joinpath(*rel_dir)
|
||||
try:
|
||||
with os.scandir(folder) as entries:
|
||||
for entry in entries:
|
||||
stem, source_ext = os.path.splitext(entry.name)
|
||||
if stem != base or not source_ext:
|
||||
continue
|
||||
if not thumbnail_settings.owns(extension, source_ext):
|
||||
continue
|
||||
if entry.is_file(follow_symlinks=False):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
@dataclass
|
||||
class StoreScan:
|
||||
"""One old store's files, sorted into what the move does with them."""
|
||||
|
||||
store: Path
|
||||
copy: list = field(default_factory=list) # (relative parts, size, mtime_ns)
|
||||
orphans: list = field(default_factory=list) # relative parts
|
||||
temps: list = field(default_factory=list)
|
||||
left: list = field(default_factory=list)
|
||||
links: int = 0
|
||||
bytes: int = 0
|
||||
|
||||
@property
|
||||
def removable(self):
|
||||
return [item[0] for item in self.copy] + self.orphans + self.temps
|
||||
|
||||
|
||||
def scan_store(store, root, *, stop=None):
|
||||
"""Sort the files of *store* (the previews of the source *root*).
|
||||
|
||||
A store that does not exist, or is itself a link, scans empty (a link
|
||||
counts once).
|
||||
"""
|
||||
store = Path(store)
|
||||
scan = StoreScan(store)
|
||||
try:
|
||||
if preview_location.is_link_like(store):
|
||||
scan.links += 1
|
||||
return scan
|
||||
if not store.is_dir():
|
||||
return scan
|
||||
except OSError:
|
||||
return scan
|
||||
_walk(scan, store, (), root, stop)
|
||||
return scan
|
||||
|
||||
|
||||
def _walk(scan, folder, rel_dir, root, stop):
|
||||
try:
|
||||
with os.scandir(folder) as iterator:
|
||||
entries = list(iterator)
|
||||
except OSError:
|
||||
return
|
||||
for entry in entries:
|
||||
if stop is not None and stop():
|
||||
return
|
||||
relative = rel_dir + (entry.name,)
|
||||
path = Path(entry.path)
|
||||
try:
|
||||
if preview_location.is_link_like(path):
|
||||
scan.links += 1
|
||||
continue
|
||||
info = entry.stat(follow_symlinks=False)
|
||||
except OSError:
|
||||
continue
|
||||
if stat_module.S_ISDIR(info.st_mode):
|
||||
_walk(scan, path, relative, root, stop)
|
||||
continue
|
||||
if not stat_module.S_ISREG(info.st_mode):
|
||||
scan.left.append(relative)
|
||||
continue
|
||||
if is_temp_name(entry.name):
|
||||
scan.temps.append(relative)
|
||||
continue
|
||||
if is_os_metadata(entry.name):
|
||||
continue # removed only when it alone keeps a folder (rmdir)
|
||||
parts = _preview_parts(entry.name)
|
||||
if parts is None:
|
||||
scan.left.append(relative)
|
||||
continue
|
||||
if in_trash_folder("/".join(relative)) or not _has_original(
|
||||
root, rel_dir, *parts
|
||||
):
|
||||
scan.orphans.append(relative)
|
||||
continue
|
||||
scan.copy.append((relative, info.st_size, info.st_mtime_ns))
|
||||
scan.bytes += info.st_size
|
||||
|
||||
|
||||
# ── copying ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def rounded_mtime_ns(mtime_ns):
|
||||
"""*mtime_ns* rounded up to the next 2-second step (FAT/exFAT)."""
|
||||
return -(-mtime_ns // MTIME_STEP_NS) * MTIME_STEP_NS
|
||||
|
||||
|
||||
def too_long(path, long_paths=None):
|
||||
"""A destination (with its temp name) over Windows' file limit."""
|
||||
if os.name != "nt" and long_paths is not False:
|
||||
return False
|
||||
if long_paths is None:
|
||||
long_paths = preview_location.long_paths_enabled()
|
||||
if long_paths:
|
||||
return False
|
||||
return len(str(path)) + TEMP_EXTRA > WINDOWS_FILE_LIMIT
|
||||
|
||||
|
||||
class CopyFailed(Exception):
|
||||
"""One preview could not be copied (a per-file error, D10A)."""
|
||||
|
||||
|
||||
class FolderGone(Exception):
|
||||
"""The destination cannot take writes any more (abort the move)."""
|
||||
|
||||
|
||||
def _make_folders(base, relative_dir, created):
|
||||
"""Make ``base/relative_dir`` one folder at a time (*base* must exist);
|
||||
every folder made is added to *created*."""
|
||||
current = Path(base)
|
||||
for part in relative_dir:
|
||||
current = current / part
|
||||
try:
|
||||
os.mkdir(current)
|
||||
created.append(str(current))
|
||||
except FileExistsError:
|
||||
pass # already there: made by an earlier copy into this folder
|
||||
except OSError as error:
|
||||
if error.errno in (errno.ENOSPC, errno.EDQUOT, errno.EROFS):
|
||||
raise FolderGone(str(error)) from error
|
||||
raise CopyFailed(
|
||||
"Its folder could not be made; made again on view"
|
||||
) from error
|
||||
if preview_location.is_link_like(current):
|
||||
raise CopyFailed("a link in the destination")
|
||||
|
||||
|
||||
def refuse_links(base, relative_dir):
|
||||
"""Raise CopyFailed when *base* or an existing folder on the way to
|
||||
``base/relative_dir`` is a link (never copy through one)."""
|
||||
current = Path(base)
|
||||
for part in (None, *relative_dir):
|
||||
if part is not None:
|
||||
current = current / part
|
||||
if preview_location.is_link_like(current):
|
||||
raise CopyFailed("a link in the destination")
|
||||
|
||||
|
||||
def copy_one(source, destination, size, mtime_ns, created, *, long_paths=None):
|
||||
"""Copy one preview; returns ``"moved"`` or ``"skipped"`` (already
|
||||
there). Raises CopyFailed (this file) or FolderGone (the destination)."""
|
||||
destination = Path(destination)
|
||||
if too_long(destination, long_paths):
|
||||
raise CopyFailed("Path too long for Windows; the original is shown")
|
||||
wanted = rounded_mtime_ns(mtime_ns)
|
||||
try:
|
||||
existing = os.stat(destination, follow_symlinks=False)
|
||||
except FileNotFoundError:
|
||||
existing = None
|
||||
except OSError as error:
|
||||
raise FolderGone(str(error)) from error
|
||||
if (
|
||||
existing is not None
|
||||
and stat_module.S_ISREG(existing.st_mode)
|
||||
and existing.st_size == size
|
||||
and existing.st_mtime_ns == wanted
|
||||
):
|
||||
return "skipped"
|
||||
temp = destination.with_name(
|
||||
destination.name + MOVE_TEMP_SUFFIX.format(token=secrets.token_hex(4))
|
||||
)
|
||||
try:
|
||||
shutil.copyfile(source, temp)
|
||||
if os.stat(temp).st_size != size:
|
||||
raise CopyFailed("The copy did not match; made again on view")
|
||||
os.utime(temp, ns=(wanted, wanted))
|
||||
os.replace(temp, destination)
|
||||
except CopyFailed:
|
||||
_unlink(temp)
|
||||
raise
|
||||
except FileNotFoundError as error:
|
||||
_unlink(temp)
|
||||
if not Path(source).exists():
|
||||
raise CopyFailed("Could not be read; made again on view") from error
|
||||
raise FolderGone(str(error)) from error
|
||||
except OSError as error:
|
||||
_unlink(temp)
|
||||
if error.errno in (errno.ENOSPC, errno.EDQUOT, errno.EROFS):
|
||||
raise FolderGone(str(error)) from error
|
||||
raise CopyFailed("Could not be copied; made again on view") from error
|
||||
return "moved"
|
||||
|
||||
|
||||
def _unlink(path):
|
||||
"""Remove a copy's temp file after a failed copy; True when it is gone.
|
||||
|
||||
One that cannot go (a Windows lock, permissions) is logged with its path;
|
||||
the next start's temp sweep removes it (``.pm-move-*.tmp``)."""
|
||||
try:
|
||||
os.unlink(path)
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
return True # never written, or already renamed into place
|
||||
except OSError as error:
|
||||
logger.warning(f"Could not remove the move's temp file {path}: {error}")
|
||||
return False
|
||||
|
||||
|
||||
# ── removing ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _in_use(error):
|
||||
return getattr(error, "winerror", None) in _WINDOWS_IN_USE
|
||||
|
||||
|
||||
def remove_file(path, *, sleep=time.sleep):
|
||||
"""Remove one file; a file another program holds open (Windows) is
|
||||
retried 3 times. Returns True when it is gone."""
|
||||
for wait in (0.0,) + REMOVE_RETRIES:
|
||||
if wait:
|
||||
sleep(wait)
|
||||
try:
|
||||
os.unlink(path)
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
return True
|
||||
except OSError as error:
|
||||
if not _in_use(error):
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def remove_empty_folders(top, *, keep_top=False):
|
||||
"""``rmdir`` the empty folders below *top* (and *top*), bottom-up, never
|
||||
through a link. A folder holding only OS metadata files loses them first.
|
||||
Returns the folders that could not be removed."""
|
||||
top = Path(top)
|
||||
left = []
|
||||
if not top.is_dir() or preview_location.is_link_like(top):
|
||||
return left
|
||||
# Top-down, pruning every link, junction or reparse point, then bottom-up:
|
||||
# never into a folder outside *top* (Windows junctions included).
|
||||
folders = []
|
||||
for folder, dirs, _files in os.walk(top):
|
||||
folder = Path(folder)
|
||||
dirs[:] = [d for d in dirs if not preview_location.is_link_like(folder / d)]
|
||||
folders.append(folder)
|
||||
for folder in reversed(folders):
|
||||
if folder == top and keep_top:
|
||||
continue
|
||||
try:
|
||||
names = os.listdir(folder)
|
||||
except OSError:
|
||||
left.append(folder)
|
||||
continue
|
||||
if names and all(is_os_metadata(name) for name in names):
|
||||
for name in names:
|
||||
remove_file(folder / name)
|
||||
try:
|
||||
os.rmdir(folder)
|
||||
except OSError:
|
||||
left.append(folder)
|
||||
return left
|
||||
@@ -0,0 +1,492 @@
|
||||
"""Move thumbnails, the review (#320 Part 3 phase 1): what a move would copy,
|
||||
remove and leave, checked before anything is written."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import shutil
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from . import preview_location, preview_store
|
||||
from . import thumbnail_move_files as files
|
||||
from .job_routes import busy_response
|
||||
from .jobs import THUMBNAILS_JOB_KIND, JobBusy
|
||||
from .thumbnail_location import default_path
|
||||
|
||||
try:
|
||||
from ...utils import library_location
|
||||
except ImportError: # tests import the package top-level
|
||||
from utils import library_location
|
||||
|
||||
MOVE_TITLE = "Move thumbnails"
|
||||
FINISHING_TITLE = "Move thumbnails (finishing)"
|
||||
PRUNE_TITLE = "Remove thumbnails of removed sources"
|
||||
MOVE_GROUPS = ("moved", "skipped", "left", "errors")
|
||||
PRUNE_GROUPS = ("removed", "left", "errors")
|
||||
PLAN_TTL = 600.0
|
||||
JOURNAL_NAME = "thumbnail-move.json"
|
||||
SPACE_MARGIN = 64 * 2**20
|
||||
SPACE_SHARE = 0.05
|
||||
LISTED = 5
|
||||
SENTENCE = (
|
||||
"Every thumbnail is copied and checked before Prompt Manager switches. "
|
||||
"Scans, deletes and tagging wait until it finishes; closing this page does "
|
||||
"not stop it."
|
||||
)
|
||||
|
||||
|
||||
def _number(value):
|
||||
return f"{value:,}"
|
||||
|
||||
|
||||
def _plural(count, one, many=None):
|
||||
return f"{_number(count)} {one if count == 1 else (many or one + 's')}"
|
||||
|
||||
|
||||
def _size(value):
|
||||
size = float(value)
|
||||
for unit in ("B", "KB", "MB", "GB"):
|
||||
if size < 1024 or unit == "GB":
|
||||
return f"{size:.1f} {unit}" if unit != "B" else f"{int(size)} B"
|
||||
size /= 1024
|
||||
return f"{size:.1f} GB"
|
||||
|
||||
|
||||
def _refusal(code, status=None, **extra):
|
||||
known_status, text = preview_location.MESSAGES.get(code, (400, code))
|
||||
return web.json_response(
|
||||
{"success": False, "code": code, "error": text, **extra},
|
||||
status=status or known_status,
|
||||
)
|
||||
|
||||
|
||||
def journal_path():
|
||||
"""``<data folder>/data/thumbnail-move.json`` (only while a move copies)."""
|
||||
return library_location.cache_dir(JOURNAL_NAME)
|
||||
|
||||
|
||||
def _read_json(path):
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as handle:
|
||||
data = json.load(handle)
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def _write_json(path, data):
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
temp = f"{path}.{os.getpid()}.tmp"
|
||||
with open(temp, "w", encoding="utf-8") as handle:
|
||||
json.dump(data, handle, indent=2)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(temp, path)
|
||||
|
||||
|
||||
def _remove(path):
|
||||
try:
|
||||
os.unlink(path)
|
||||
except FileNotFoundError:
|
||||
pass # already gone: removing it is the goal
|
||||
|
||||
|
||||
def _is_network(path):
|
||||
text = str(path)
|
||||
if text.startswith(("\\\\", "//")):
|
||||
return True
|
||||
if os.name == "nt":
|
||||
try:
|
||||
import ctypes
|
||||
|
||||
drive = os.path.splitdrive(os.path.abspath(text))[0] + "\\"
|
||||
return ctypes.windll.kernel32.GetDriveTypeW(drive) == 4 # DRIVE_REMOTE
|
||||
except (AttributeError, OSError):
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def _volume(path):
|
||||
current = Path(os.path.abspath(path))
|
||||
while not current.exists() and current.parent != current:
|
||||
current = current.parent
|
||||
try:
|
||||
return os.stat(current).st_dev
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _free(path):
|
||||
current = Path(os.path.abspath(path))
|
||||
while not current.exists() and current.parent != current:
|
||||
current = current.parent
|
||||
try:
|
||||
return shutil.disk_usage(current).free
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
# ── the plan ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class SourcePlan:
|
||||
"""One listed source: its old store, its new store and what moves."""
|
||||
|
||||
root: str
|
||||
label: str
|
||||
key: str | None
|
||||
old_store: str | None
|
||||
new_store: str
|
||||
scan: files.StoreScan | None
|
||||
available: bool = True
|
||||
too_long: list = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MovePlan:
|
||||
"""A reviewed move: from, to, the sources and the removals."""
|
||||
|
||||
token: str
|
||||
created: float
|
||||
revision: str
|
||||
old: preview_location.ParsedLocation
|
||||
target: dict # {"mode", "path"}
|
||||
target_id: str | None
|
||||
target_exists: bool
|
||||
target_default: bool
|
||||
install: str
|
||||
sources: list
|
||||
unlisted: list # (key, store path, scan)
|
||||
needs_bytes: int
|
||||
confirm_required: bool
|
||||
|
||||
@property
|
||||
def to_folder(self):
|
||||
return self.target["mode"] == preview_store.FOLDER_MODE
|
||||
|
||||
@property
|
||||
def from_folder(self):
|
||||
return self.old.mode == preview_store.FOLDER_MODE
|
||||
|
||||
|
||||
def _roots_revision(roots):
|
||||
text = json.dumps(sorted(str(root) for root in roots))
|
||||
return hashlib.sha256(text.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
class ThumbnailMovePlanMixin:
|
||||
"""The move's review and its routes."""
|
||||
|
||||
_move_plans = None
|
||||
|
||||
# ── routes ────────────────────────────────────────────────────────
|
||||
|
||||
def _register_thumbnail_move_routes(self, routes):
|
||||
@routes.post("/prompt_manager/images/thumbnails/plan")
|
||||
async def thumbnail_move_plan_route(request):
|
||||
return await self.plan_thumbnail_move(request)
|
||||
|
||||
@routes.post("/prompt_manager/images/thumbnails/location/recreate")
|
||||
async def thumbnail_location_recreate_route(request):
|
||||
return await self.recreate_thumbnail_folder(request)
|
||||
|
||||
@routes.post("/prompt_manager/images/thumbnails/location/adopt")
|
||||
async def thumbnail_location_adopt_route(request):
|
||||
return await self.adopt_thumbnail_folder(request)
|
||||
|
||||
# ── review ────────────────────────────────────────────────────────
|
||||
|
||||
async def plan_thumbnail_move(self, request):
|
||||
try:
|
||||
body = await request.json()
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
body = None
|
||||
if not isinstance(body, dict) or body.get("mode") != "move":
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"code": "invalid-request",
|
||||
"error": "mode must be move",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
target = body.get("location")
|
||||
if not isinstance(target, dict) or target.get("mode") not in (
|
||||
preview_store.SOURCE_MODE,
|
||||
preview_store.FOLDER_MODE,
|
||||
):
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"code": "invalid-request",
|
||||
"error": "location must name a mode",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
blocker = self._jobs.blocker(THUMBNAILS_JOB_KIND, admission="guarded")
|
||||
if blocker is not None:
|
||||
return busy_response(JobBusy(blocker))
|
||||
try:
|
||||
outcome = await self._run_in_executor(self._build_move_plan, target)
|
||||
except Exception:
|
||||
self.logger.exception("The thumbnail move could not be planned")
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"code": None,
|
||||
"error": "The move could not be planned",
|
||||
},
|
||||
status=500,
|
||||
)
|
||||
if isinstance(outcome, str):
|
||||
return _refusal(outcome)
|
||||
plan, payload = outcome
|
||||
if payload.get("insufficient"):
|
||||
return _refusal(
|
||||
"insufficient-space",
|
||||
status=507,
|
||||
error_detail=payload["insufficient"],
|
||||
plan=payload,
|
||||
)
|
||||
self._store_move_plan(plan)
|
||||
return web.json_response({"success": True, **payload})
|
||||
|
||||
def _store_move_plan(self, plan):
|
||||
if self._move_plans is None:
|
||||
self._move_plans = {}
|
||||
now = time.monotonic()
|
||||
self._move_plans = {
|
||||
token: kept
|
||||
for token, kept in self._move_plans.items()
|
||||
if now - kept.created < PLAN_TTL
|
||||
}
|
||||
self._move_plans[plan.token] = plan
|
||||
|
||||
def _build_move_plan(self, target):
|
||||
"""``(plan, payload)`` or a refusal code (blocking)."""
|
||||
old = preview_store.setting()
|
||||
location = preview_store.current()
|
||||
install = old.install or str(uuid.uuid4())
|
||||
target_path = None
|
||||
default = default_path()
|
||||
if target["mode"] == preview_store.FOLDER_MODE:
|
||||
raw = target.get("path")
|
||||
if not isinstance(raw, str):
|
||||
return "location-not-absolute"
|
||||
context = self._thumbnail_location_context()
|
||||
code = preview_location.location_problem(
|
||||
raw, purpose="move", context=context
|
||||
)
|
||||
if code is not None:
|
||||
return code
|
||||
target_path = os.path.abspath(raw)
|
||||
elif old.mode == preview_store.SOURCE_MODE and not old.problem:
|
||||
return "location-same"
|
||||
target_default = bool(default) and os.path.normcase(
|
||||
target_path or ""
|
||||
) == os.path.normcase(os.path.abspath(default))
|
||||
target_exists = bool(target_path) and os.path.isdir(target_path)
|
||||
target_marker = (
|
||||
preview_location.read_marker(target_path) if target_exists else None
|
||||
)
|
||||
roots = [str(root) for root in self._get_all_output_dirs()]
|
||||
labels = self._source_labels(roots)
|
||||
old_keys = {}
|
||||
if old.mode == preview_store.FOLDER_MODE and location.available:
|
||||
marker = preview_location.read_marker(old.path) or {}
|
||||
old_keys = marker.get("keys") or {}
|
||||
new_keys = dict((target_marker or {}).get("keys") or {})
|
||||
taken = {key: entry.get("identity") for key, entry in new_keys.items()}
|
||||
sources = []
|
||||
for root in roots:
|
||||
identity = os.path.normcase(os.path.realpath(root))
|
||||
dev_ino = preview_location.file_identity(root)
|
||||
old_key = None
|
||||
old_store = None
|
||||
available = True
|
||||
if old.mode == preview_store.SOURCE_MODE:
|
||||
old_store = str(preview_store.source_folder(root))
|
||||
elif location.available:
|
||||
old_key = preview_location.find_key(old_keys, root, identity, dev_ino)
|
||||
if old_key:
|
||||
old_store = os.path.join(old.path, old_key)
|
||||
else:
|
||||
available = False
|
||||
key = None
|
||||
if target_path:
|
||||
key = old_key or preview_location.find_key(
|
||||
new_keys, root, identity, dev_ino
|
||||
)
|
||||
if key is None or taken.get(key, identity) != identity:
|
||||
key = preview_location.source_key(root, identity, taken)
|
||||
taken[key] = identity
|
||||
new_store = os.path.join(target_path, key)
|
||||
else:
|
||||
new_store = str(preview_store.source_folder(root))
|
||||
scan = None
|
||||
if old_store:
|
||||
scan = files.scan_store(old_store, root)
|
||||
plan = SourcePlan(
|
||||
root, labels.get(root, root), key, old_store, new_store, scan, available
|
||||
)
|
||||
if scan is not None and target_path:
|
||||
plan.too_long = [
|
||||
item[0]
|
||||
for item in scan.copy
|
||||
if files.too_long(Path(new_store).joinpath(*item[0]))
|
||||
]
|
||||
sources.append(plan)
|
||||
unlisted = self._unlisted_keys(old, location, old_keys, install)
|
||||
needs = sum(source.scan.bytes for source in sources if source.scan is not None)
|
||||
plan = MovePlan(
|
||||
token=secrets.token_urlsafe(16),
|
||||
created=time.monotonic(),
|
||||
revision=_roots_revision(roots),
|
||||
old=old,
|
||||
target={"mode": target["mode"], "path": target_path},
|
||||
target_id=(target_marker or {}).get("id"),
|
||||
target_exists=target_exists,
|
||||
target_default=target_default,
|
||||
install=install,
|
||||
sources=sources,
|
||||
unlisted=unlisted,
|
||||
needs_bytes=needs,
|
||||
confirm_required=bool(target_path) and not target_default,
|
||||
)
|
||||
return plan, self._plan_payload(plan, location)
|
||||
|
||||
def _unlisted_keys(self, old, location, keys, install):
|
||||
"""This install's keys whose source no longer is in any root list
|
||||
(``(key, store, scan)``), in the folder in use."""
|
||||
if old.mode != preview_store.FOLDER_MODE or not location.available:
|
||||
return []
|
||||
listed = {
|
||||
os.path.normcase(os.path.realpath(root))
|
||||
for root in self._all_source_roots()
|
||||
}
|
||||
found = []
|
||||
for key, entry in sorted(keys.items()):
|
||||
if not preview_location.valid_key(key) or not isinstance(entry, dict):
|
||||
continue # a marker is data: never a path out of the folder
|
||||
if entry.get("install") not in (install, None):
|
||||
continue
|
||||
if entry.get("identity") in listed:
|
||||
continue
|
||||
store = os.path.join(old.path, key)
|
||||
found.append(
|
||||
(key, store, files.scan_store(store, entry.get("path") or store))
|
||||
)
|
||||
return found
|
||||
|
||||
def _source_labels(self, roots):
|
||||
from . import sources
|
||||
|
||||
labels = sources._comfyui_labels()
|
||||
return {root: sources.source_label(root, labels) for root in roots}
|
||||
|
||||
def _plan_payload(self, plan, location):
|
||||
sources = plan.sources
|
||||
copies = sum(len(s.scan.copy) for s in sources if s.scan)
|
||||
orphans = sum(len(s.scan.orphans) for s in sources if s.scan)
|
||||
temps = sum(len(s.scan.temps) for s in sources if s.scan)
|
||||
links = sum(s.scan.links for s in sources if s.scan)
|
||||
left = [
|
||||
f"{s.label} · {'/'.join(item)}"
|
||||
for s in sources
|
||||
if s.scan
|
||||
for item in s.scan.left
|
||||
]
|
||||
too_long = [
|
||||
f"{s.label} · {'/'.join(item)}" for s in sources for item in s.too_long
|
||||
]
|
||||
unlisted_count = sum(
|
||||
len(scan.copy) + len(scan.orphans) for _k, _s, scan in plan.unlisted
|
||||
)
|
||||
free = None
|
||||
insufficient = None
|
||||
if plan.needs_bytes:
|
||||
volumes = {}
|
||||
for source in sources:
|
||||
if source.scan is None or not source.scan.bytes:
|
||||
continue
|
||||
volume = _volume(source.new_store)
|
||||
entry = volumes.setdefault(volume, [source.new_store, 0])
|
||||
entry[1] += source.scan.bytes
|
||||
for where, needed in volumes.values():
|
||||
available = _free(where)
|
||||
if available is None:
|
||||
continue
|
||||
free = available if free is None else min(free, available)
|
||||
margin = max(SPACE_MARGIN, int(needed * SPACE_SHARE))
|
||||
if needed + margin > available:
|
||||
insufficient = {
|
||||
"needed": needed + margin,
|
||||
"free": available,
|
||||
"text": (
|
||||
f"There is not enough space for the thumbnails: "
|
||||
f"{_size(available)} free, {_size(needed + margin)} "
|
||||
"needed. Free some space or choose another folder."
|
||||
),
|
||||
}
|
||||
payload = {
|
||||
"mode": "move",
|
||||
"from": {
|
||||
"mode": plan.old.mode,
|
||||
"path": plan.old.path,
|
||||
"available": location.available,
|
||||
},
|
||||
"to": {
|
||||
"mode": plan.target["mode"],
|
||||
"path": plan.target["path"],
|
||||
"exists": plan.target_exists,
|
||||
"will_create": bool(plan.target["path"]) and not plan.target_exists,
|
||||
"default": plan.target_default,
|
||||
},
|
||||
"sources": [
|
||||
{
|
||||
"label": s.label,
|
||||
"key": s.key,
|
||||
"files": len(s.scan.copy) if s.scan else 0,
|
||||
"bytes": s.scan.bytes if s.scan else 0,
|
||||
"too_long": len(s.too_long),
|
||||
"links": s.scan.links if s.scan else 0,
|
||||
"orphans": len(s.scan.orphans) if s.scan else 0,
|
||||
"left_in_place": len(s.scan.left) if s.scan else 0,
|
||||
"available": s.available,
|
||||
}
|
||||
for s in sources
|
||||
],
|
||||
"totals": {
|
||||
"sources": len(sources),
|
||||
"files": copies,
|
||||
"bytes": plan.needs_bytes,
|
||||
"orphans": orphans,
|
||||
"temps": temps,
|
||||
"links": links,
|
||||
"left_in_place": len(left),
|
||||
"too_long": len(too_long),
|
||||
"unlisted_sources": len(plan.unlisted),
|
||||
"unlisted_files": unlisted_count,
|
||||
},
|
||||
"too_long_listed": too_long[:LISTED],
|
||||
"left_listed": left[:LISTED],
|
||||
"unlisted": [
|
||||
{"key": key, "files": len(scan.copy) + len(scan.orphans)}
|
||||
for key, _store, scan in plan.unlisted
|
||||
],
|
||||
"needs_bytes": plan.needs_bytes,
|
||||
"free_bytes": free,
|
||||
"confirm_required": plan.confirm_required,
|
||||
"token": plan.token,
|
||||
"sentence": SENTENCE,
|
||||
}
|
||||
if insufficient:
|
||||
payload["insufficient"] = insufficient
|
||||
return payload
|
||||
@@ -0,0 +1,246 @@
|
||||
"""Move thumbnails after a restart and the unavailable folder's actions
|
||||
(#320 Parts 3 and 4): undo an interrupted copy, finish a removal that
|
||||
waited, Recreate folder and Use this folder."""
|
||||
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from . import preview_location, preview_store
|
||||
from . import thumbnail_move_files as files
|
||||
from .job_routes import busy_response
|
||||
from .jobs import THUMBNAILS_JOB_KIND, JobBusy
|
||||
from .thumbnail_move_plan import (
|
||||
FINISHING_TITLE,
|
||||
MOVE_GROUPS,
|
||||
_read_json,
|
||||
_refusal,
|
||||
_remove,
|
||||
)
|
||||
from . import thumbnail_move_plan as move_plan
|
||||
|
||||
|
||||
class ThumbnailMoveRecoveryMixin:
|
||||
"""Startup recovery, the finishing job, Recreate and Use this folder."""
|
||||
|
||||
_move_recover_lock = threading.Lock()
|
||||
# Per API: set once recover_thumbnail_move has run (a move waits for it).
|
||||
_move_recovered = False
|
||||
|
||||
# ── startup ───────────────────────────────────────────────────────
|
||||
|
||||
def recover_thumbnail_move(self):
|
||||
"""Startup (B4): undo an interrupted copy, or forget a journal the
|
||||
switch already made true. Idempotent; the move start waits for it."""
|
||||
with self._move_recover_lock:
|
||||
if self._move_recovered:
|
||||
return
|
||||
try:
|
||||
self._recover_journal()
|
||||
except Exception:
|
||||
self.logger.warning(
|
||||
"The interrupted thumbnail move check failed", exc_info=True
|
||||
)
|
||||
self._move_recovered = True
|
||||
|
||||
def _recover_journal(self):
|
||||
path = move_plan.journal_path()
|
||||
journal = _read_json(path)
|
||||
if journal is None:
|
||||
return
|
||||
setting = preview_store.setting()
|
||||
target = journal.get("to") or {}
|
||||
now = setting.path if setting.mode == preview_store.FOLDER_MODE else None
|
||||
if setting.mode == target.get("mode") and (
|
||||
now is None
|
||||
or os.path.normcase(now) == os.path.normcase(target.get("path") or "")
|
||||
):
|
||||
_remove(path) # the switch happened; only the journal was left
|
||||
return
|
||||
for store in journal.get("stores") or []:
|
||||
if not isinstance(store, str) or not os.path.isdir(store):
|
||||
continue
|
||||
for current, dirs, names in os.walk(store):
|
||||
dirs[:] = [
|
||||
d
|
||||
for d in dirs
|
||||
if not preview_location.is_link_like(Path(current) / d)
|
||||
]
|
||||
for name in names:
|
||||
if files.MOVE_TEMP.search(name):
|
||||
files.remove_file(Path(current) / name)
|
||||
for folder in reversed(journal.get("created_dirs") or []):
|
||||
if not isinstance(folder, str) or not os.path.isdir(folder):
|
||||
continue
|
||||
for current, dirs, names in os.walk(folder):
|
||||
dirs[:] = [
|
||||
d
|
||||
for d in dirs
|
||||
if not preview_location.is_link_like(Path(current) / d)
|
||||
]
|
||||
for name in names:
|
||||
if files._preview_parts(name) or files.is_temp_name(name):
|
||||
files.remove_file(Path(current) / name)
|
||||
files.remove_empty_folders(folder)
|
||||
folder = target.get("path")
|
||||
if journal.get("created_folder") and folder and os.path.isdir(folder):
|
||||
names = set(os.listdir(folder))
|
||||
if names <= {preview_location.MARKER_NAME}:
|
||||
_remove(preview_location.marker_path(folder))
|
||||
files.remove_empty_folders(folder)
|
||||
_remove(path)
|
||||
self.logger.info("An interrupted thumbnail move was undone; nothing changed")
|
||||
|
||||
def _cleanup_reachable(self, entry):
|
||||
folder = entry.get("folder")
|
||||
if folder:
|
||||
return os.path.isdir(folder)
|
||||
return True
|
||||
|
||||
async def finish_thumbnail_move(self, _app=None):
|
||||
"""Startup: recover, then start "Move thumbnails (finishing)" when old
|
||||
copies wait and every one is reachable (on the server's loop)."""
|
||||
await self._run_in_executor(self.recover_thumbnail_move)
|
||||
cleanup = list(preview_store.setting().cleanup)
|
||||
if not cleanup:
|
||||
return None
|
||||
reachable = await self._run_in_executor(
|
||||
lambda: all(self._cleanup_reachable(entry) for entry in cleanup)
|
||||
)
|
||||
if not reachable:
|
||||
return None
|
||||
try:
|
||||
return self._jobs.start(
|
||||
THUMBNAILS_JOB_KIND,
|
||||
FINISHING_TITLE,
|
||||
admission="guarded",
|
||||
cancellable=False,
|
||||
scope={"mode": "move", "phase": "remove"},
|
||||
groups=MOVE_GROUPS,
|
||||
runner=self._run_finishing,
|
||||
)
|
||||
except JobBusy:
|
||||
return None
|
||||
|
||||
async def _run_finishing(self, job):
|
||||
await self._remove_old_copies(job, None)
|
||||
left = len(preview_store.setting().cleanup)
|
||||
self._end_move(
|
||||
job,
|
||||
"finished",
|
||||
(
|
||||
"Old thumbnails removed."
|
||||
if not left
|
||||
else (
|
||||
"Some old thumbnails are still in use; they are removed at "
|
||||
"the next start."
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
# ── Recreate and Use this folder (Part 4) ─────────────────────────
|
||||
|
||||
async def recreate_thumbnail_folder(self, _request):
|
||||
blocker = self._jobs.blocker(THUMBNAILS_JOB_KIND, admission="guarded")
|
||||
if blocker is not None:
|
||||
return busy_response(JobBusy(blocker))
|
||||
code = await self._run_in_executor(self._recreate_folder)
|
||||
if code is not None:
|
||||
status = (
|
||||
409 if code in ("location-has-marker", "location-not-empty") else 400
|
||||
)
|
||||
if code == "location-has-marker":
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"code": code,
|
||||
"error": "The folder is back with its thumbnails. Nothing "
|
||||
"was recreated.",
|
||||
},
|
||||
status=status,
|
||||
)
|
||||
return _refusal(code, status=status)
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"location": self._thumbnail_location_payload(with_path=True),
|
||||
}
|
||||
)
|
||||
|
||||
def _recreate_folder(self):
|
||||
setting = preview_store.setting()
|
||||
if setting.mode != preview_store.FOLDER_MODE or not setting.path:
|
||||
return "location-same"
|
||||
folder = setting.path
|
||||
if os.path.isdir(folder):
|
||||
if preview_location.read_marker(folder) is not None:
|
||||
return "location-has-marker"
|
||||
if not preview_location.holds_only_metadata(folder):
|
||||
return "location-not-empty"
|
||||
elif not os.path.isdir(os.path.dirname(folder)):
|
||||
return "location-parent-missing"
|
||||
else:
|
||||
try:
|
||||
os.mkdir(folder)
|
||||
except OSError:
|
||||
return "location-not-writable"
|
||||
try:
|
||||
marker_id = preview_location.init_folder(folder, created_by_pm=True)
|
||||
except OSError:
|
||||
return "location-not-writable"
|
||||
return self._commit_location_id(marker_id)
|
||||
|
||||
async def adopt_thumbnail_folder(self, _request):
|
||||
blocker = self._jobs.blocker(THUMBNAILS_JOB_KIND, admission="guarded")
|
||||
if blocker is not None:
|
||||
return busy_response(JobBusy(blocker))
|
||||
code = await self._run_in_executor(self._adopt_folder)
|
||||
if code is not None:
|
||||
return _refusal(code)
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"location": self._thumbnail_location_payload(with_path=True),
|
||||
}
|
||||
)
|
||||
|
||||
def _adopt_folder(self):
|
||||
setting = preview_store.setting()
|
||||
if setting.mode != preview_store.FOLDER_MODE or not setting.path:
|
||||
return "location-same"
|
||||
try:
|
||||
marker = preview_location.read_marker(setting.path)
|
||||
except OSError:
|
||||
return "location-unavailable"
|
||||
if marker is None:
|
||||
return "location-unavailable"
|
||||
return self._commit_location_id(marker["id"])
|
||||
|
||||
def _commit_location_id(self, marker_id):
|
||||
from ..config import PromptManagerConfig
|
||||
from ..config_store import get_config_store
|
||||
|
||||
value = dict(preview_store.setting().to_config())
|
||||
value["id"] = marker_id
|
||||
|
||||
def mutate(config):
|
||||
section = config.get("thumbnails")
|
||||
if not isinstance(section, dict):
|
||||
section = {}
|
||||
section["location"] = value
|
||||
config["thumbnails"] = section
|
||||
return config
|
||||
|
||||
def publish(_config):
|
||||
PromptManagerConfig.THUMBNAIL_LOCATION = value
|
||||
preview_store.configure(value, check=False)
|
||||
|
||||
try:
|
||||
get_config_store().commit(mutate, expected_revision=None, publish=publish)
|
||||
except Exception:
|
||||
self.logger.exception("The thumbnail folder could not be saved")
|
||||
return "location-not-writable"
|
||||
preview_store.refresh()
|
||||
return None
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Remove thumbnails of removed sources (#320 Part 5, D200): a Thumbnail
|
||||
maintenance scope, offered only in folder mode (31A).
|
||||
|
||||
*Removed* means a key of this install in the thumbnail folder's marker whose
|
||||
identity matches no root in any root list. The job removes the
|
||||
preview-grammar files and temps of those key folders, ``rmdir``s the empty
|
||||
folders, drops the keys from the marker and lists anything else as "Left in
|
||||
place". Kind ``thumbnails``, ``mode: "prune"``.
|
||||
"""
|
||||
|
||||
import functools
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from . import preview_location, preview_store
|
||||
from . import thumbnail_move_files as files
|
||||
from .jobs import THUMBNAILS_JOB_KIND
|
||||
from .thumbnail_location import location_refusal
|
||||
from .thumbnail_move_plan import _number, _plural, _size
|
||||
|
||||
PRUNE_TITLE = "Remove thumbnails of removed sources"
|
||||
PRUNE_GROUPS = ("removed", "left", "errors")
|
||||
|
||||
|
||||
def _prune_line(count, files_count, size):
|
||||
if not count:
|
||||
return "No sources were removed: nothing to remove."
|
||||
return (
|
||||
f"{_plural(count, 'source')} no longer listed: "
|
||||
f"{_plural(files_count, 'thumbnail')} ({_size(size)}) are removed."
|
||||
)
|
||||
|
||||
|
||||
class ThumbnailPruneMixin:
|
||||
"""The prune plan and job."""
|
||||
|
||||
def _prune_targets(self):
|
||||
"""``[(key, store, scan)]`` of this install's unlisted keys (blocking)."""
|
||||
setting = preview_store.setting()
|
||||
location = preview_store.current()
|
||||
if setting.mode != preview_store.FOLDER_MODE or not location.available:
|
||||
return []
|
||||
marker = preview_location.read_marker(setting.path) or {}
|
||||
install = setting.install
|
||||
return self._unlisted_keys(setting, location, marker.get("keys") or {}, install)
|
||||
|
||||
def _prune_payload(self, targets):
|
||||
files_count = sum(
|
||||
len(scan.copy) + len(scan.orphans) for _k, _s, scan in targets
|
||||
)
|
||||
size = sum(scan.bytes for _k, _s, scan in targets)
|
||||
return {
|
||||
"success": True,
|
||||
"mode": "prune",
|
||||
"sources": [
|
||||
{
|
||||
"key": key,
|
||||
"files": len(scan.copy) + len(scan.orphans),
|
||||
"bytes": scan.bytes,
|
||||
"left_in_place": len(scan.left),
|
||||
}
|
||||
for key, _store, scan in targets
|
||||
],
|
||||
"targets": files_count,
|
||||
"bytes": size,
|
||||
"line": _prune_line(len(targets), files_count, size),
|
||||
"folder": preview_store.setting().path,
|
||||
}
|
||||
|
||||
async def get_prune_plan(self):
|
||||
refused = location_refusal()
|
||||
if refused is not None:
|
||||
return refused
|
||||
if preview_store.current().mode != preview_store.FOLDER_MODE:
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"code": "invalid-request",
|
||||
"error": "Thumbnails are kept inside each source: no removed "
|
||||
"source has thumbnails here.",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
targets = await self._run_in_executor(self._prune_targets)
|
||||
return web.json_response(
|
||||
self._prune_payload(targets), headers={"Cache-Control": "no-store"}
|
||||
)
|
||||
|
||||
def start_thumbnail_prune(self):
|
||||
"""Start the prune job (sync, on the loop); raises JobBusy."""
|
||||
return self._jobs.start(
|
||||
THUMBNAILS_JOB_KIND,
|
||||
PRUNE_TITLE,
|
||||
admission="guarded",
|
||||
cancellable=True,
|
||||
scope={"mode": "prune"},
|
||||
groups=PRUNE_GROUPS,
|
||||
runner=self._run_thumbnail_prune,
|
||||
)
|
||||
|
||||
async def _run_thumbnail_prune(self, job):
|
||||
targets = await self._run_in_executor(self._prune_targets)
|
||||
job.update(total=len(targets), processed=0)
|
||||
setting = preview_store.setting()
|
||||
for done, (key, store, scan) in enumerate(targets, 1):
|
||||
if job.cancelled:
|
||||
break
|
||||
removed, failed = await self._run_in_executor(
|
||||
functools.partial(self._prune_store, store)
|
||||
)
|
||||
job.record_counted(
|
||||
"removed", removed, [{"path": key, "detail": "Thumbnails removed"}]
|
||||
)
|
||||
if failed:
|
||||
job.record_counted("errors", failed)
|
||||
if scan.left:
|
||||
job.record_counted(
|
||||
"left",
|
||||
len(scan.left),
|
||||
[{"path": f"{key}/{'/'.join(item)}"} for item in scan.left],
|
||||
)
|
||||
await self._run_in_executor(
|
||||
functools.partial(self._forget_key, setting.path, key, store)
|
||||
)
|
||||
job.update(
|
||||
processed=done,
|
||||
progress=int(done * 100 / len(targets)),
|
||||
status_line=f"Removing thumbnails of {_number(done)} of "
|
||||
f"{_number(len(targets))} removed sources",
|
||||
)
|
||||
job.publish({"type": "progress", "summary": job.summary()})
|
||||
line = f"{_number(job.counters.get('removed', 0))} thumbnails removed"
|
||||
if job.counters.get("errors"):
|
||||
line += f" · {_plural(job.counters['errors'], 'error')}"
|
||||
job.update(status_line=line, progress=100)
|
||||
job.log_line(line)
|
||||
job.finish("cancelled" if job.cancelled else "finished")
|
||||
|
||||
@staticmethod
|
||||
def _prune_store(store):
|
||||
"""Remove the previews and temps of one key folder; ``(removed, failed)``."""
|
||||
removed = failed = 0
|
||||
store = Path(store)
|
||||
if not store.is_dir() or preview_location.is_link_like(store):
|
||||
return 0, 0
|
||||
for current, dirs, names in os.walk(store):
|
||||
dirs[:] = [
|
||||
d for d in dirs if not preview_location.is_link_like(Path(current) / d)
|
||||
]
|
||||
for name in names:
|
||||
path = Path(current) / name
|
||||
if not (files.is_temp_name(name) or files._preview_parts(name)):
|
||||
continue
|
||||
if preview_location.is_link_like(path):
|
||||
continue
|
||||
if files.remove_file(path):
|
||||
removed += 1
|
||||
else:
|
||||
failed += 1
|
||||
files.remove_empty_folders(store)
|
||||
return removed, failed
|
||||
|
||||
@staticmethod
|
||||
def _forget_key(folder, key, store):
|
||||
"""Drop *key* from the marker once its folder is gone."""
|
||||
if os.path.exists(store):
|
||||
return
|
||||
|
||||
def forget(marker):
|
||||
marker["keys"].pop(key, None)
|
||||
|
||||
with preview_store.write_lock:
|
||||
preview_location.write_marker(folder, forget)
|
||||
@@ -114,9 +114,16 @@ def validate_edge(value):
|
||||
# ── names ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def preview_stem(rel_path):
|
||||
"""``<rel without extension>_thumb`` (POSIX): a preview's name inside the
|
||||
store of its source (``preview_store``)."""
|
||||
return f"{Path(rel_path).with_suffix('').as_posix()}{THUMB_SUFFIX}"
|
||||
|
||||
|
||||
def thumbnail_stem_rel(rel_path):
|
||||
"""``thumbnails/<rel without extension>_thumb`` (POSIX)."""
|
||||
return f"thumbnails/{Path(rel_path).with_suffix('').as_posix()}{THUMB_SUFFIX}"
|
||||
"""``thumbnails/<rel without extension>_thumb`` (POSIX): URL text only.
|
||||
Where the preview is on disk is ``preview_store``'s answer."""
|
||||
return f"thumbnails/{preview_stem(rel_path)}"
|
||||
|
||||
|
||||
def thumbnail_rel(rel_path, extension=None):
|
||||
@@ -207,9 +214,20 @@ def owned_in_folder(folder, stem, source_extension, preferred=None):
|
||||
|
||||
|
||||
def _owned(root, rel_path, preferred):
|
||||
from . import preview_store
|
||||
|
||||
rel_path = Path(rel_path)
|
||||
stem = Path(root) / thumbnail_stem_rel(rel_path)
|
||||
return owned_in_folder(stem.parent, stem.name, rel_path.suffix, preferred)
|
||||
store = preview_store.store_dir(root)
|
||||
if store is None:
|
||||
return iter(())
|
||||
stem = store / preview_stem(rel_path)
|
||||
found = owned_in_folder(stem.parent, stem.name, rel_path.suffix, preferred)
|
||||
if preview_store.current().mode == preview_store.SOURCE_MODE:
|
||||
return found
|
||||
# Folder mode: timed, so a stalled share marks the folder unreachable,
|
||||
# and only previews no link takes out of the key folder (deletes use it).
|
||||
with preview_store.store_io():
|
||||
return iter([path for path in found if preview_store.may_write(path)])
|
||||
|
||||
|
||||
def owned_thumbnails(root, rel_path, preferred=None):
|
||||
|
||||
@@ -275,3 +275,4 @@ class WorkflowStorageRoutesMixin:
|
||||
job.record_counted("changed", 1)
|
||||
job.log_line(line)
|
||||
job.update(progress=100, status_line=line)
|
||||
return None
|
||||
|
||||
@@ -767,6 +767,8 @@ class PromptManagerConfig:
|
||||
THUMBNAIL_QUALITY = 80
|
||||
THUMBNAIL_EDGE = 512
|
||||
THUMBNAIL_EDGE_RANGE = (128, 2048)
|
||||
# #320 thumbnail location (config.json ``thumbnails.location``), as read.
|
||||
THUMBNAIL_LOCATION = None
|
||||
|
||||
@classmethod
|
||||
def max_worker_threads(cls) -> int:
|
||||
@@ -828,6 +830,12 @@ class PromptManagerConfig:
|
||||
"format": cls.THUMBNAIL_FORMAT,
|
||||
"quality": cls.THUMBNAIL_QUALITY,
|
||||
"edge": cls.THUMBNAIL_EDGE,
|
||||
# #320: as read; only the Move thumbnails job writes it.
|
||||
**(
|
||||
{"location": cls.THUMBNAIL_LOCATION}
|
||||
if cls.THUMBNAIL_LOCATION is not None
|
||||
else {}
|
||||
),
|
||||
},
|
||||
"gallery": GalleryConfig.get_config(),
|
||||
"integrations": IntegrationConfig.get_config(),
|
||||
@@ -1002,6 +1010,11 @@ class PromptManagerConfig:
|
||||
low, high = cls.THUMBNAIL_EDGE_RANGE
|
||||
if type(edge) is int and low <= edge <= high:
|
||||
cls.THUMBNAIL_EDGE = edge
|
||||
# #320: kept as read (``preview_location.parse_location`` checks it;
|
||||
# a bad value becomes an unavailable ``invalid`` location, never a
|
||||
# silent fall-back to the sources).
|
||||
if "location" in section:
|
||||
cls.THUMBNAIL_LOCATION = section["location"]
|
||||
|
||||
|
||||
# Load configuration on import
|
||||
|
||||
@@ -109,6 +109,7 @@ def _write_atomically(path: str, payload: bytes) -> None:
|
||||
try:
|
||||
os.unlink(temp_path)
|
||||
except OSError:
|
||||
# nothing to clean up; the original error is raised below
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
@@ -642,6 +642,7 @@ def layout_summary(layout: LoraManagerLayout, home: Path) -> Dict:
|
||||
relative = Path(settings_file).relative_to(home)
|
||||
settings_file = os.path.join("~", str(relative))
|
||||
except ValueError:
|
||||
# outside the home folder: show the full path
|
||||
pass
|
||||
return {
|
||||
"source": layout.source,
|
||||
|
||||
@@ -585,6 +585,7 @@ def _read_capped(resp, cap: int) -> Optional[bytes]:
|
||||
if int(declared) > cap:
|
||||
return None
|
||||
except ValueError:
|
||||
# a bad header says nothing; the read below enforces the cap
|
||||
pass
|
||||
|
||||
chunks = []
|
||||
@@ -707,6 +708,7 @@ def _save_atomically(image, local_path: Path) -> None:
|
||||
try:
|
||||
os.remove(temp)
|
||||
except OSError:
|
||||
# nothing to clean up; the original error is raised below
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "promptmanager"
|
||||
description = "A powerful ComfyUI custom node that extends the standard text encoder with persistent prompt storage, advanced search capabilities, and an automatic image gallery system using SQLite."
|
||||
version = "3.3.1"
|
||||
version = "3.4.1"
|
||||
license = {file = "LICENSE"}
|
||||
requires-python = ">=3.12"
|
||||
dependencies = ["watchdog>=2.1.0", "Pillow>=8.0.0"]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Runtime dependencies for PromptManager.
|
||||
# aiohttp, numpy and torch come from ComfyUI itself and are not listed here.
|
||||
watchdog>=2.1.0 # Output directory monitoring
|
||||
watchdog>=2.1.0 # Output directory monitoring
|
||||
Pillow>=8.0.0 # Image metadata extraction and thumbnails
|
||||
|
||||
@@ -6,7 +6,6 @@ import { THEMES, seedConcept, openConcept, openProduction, diffInventory, mutate
|
||||
// app.js 1428–1432) and its backup box with the concept's System `.notice`; the differences are D55. The job view
|
||||
// has no concept counterpart: it is compared with the approved mockup `activity-scan.html` (a local, ignored file),
|
||||
// whose structure and CSS values are restated below (D56). Each comparison is shown to notice a mutation.
|
||||
const workspace = '/prompt_manager/workspace';
|
||||
|
||||
test.beforeEach(async ({ page, request, browserName }) => {
|
||||
await startCoverage(page, browserName);
|
||||
|
||||
@@ -111,9 +111,23 @@ async function selectTheme(page,theme) {
|
||||
}
|
||||
|
||||
/** The concept at `#<route>` with its own Theme select. */
|
||||
/**
|
||||
* The recorded layout deviations (#530), applied to the opened concept page and never to its files, so every box that
|
||||
* follows from them (wider columns, wrapped text, row heights) is compared rather than excused path by path.
|
||||
* Every other measurement still differs if production moves. workspace-layout-tags-system.spec.mjs pins the real values
|
||||
* and that the concept itself still has the old ones.
|
||||
* D182: the second `.two-col` card starts level, and a surface after a row is 18px below it. D184: no 1000px limit on System. D183: the sticky tag header's background and drawn line.
|
||||
*/
|
||||
export const CONCEPT_LAYOUT_DEVIATIONS=`.two-col > .surface + .surface { margin-top: 0; }
|
||||
.two-col + .surface { margin-top: 18px; }
|
||||
.settings-layout { max-width: none; }
|
||||
.two-col > .surface > .table-wrap thead th { background: var(--ca-surface); border-bottom-color: transparent; }`;
|
||||
export const mapConceptLayout=page=>page.addStyleTag({content:CONCEPT_LAYOUT_DEVIATIONS});
|
||||
|
||||
export async function openConcept(page,route,theme,motion='no-preference') {
|
||||
await page.emulateMedia({reducedMotion:motion});
|
||||
await page.goto('about:blank');await page.goto(`${CONCEPT_URL}#${route}`);
|
||||
await mapConceptLayout(page);
|
||||
await selectTheme(page,theme);
|
||||
await expect(page.locator('#main #page-title')).toBeVisible();
|
||||
}
|
||||
|
||||
@@ -362,10 +362,14 @@ test('Show in folder asks the server to open the listed file and announces each
|
||||
const body=sent.postDataJSON();
|
||||
expect(Object.keys(body).sort()).toEqual(['path','root_index','root_scope']);
|
||||
expect(body).toMatchObject({root_index:0,path:'inspector/complete.png'});expect(body.root_scope).toMatch(/^[0-9a-f]{64}$/);
|
||||
await expect(toast).toHaveText('Opened the folder on this computer.');
|
||||
await expect(toast).toHaveText('Opened the folder on the computer running ComfyUI.');
|
||||
// #533: the answer is really on screen and reachable above the modal viewer, not covered by it.
|
||||
await expect(toast).toBeVisible();
|
||||
expect(await toast.evaluate(node=>{const box=node.getBoundingClientRect();const top=document.elementFromPoint(box.x+box.width/2,box.y+box.height/2);return top===node;})).toBe(true);
|
||||
await expect(page.locator('#viewer-dialog').getByRole('status').filter({hasText:'Opened the folder on the computer running ComfyUI.'})).toHaveCount(1);
|
||||
const opened=async()=>(await (await request.get('/__test__/reveal')).json()).opened;
|
||||
expect(await opened()).toEqual(['output/inspector/complete.png']);
|
||||
for (const [outcome,message] of [['unavailable','No file manager is available on this computer.'],['fail','The folder could not be opened. Try again.']]) {
|
||||
for (const [outcome,message] of [['unavailable','No file manager is available on the computer running ComfyUI.'],['fail','The folder could not be opened. Try again.']]) {
|
||||
await expect(await request.post('/__test__/reveal',{data:{outcome}})).toBeOK();
|
||||
await show.click();await expect(toast).toHaveText(message);
|
||||
}
|
||||
@@ -373,11 +377,56 @@ test('Show in folder asks the server to open the listed file and announces each
|
||||
await page.route('**/prompt_manager/images/reveal',route=>route.fulfill({status:429,contentType:'application/json',body:JSON.stringify({success:false,code:'busy',error:'The folder is already opening.'})}));
|
||||
await show.click();await expect(toast).toHaveText('The folder is already opening.');
|
||||
await page.unroute('**/prompt_manager/images/reveal');
|
||||
// A proxied request counts as remote, so the real route answers 403 not-local.
|
||||
// A proxied click opens the folder: the proxy's headers, not the browser's address, name the page (#533).
|
||||
await expect(await request.post('/__test__/reveal',{data:{outcome:'opened'}})).toBeOK();
|
||||
await page.route('**/prompt_manager/images/reveal',route=>route.continue({headers:{...route.request().headers(),'x-forwarded-for':'203.0.113.7'}}));
|
||||
await show.click();await expect(toast).toHaveText('Show in folder works only in a browser on the computer running ComfyUI.');
|
||||
expect(await opened()).toEqual(['output/inspector/complete.png']);
|
||||
await show.click();await expect(toast).toHaveText('Opened the folder on the computer running ComfyUI.');
|
||||
expect(await opened()).toHaveLength(2);
|
||||
await page.unroute('**/prompt_manager/images/reveal');
|
||||
// A plain cross-site POST is refused, and the toast says so.
|
||||
// Chromium keeps its own Origin on a routed fetch, so the real route is asked directly and the toast gets its answer.
|
||||
const refused=await request.post('/prompt_manager/images/reveal',{data:body,headers:{origin:'https://evil.example'}});
|
||||
expect(refused.status()).toBe(403);expect((await refused.json()).code).toBe('not-same-origin');
|
||||
await page.route('**/prompt_manager/images/reveal',route=>route.fulfill({status:403,contentType:'application/json',body:JSON.stringify({success:false,code:'not-same-origin',error:"This page's address does not match the server's."})}));
|
||||
await show.click();await expect(toast).toHaveText("The folder was not opened: this page's address does not match the server's.");
|
||||
await expect(toast).toBeVisible();
|
||||
expect(await opened()).toHaveLength(2);
|
||||
// #533: closing the viewer takes the toast back to the page, still showing the message.
|
||||
await page.keyboard.press('Escape');await expect(page.locator('#viewer-dialog')).toHaveCount(0);
|
||||
await expect(toast).toBeVisible();await expect(page.locator('body > #workspace-toast')).toHaveCount(1);
|
||||
});
|
||||
// #533 D176: real modals in the real top layer, with the page's own toast module.
|
||||
test('a toast stays on screen across nested, switched and closing modals',async({page,request})=>{
|
||||
await fullFixture(page,request);
|
||||
await page.evaluate(async()=>{
|
||||
const url=performance.getEntriesByType('resource').map(entry=>entry.name).find(name=>name.includes('/toast.mjs'));
|
||||
const {showWorkspaceToast}=await import(url);
|
||||
window.say=showWorkspaceToast;
|
||||
window.modal=(id,first=false)=>{const dialog=document.createElement('dialog');dialog.id=id;dialog.textContent=id;document.body[first?'prepend':'append'](dialog);dialog.showModal();return dialog;};
|
||||
window.onTop=()=>{const node=document.getElementById('workspace-toast');const box=node.getBoundingClientRect();return box.width>0&&document.elementFromPoint(box.x+box.width/2,box.y+box.height/2)===node;};
|
||||
});
|
||||
const parent=()=>page.evaluate(()=>{const host=document.getElementById('workspace-toast')?.parentElement;return host?host.id||host.localName:null;});
|
||||
const onTop=()=>page.evaluate(()=>window.onTop());
|
||||
await page.evaluate(()=>{window.modal('a');say('one');});
|
||||
expect(await parent()).toBe('a');expect(await onTop()).toBe(true);
|
||||
// Nested: the top one holds it, even earlier in the page than the one under it.
|
||||
await page.evaluate(()=>{window.modal('b',true);say('two');});
|
||||
expect(await parent()).toBe('b');expect(await onTop()).toBe(true);
|
||||
// The top one closes while the toast shows: the message stays, in the modal now on top.
|
||||
await page.evaluate(()=>document.getElementById('b').close());
|
||||
await expect.poll(parent).toBe('a');expect(await onTop()).toBe(true);
|
||||
await expect(page.locator('#workspace-toast')).toHaveText('two');
|
||||
// Switched: the next modal takes the next toast.
|
||||
await page.evaluate(()=>{document.getElementById('a').close();window.modal('c');say('three');});
|
||||
expect(await parent()).toBe('c');expect(await onTop()).toBe(true);
|
||||
// Closed and removed at once, as the workspace's dialogs do: home, still showing.
|
||||
await page.evaluate(()=>{const c=document.getElementById('c');c.close();c.remove();});
|
||||
await expect.poll(parent).toBe('body');expect(await onTop()).toBe(true);
|
||||
await expect(page.locator('#workspace-toast')).toHaveText('three');
|
||||
// Removed while open, with no close event: the next message still shows.
|
||||
await page.evaluate(()=>{window.modal('d');say('four');document.getElementById('d').remove();say('five');});
|
||||
expect(await parent()).toBe('body');expect(await onTop()).toBe(true);
|
||||
await expect(page.locator('#workspace-toast')).toHaveText('five');
|
||||
});
|
||||
test('full inspector shows six facts and scoped provenance',async({page,request},testInfo)=>{
|
||||
await page.setViewportSize({width:1600,height:1100});await fullFixture(page,request);const panel=inspector(page);
|
||||
@@ -415,7 +464,7 @@ test('full inspector preserves long role text and exact sends',async({page,reque
|
||||
await fullFixture(page,request);const panel=inspector(page);const positive=panel.locator('.prompt-section.positive .prompt-text');await expect(positive).toContainText('猫 😀');const text=await positive.textContent();expect(text.length).toBeGreaterThan(1000);
|
||||
await panel.locator('.detail-footer button.send').click();const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
|
||||
await expect(sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).locator('option')).toContainText(['Exact host']);
|
||||
await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'Exact host'});await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'Exact pair'});
|
||||
await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'Exact host'});await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'#pair Exact pair'});
|
||||
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.');
|
||||
expect(await host.evaluate(()=>window.sent)).toEqual([{positive:text,negative:'blurred edges, watermark'}]);
|
||||
} finally {await host.evaluate(()=>window.bridge.dispose());await host.close();}
|
||||
@@ -627,6 +676,10 @@ test('viewer matches the concept shell and toolbar order',async({page,request})=
|
||||
expect(modePaths.map(path=>path.split(' > ').slice(2).join(' > '))).toEqual(['button#viewer-mode.icon-btn','button#viewer-mode.icon-btn > svg.icon','button#viewer-mode.icon-btn > svg.icon > path']);
|
||||
expect(production.paths[production.paths.indexOf(modePaths[2])+1]).toBe('dialog#viewer-dialog > div.dialog-header > button#viewer-close');
|
||||
production.paths=production.paths.filter(path=>!modePaths.includes(path));
|
||||
// #532 D170: production's shortcuts button sits right before the view switch; the concept has none.
|
||||
const keysPaths=production.paths.filter(path=>path.includes('button#viewer-keys'));
|
||||
expect(keysPaths.map(path=>path.split(' > ').slice(2).join(' > '))).toEqual(['button#viewer-keys.icon-btn','button#viewer-keys.icon-btn > svg.icon','button#viewer-keys.icon-btn > svg.icon > path']);
|
||||
production.paths=production.paths.filter(path=>!keysPaths.includes(path));
|
||||
// #480 D159: the Images viewer's Delete… (`danger`) follows Download; the concept has none.
|
||||
expect(production.toolbar[8].text).toBe('Delete…');
|
||||
production.toolbar.splice(8,1);
|
||||
|
||||
@@ -107,7 +107,7 @@ export async function exerciseCurrentPairSend(page, roles) {
|
||||
const send = viewer.getByRole('button', { name: /^Send prompt pair to ComfyUI: / });await send.click();
|
||||
const sender = page.getByRole('dialog', { name: 'Send to ComfyUI', exact: true });await expect(sender).toBeVisible();
|
||||
await sender.getByRole('combobox', { name: 'ComfyUI window', exact: true }).selectOption({ label: 'Canonical ComfyUI' });
|
||||
await sender.getByRole('combobox', { name: 'Destination node', exact: true }).selectOption({ label: 'Canonical destination' });
|
||||
await sender.getByRole('combobox', { name: 'Destination node', exact: true }).selectOption({ label: '#canonical-pair Canonical destination' });
|
||||
await sender.getByRole('button', { name: 'Preview changes', exact: true }).click();
|
||||
await expect(sender).toContainText(roles.positive);await expect(sender).toContainText(roles.negative);
|
||||
await sender.getByRole('button', { name: 'Apply to ComfyUI', exact: true }).click();await expect(sender).toContainText('Sent to ComfyUI.');
|
||||
|
||||
@@ -194,7 +194,7 @@ function productionSent(host) {
|
||||
return async page=>{
|
||||
const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
|
||||
await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'Concept ComfyUI'});
|
||||
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'Concept destination'});
|
||||
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'#pair-1 Concept destination'});
|
||||
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
|
||||
await expect.poll(()=>host.evaluate(()=>window.previewed.length)).toBe(1);
|
||||
const roles=await host.evaluate(()=>window.previewed.pop());
|
||||
|
||||
@@ -474,7 +474,10 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`images selecti
|
||||
expect.soft(actual.bulkbar.bar.items.map(item=>item.text),'production bar').toEqual(['1 selected','Add tags','Auto-tag selected',
|
||||
'Set category','Add to collection','Export metadata','Delete…','Clear']);
|
||||
expect.soft(compare(empty,emptyActual,manifest,['bulkbar','cards','panel']),'nothing checked').toEqual([]);
|
||||
expect.soft(compare(oracle,actual,manifest,['bulkbar','cards','panel']),'one card checked').toEqual([]);
|
||||
// D186: the panel's sticky offset clears the shown bar; at one column the bar is one sideways row (its items are checked above).
|
||||
const stuckPanel={...oracle,panel:{...oracle.panel,...(width<=700?{top:actual.panel.top}:{}),box:{...oracle.panel.box,top:actual.panel.box.top}}};
|
||||
expect(parseFloat(actual.panel.box.top)).toBeGreaterThanOrEqual(20);
|
||||
expect.soft(compare(stuckPanel,actual,manifest,width<=700?['panel']:['bulkbar','cards','panel']),'one card checked').toEqual([]);
|
||||
// The bar comparison notices a change to the production bar.
|
||||
await mutate(page,{css:'.bulkbar { padding: 4px }'});
|
||||
expect(compare(oracle,await inventoryOf(page,'#workspace-main'),manifest,['bulkbar']).map(item=>item.path)).toContain('bulkbar.bar.paddingTop');
|
||||
|
||||
@@ -259,7 +259,7 @@ async function productionSendOf(page,context) {
|
||||
const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});await expect(sender).toBeVisible();
|
||||
const windows=sender.getByRole('combobox',{name:'ComfyUI window',exact:true});
|
||||
await expect(windows.locator('option')).toContainText(['Test ComfyUI']);await windows.selectOption({label:'Test ComfyUI'});
|
||||
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'My prompt pair'});
|
||||
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'#pair-1 My prompt pair'});
|
||||
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
|
||||
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();
|
||||
await expect(sender).toContainText('Sent to ComfyUI.');
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// A job's end toast follows the job, which has no time limit: under load it outlasts the 5 s expect default (#533, as
|
||||
// #520). Do `action`, whose POST to `url` answers with the job it started, then wait for that job's event stream to end.
|
||||
export const APPLY_URL = '/prompt_manager/maintenance/apply';
|
||||
export const RETURN_URL = '/prompt_manager/images/removed/return';
|
||||
|
||||
export async function startAndAwaitJob(page, action, url = APPLY_URL) {
|
||||
const applied = page.waitForResponse(response => response.url().endsWith(url));
|
||||
await action();
|
||||
const { job } = await (await applied).json();
|
||||
const events = await page.request.get(`/prompt_manager/jobs/${job.id}/events`);
|
||||
if (!events.ok()) throw new Error(`Job ${job.id} events answered ${events.status()}`);
|
||||
return job;
|
||||
}
|
||||
@@ -233,7 +233,7 @@ function productionSent(host) {
|
||||
return async page=>{
|
||||
const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
|
||||
await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'Concept ComfyUI'});
|
||||
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'Concept destination'});
|
||||
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'#pair-1 Concept destination'});
|
||||
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
|
||||
await expect.poll(()=>host.evaluate(()=>window.previewed.length)).toBe(1);
|
||||
const roles=await host.evaluate(()=>window.previewed.pop());
|
||||
@@ -374,7 +374,9 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`the Library se
|
||||
await captureCanonicalState(page,testInfo,`production-library-selection-${width}-${theme}`);
|
||||
await testInfo.attach('library-selection',{body:JSON.stringify({reference,actual},null,2),contentType:'application/json'});
|
||||
expect(reference.bar.buttons.map(button=>button.text),'the concept bar has every Library action').toEqual(['Add tags','Set category','Clear']);
|
||||
expect(actual).toEqual(reference);
|
||||
// D186: at one column the bar is one row that scrolls sideways, with the same buttons.
|
||||
const mapped=width<=700?{...reference,bar:{...reference.bar,flexWrap:'nowrap',buttons:reference.bar.buttons.map((button,index)=>({...button,left:actual.bar.buttons[index].left,top:actual.bar.buttons[index].top}))}}:reference;
|
||||
expect(actual).toEqual(mapped);
|
||||
// The comparison notices a moved checkbox (spec §7 mutation check).
|
||||
await mutate(page,{css:'.prompt-card .image-overlays { inset: 20px 9px auto }'});
|
||||
expect((await measureSelection(page,{card:CARDS,main:'#workspace-main'})).overlay.top).not.toBe(reference.overlay.top);
|
||||
|
||||
@@ -33,7 +33,7 @@ async function chooseDestination(page) {
|
||||
const host=sender.getByRole('combobox',{name:'ComfyUI window',exact:true});
|
||||
await expect(host.locator('option')).toContainText(['Test ComfyUI']);
|
||||
await host.selectOption({label:'Test ComfyUI'});
|
||||
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'My prompt pair'});
|
||||
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'#pair-1 My prompt pair'});
|
||||
return sender;
|
||||
}
|
||||
|
||||
@@ -165,7 +165,8 @@ test('host apply rejection retains the current text and requires a new preview',
|
||||
await page.goto(workspace);await select(page,'0:shared/same.png');await details(page).getByRole('button',{name:/^Send positive prompt to ComfyUI: /}).click();
|
||||
const sender=await chooseDestination(page);await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
|
||||
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText(/Workflow changed/);
|
||||
await expect(sender.getByRole('button',{name:'Apply to ComfyUI',exact:true})).toBeDisabled();
|
||||
// D175: a refusal wrote nothing, so Apply may try again.
|
||||
await expect(sender.getByRole('button',{name:'Apply to ComfyUI',exact:true})).toBeEnabled();
|
||||
expect(await host.evaluate(()=>window.hostValues.positive)).toBe('Existing positive');
|
||||
} finally {await host.evaluate(()=>window.fixtureHost.dispose());await host.close();}
|
||||
});
|
||||
@@ -193,7 +194,7 @@ test('empty negative send clears only the negative destination and preserves its
|
||||
test('no open ComfyUI window is actionable and rediscovery finds a newly opened host',async({page,context})=>{
|
||||
await page.goto(workspace);await select(page,'0:shared/same.png');
|
||||
await details(page).getByRole('button',{name:/^Send positive prompt to ComfyUI: /}).click();
|
||||
const sender=dialog(page);await expect(sender).toContainText('No compatible ComfyUI windows found.');
|
||||
const sender=dialog(page);await expect(sender).toContainText('No ComfyUI window answered in this browser');
|
||||
const host=await fakeHost(context,page);
|
||||
try {
|
||||
await sender.getByRole('button',{name:'Find ComfyUI windows',exact:true}).click();
|
||||
@@ -319,3 +320,44 @@ test('copy toast and fallback dialog',async({page})=>{
|
||||
await expect(fallback).toContainText('Automatic clipboard access requires a secure browser context.');
|
||||
await page.keyboard.press('Escape');await expect(fallback).toBeHidden();await expect(copy).toBeFocused();
|
||||
});
|
||||
|
||||
// #542 D191: "All N eligible nodes" previews every node, sends one after another and names each refusal.
|
||||
test('All eligible nodes previews each node, sends in node id order and names a refusal',async({page,context},testInfo)=>{
|
||||
const host=await context.newPage();await host.goto('/prompt_manager/workspace#/overview');
|
||||
await host.evaluate(async()=>{
|
||||
const {createBridgeHost}=await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js');
|
||||
const targets=['12','3','7'].map(id=>({id,label:id==='7'?'Edited in ComfyUI':'Prompt Manager (+/-)',type:'PromptManagerPair',roles:['positive','negative']}));
|
||||
window.hostValues=Object.fromEntries(targets.map(target=>[target.id,{positive:`Existing ${target.id}`,negative:'Existing negative'}]));window.applied=[];
|
||||
window.fixtureHost=createBridgeHost({origin:location.origin,namespace:'/',label:'Test ComfyUI',adapter:{
|
||||
listTargets:()=>targets,
|
||||
preview:(id,roles)=>{const target=targets.find(item=>item.id===id);return{preview:{target,fields:Object.entries(roles).map(([role,after])=>({role,before:window.hostValues[id][role],after}))},snapshot:{id,roles}};},
|
||||
apply:({id,roles})=>{if(id==='7')throw new Error('Workflow or prompt changed. Preview again.');Object.assign(window.hostValues[id],roles);window.applied.push(id);return{targetId:id,values:roles};},
|
||||
}});
|
||||
});
|
||||
try {
|
||||
await page.goto(workspace);await select(page,'0:shared/same.png');await details(page).getByRole('button',{name:/^Send prompt pair to ComfyUI: /}).click();
|
||||
const sender=dialog(page);const nodes=sender.getByRole('combobox',{name:'Destination node',exact:true});
|
||||
await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'Test ComfyUI'});
|
||||
await expect(nodes.locator('option')).toHaveText(['Choose a destination node','#12 Prompt Manager (+/-)','#3 Prompt Manager (+/-)','#7 Edited in ComfyUI','All 3 eligible nodes']);
|
||||
await expect(nodes).toHaveValue('');
|
||||
await nodes.selectOption({label:'All 3 eligible nodes'});
|
||||
await expect(sender.getByRole('status')).toHaveText('Sending to all 3 eligible nodes in Test ComfyUI. Apply to send it, or preview the changes first.');
|
||||
await page.screenshot({path:testInfo.outputPath('send-all-option.png')});
|
||||
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
|
||||
const reviews=sender.locator('section.prompt-sender-node');
|
||||
await expect(reviews.locator('> h4')).toHaveText(['#3 Prompt Manager (+/-)','#7 Edited in ComfyUI','#12 Prompt Manager (+/-)']);
|
||||
await expect(sender.getByRole('region',{name:'#12 Prompt Manager (+/-)',exact:true})).toContainText('Existing 12');
|
||||
expect(await host.evaluate(()=>window.applied)).toEqual([]);
|
||||
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();
|
||||
await expect(sender.getByRole('alert')).toHaveText('Sent to 2 of 3 nodes in Test ComfyUI. #7 Edited in ComfyUI: Workflow or prompt changed. Preview again.');
|
||||
expect(await host.evaluate(()=>window.applied)).toEqual(['3','12']);
|
||||
expect(await host.evaluate(()=>window.hostValues['7'].positive)).toBe('Existing 7');
|
||||
await page.screenshot({path:testInfo.outputPath('send-all-result.png')});
|
||||
await page.setViewportSize({width:390,height:844});
|
||||
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();await expect(reviews).toHaveCount(3);
|
||||
await reviews.last().scrollIntoViewIfNeeded();
|
||||
expect(await page.evaluate(()=>document.querySelector('dialog.prompt-sender').scrollWidth<=document.querySelector('dialog.prompt-sender').clientWidth+1)).toBe(true);
|
||||
await page.screenshot({path:testInfo.outputPath('send-all-mobile.png')});
|
||||
const apply=sender.getByRole('button',{name:'Apply to ComfyUI',exact:true});await apply.focus();await expect(apply).toBeFocused();
|
||||
} finally {await host.evaluate(()=>window.fixtureHost.dispose());await host.close();}
|
||||
});
|
||||
|
||||
@@ -26,6 +26,10 @@ async function openReview(page, theme) {
|
||||
await openProduction(page, 'review', theme);
|
||||
await expect(panel(page).getByRole('combobox', { name: 'Sampler context', exact: true })).toBeEnabled();
|
||||
await settle(page);
|
||||
// D179, D181: the two buttons the concept lacks are pinned here, then left out of the comparison.
|
||||
await expect(panel(page).getByRole('button', { name: 'Leave unresolved', exact: true })).toBeVisible();
|
||||
await expect(panel(page).getByRole('button', { name: 'Link to a prompt…', exact: true })).toBeVisible();
|
||||
await page.addStyleTag({ content: '.review-panel .review-leave-unresolved, .review-panel .review-link-open { display: none !important; }' });
|
||||
}
|
||||
async function settle(page) {
|
||||
await page.mouse.move(0, 0);
|
||||
@@ -90,7 +94,7 @@ for (const [width, height] of SIZES) for (const theme of THEMES) {
|
||||
expect(surface.children[1].text).toBe(CONCEPT_FILE);
|
||||
const actions = surface.children.at(-1);
|
||||
expect(actions.children.map(button => [button.text, button.primary])).toEqual([['Leave for later', false], ['Apply association', true]]);
|
||||
expect(node(surface, 'select').options.at(-1)).toEqual(['Leave unresolved', false]);
|
||||
expect(node(surface, 'select').options.map(option => option[0])).not.toContain('Leave unresolved'); // D179
|
||||
expect(node(main, 'div.image-grid').children.map(card => node(card, 'h3').text))
|
||||
.toEqual(['alpine_00001.png', 'dunes_00001.png', 'forest_00001.png']);
|
||||
expect(actual.noPageScroll).toBe(true);
|
||||
@@ -169,7 +173,7 @@ test('the comparison notices a dropped notice border and reordered actions', asy
|
||||
const reordered = await productionInventory(page);
|
||||
expect(diffInventory(reference, reordered).map(item => item.path)).toEqual(expect.arrayContaining([
|
||||
'main.children.2.children.1.children.6.children.0.text', 'main.children.2.children.1.children.6.children.0.primary']));
|
||||
await panel(page).locator('.dialog-actions').evaluate(row => row.prepend(row.lastElementChild));
|
||||
await panel(page).locator('.dialog-actions').evaluate(row => row.append(row.firstElementChild));
|
||||
expect(diffInventory(reference, await productionInventory(page))).toEqual([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -22,6 +22,9 @@ from PIL.PngImagePlugin import PngInfo
|
||||
from tests.browser.folder_trash import close_trash, replace_folder
|
||||
from tests.browser.malloc_tuning import pin_mmap_threshold
|
||||
from tests.browser.request_hold import register_request_holds
|
||||
from tests.browser.thumbnail_location_fixture import (
|
||||
register_thumbnail_location_commands,
|
||||
)
|
||||
from tests.browser import slow_reset_watch
|
||||
from tests.browser.fast_reset import SEED_ON_LIVE_ENV, ScratchSeeder
|
||||
from tests.browser.workspace_activity_fixture import register_activity_commands
|
||||
@@ -240,6 +243,7 @@ def create_app(root: Path) -> web.Application:
|
||||
library_editor_commands = register_library_editor_commands(routes, api, root)
|
||||
register_vocabulary_commands(routes, api, root)
|
||||
restore_sources = register_source_change_commands(routes, api)
|
||||
reset_thumbnail_location = register_thumbnail_location_commands(routes, api, root)
|
||||
register_tagging_commands(routes, api, root, model, on_reset=restore_sources)
|
||||
register_review_commands(routes, api, root)
|
||||
register_collections_commands(routes, api, root)
|
||||
@@ -451,6 +455,8 @@ def create_app(root: Path) -> web.Application:
|
||||
restore_sources()
|
||||
# No preview queued by the last test may land in the new fixture.
|
||||
await api._run_in_executor(api.close_auto_thumbnails)
|
||||
# Previews inside each source again (#320).
|
||||
await api._run_in_executor(reset_thumbnail_location)
|
||||
library_editor_commands.reset()
|
||||
await api._run_in_executor(reset_logs)
|
||||
data = await request.json() if request.can_read_body else {}
|
||||
@@ -595,6 +601,7 @@ def create_app(root: Path) -> web.Application:
|
||||
history_commands.reset()
|
||||
library_editor_commands.reset()
|
||||
reveal_stub.reset()
|
||||
await api._run_in_executor(reset_thumbnail_location)
|
||||
history_fixture = None
|
||||
manifest = await api._run_in_executor(
|
||||
seeder.run,
|
||||
|
||||