Merge remote-tracking branch 'origin/main' into feat/249-library-location

This commit is contained in:
Vito Sansevero
2026-10-24 08:39:45 -07:00
31 changed files with 2874 additions and 232 deletions
+3
View File
@@ -9,6 +9,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
- AutoThumbnailMixin: previews made on first view, on capture and after a scan
- ReviewRoutesMixin: the Needs review queue, item detail and decisions
- ReviewRecordingMixin: Needs review recording for the scan and the monitor
- ReviewEventsMixin: the Needs review change stream an open page re-reads on
@@ -49,6 +50,7 @@ from .settings_routes import SettingsRoutesMixin
from .job_routes import JobRoutesMixin
from .image_tag_jobs import ImageTagJobsMixin
from .thumbnail_jobs import ThumbnailJobsMixin
from .thumbnail_auto import AutoThumbnailMixin
from .system import SystemRoutesMixin
from .library_location import LibraryLocationRoutesMixin
from .maintenance import MaintenanceRoutesMixin
@@ -293,6 +295,7 @@ class PromptManagerAPI(
JobRoutesMixin,
ImageTagJobsMixin,
ThumbnailJobsMixin,
AutoThumbnailMixin,
SystemRoutesMixin,
LibraryLocationRoutesMixin,
MaintenanceRoutesMixin,
+3
View File
@@ -1504,6 +1504,9 @@ class AdminRoutesMixin:
verb = "cancelled" if state == "cancelled" else "finished"
job.log_line(f"Scan {verb} · {_plural(job.processed, 'file')} processed")
job.finish(state)
if state == "finished":
# After the scan, so the guarded thumbnail job is admitted (#249).
await self._hand_off_scan_thumbnails(job, output_dirs)
async def _stream_scan_job(self, request, job):
"""Stream ``job`` as SSE; stop quietly if the client goes away."""
+42 -20
View File
@@ -734,7 +734,10 @@ class HistoryRoutesMixin:
raise HistoryReadError(422, "response-limit") from None
else:
prepared = _prepare_current_media(
resolved, scope, thumbnail=kind == "thumbnail"
resolved,
scope,
thumbnail=kind == "thumbnail",
**media_options,
)
_check_current(scope, self, fence, epoch, generation)
return prepared
@@ -761,25 +764,44 @@ class HistoryRoutesMixin:
_check_current(scope, self, fence, epoch, generation)
raise
pending = asyncio.create_task(self._run_in_executor(project))
try:
prepared = await asyncio.shield(pending)
except asyncio.CancelledError:
pending.add_done_callback(_close_prepared_result)
raise
except (
HistoryReadError,
HistoryEpochChanged,
sqlite3.Error,
OSError,
ValueError,
):
raise
except Exception as error:
self.logger.warning(
"Current history media unavailable (%s)", type(error).__name__
)
raise _current_unavailable("temporary_unavailable") from None
async def execute():
pending = asyncio.create_task(self._run_in_executor(project))
try:
return await asyncio.shield(pending)
except asyncio.CancelledError:
pending.add_done_callback(_close_prepared_result)
raise
except (
HistoryReadError,
HistoryEpochChanged,
sqlite3.Error,
OSError,
ValueError,
):
raise
except Exception as error:
self.logger.warning(
"Current history media unavailable (%s)", type(error).__name__
)
raise _current_unavailable("temporary_unavailable") from None
# A missing preview (#249) is noted on the first read, made through
# the serve route's admission and single flight, then read again. A
# video whose preview cannot be made is the placeholder frame.
needed = []
media_options = {}
if kind == "thumbnail":
media_options = {
"ensure_preview": lambda *preview: needed.append(preview),
"placeholder": self._video_placeholder_bytes(),
}
prepared = await execute()
if needed:
if not isinstance(prepared, bytes):
prepared.close()
await self._admit_preview(*needed[0])
media_options["ensure_preview"] = None
prepared = await execute()
if isinstance(prepared, bytes):
return _success_response(prepared)
return await _current_media_response(request, prepared)
+33 -7
View File
@@ -5,6 +5,7 @@ from dataclasses import dataclass
import errno
import hashlib
import hmac
import io
import json
import mimetypes
import os
@@ -309,17 +310,35 @@ class _OpenedCurrentMedia:
self.handle.close()
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))
def _prepare_current_media(
resolved: ResolvedCurrentCandidate, scope: CurrentOutputScope, *, thumbnail: bool
resolved: ResolvedCurrentCandidate,
scope: CurrentOutputScope,
*,
thumbnail: bool,
ensure_preview=None,
placeholder: bytes | None = None,
) -> _OpenedCurrentMedia:
"""Open the current file, or its preview for ``thumbnail``.
A missing preview that may be written is handed to
``ensure_preview(src, dst, is_video)`` (the route makes it and reads
again, #249). Without one an image's original is opened and a video is
the ``placeholder`` JPEG, never the video inside an ``<img>``.
"""
target = resolved.canonical_target
if thumbnail:
relative = target.relative_to(resolved.gallery_root)
extension = (
".jpg"
if target.suffix.lower() in VIDEO_EXTENSIONS
else target.suffix.lower()
)
is_video = target.suffix.lower() in VIDEO_EXTENSIONS
extension = ".jpg" if is_video else target.suffix.lower()
preview = (
resolved.gallery_root
/ "thumbnails"
@@ -328,7 +347,14 @@ def _prepare_current_media(
try:
preview.stat()
except FileNotFoundError:
pass
if ensure_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 _OpenedCurrentMedia(
io.BytesIO(placeholder), len(placeholder), "image/jpeg"
)
else:
target = _canonical(preview)
handle = _open_current_target(target, resolved.namespace, scope)
+259 -137
View File
@@ -110,6 +110,8 @@ def _card_header(path: Path) -> tuple:
# The legacy gallery page's thumbnail generation, as Activity lists it (D80).
THUMBNAIL_GENERATION_TITLE = "Thumbnail generation (gallery page)"
# Its clear: automatic previews are not written while it runs.
THUMBNAIL_CLEAR_TITLE = "Thumbnail clear (gallery page)"
class _CardFacts(NamedTuple):
@@ -261,13 +263,32 @@ def _temp_sibling(dst):
return dst.with_name(f".{dst.stem}.part-{token}{dst.suffix}")
def _atomic_save(img, dst, fmt=None, **save_kwargs):
class PreviewWriteHeld(Exception):
"""A preview write stopped before its rename: ``guard()`` said no (#249)."""
# The result error of a write held before its rename (never remembered).
PREVIEW_HELD = "Preview writes are held while another job runs"
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)
def _atomic_save(img, dst, fmt=None, *, guard=None, **save_kwargs):
"""Save a PIL image to *dst* without a partially written file ever being
visible at that path: write a temp sibling, then rename over it."""
visible at that path: write a temp sibling, then rename over it.
``guard()`` (automatic previews) is asked again just before the rename;
false leaves *dst* untouched and raises PreviewWriteHeld.
"""
tmp = _temp_sibling(dst)
try:
img.save(tmp, fmt, **save_kwargs)
os.replace(tmp, dst)
_replace_unless_held(tmp, dst, guard)
finally:
if tmp.exists():
tmp.unlink()
@@ -305,11 +326,11 @@ def _ffmpeg_qscale(encoding):
return str(_FFMPEG_QSCALE.get(encoding["quality"], 4))
def _write_image_thumbnail(src, dst, thumbnail_size, encoding=None):
def _write_image_thumbnail(src, dst, thumbnail_size, encoding=None, guard=None):
"""Resize *src* into *dst* (blocking PIL work), atomically.
``encoding`` is one of THUMBNAIL_ENCODINGS (medium when None); a PNG
preview keeps only its ``optimize`` flag.
preview keeps only its ``optimize`` flag. ``guard`` see _atomic_save.
"""
encoding = encoding or DEFAULT_THUMBNAIL_ENCODING
with Image.open(src) as img:
@@ -319,7 +340,50 @@ def _write_image_thumbnail(src, dst, thumbnail_size, encoding=None):
save_kwargs = dict(encoding)
if dst.suffix.lower() == ".png":
save_kwargs = {"optimize": encoding["optimize"]}
_atomic_save(img, dst, **save_kwargs)
_atomic_save(img, dst, guard=guard, **save_kwargs)
def _video_placeholder_image(thumbnail_size):
"""The placeholder frame for a video no decoder could read: a play mark."""
from PIL import ImageDraw, ImageFont
img = Image.new("RGB", thumbnail_size, color=(50, 50, 50))
draw = ImageDraw.Draw(img)
center_x, center_y = thumbnail_size[0] // 2, thumbnail_size[1] // 2
triangle_size = min(thumbnail_size) // 4
points = [
(center_x - triangle_size // 2, center_y - triangle_size // 2),
(center_x - triangle_size // 2, center_y + triangle_size // 2),
(center_x + triangle_size // 2, center_y),
]
draw.polygon(points, fill=(255, 255, 255))
try:
font = ImageFont.load_default()
text = "VIDEO"
bbox = draw.textbbox((0, 0), text, font=font)
text_width = bbox[2] - bbox[0]
draw.text(
(center_x - text_width // 2, center_y + triangle_size // 2 + 10),
text,
fill=(255, 255, 255),
font=font,
)
except (OSError, AttributeError):
pass
return img
def _is_decode_error(exc):
"""True when *exc* says the file itself cannot be decoded (#249 M4).
Pillow's decoder errors are OSErrors without an errno; a real I/O error
(disk full, a share gone) carries one and is worth another try.
"""
if isinstance(exc, (UnidentifiedImageError, Image.DecompressionBombError)):
return True
if isinstance(exc, SyntaxError):
return True
return isinstance(exc, OSError) and exc.errno is None
def _new_thumbnail_stats():
@@ -710,23 +774,51 @@ def _iter_media_files(output_path, max_files):
yield root_path / name
def media_thumbnail_url(output_path, rel_path, root_index, *, root_scope=None):
"""The Images listing's thumbnail URL for *rel_path* under *output_path*,
or None when no thumbnail exists (one blocking stat). The Needs review
queue cards use the same URL (#249 F, M10)."""
def media_thumbnail_rel(rel_path):
"""``thumbnails/<rel>_thumb<ext>`` (POSIX) for the media file *rel_path*.
The extension is lower case, and a video's preview is a ``.jpg``: the
name every thumbnail writer (the plan, the job, automatic previews) uses.
"""
rel_path = Path(rel_path)
extension = rel_path.suffix.lower()
thumb_rel = (
return (
f"thumbnails/{rel_path.with_suffix('').as_posix()}_thumb"
f"{'.jpg' if extension in VIDEO_EXTENSIONS else extension}"
)
def media_thumbnail(output_path, rel_path, root_index, *, root_scope=None):
"""``(thumbnail_url, ready)`` for *rel_path* under *output_path* (one
blocking stat). The Images listing, the Needs review queue cards and the
Library use the same URL (#249 F, M10).
An existing preview's URL carries its mtime as ``v=`` and ``ready`` is
True. A missing one is still listed (without ``v=``): the serve route
makes it on first view and falls back when it cannot
(py/api/thumbnail_auto.py), so no card loads the full file because its
preview was not made yet. Media inside a nested ``thumbnails`` folder is
never previewed: ``(None, False)``, and its card shows the original.
"""
rel_path = Path(rel_path)
if "thumbnails" in rel_path.parts[:-1]:
return None, False
thumb_rel = media_thumbnail_rel(rel_path)
thumbnail_url = thumbnail_url_for(thumb_rel, Path(output_path) / thumb_rel)
ready = thumbnail_url is not None
if thumbnail_url is None:
return None
thumbnail_url += f"&root={root_index}"
quoted = urllib.parse.quote(thumb_rel, safe="/")
thumbnail_url = f"/prompt_manager/images/serve/{quoted}?root={root_index}"
else:
thumbnail_url += f"&root={root_index}"
if root_scope is not None:
thumbnail_url += f"&root_scope={root_scope}"
return thumbnail_url
return thumbnail_url, ready
def media_thumbnail_url(output_path, rel_path, root_index, *, root_scope=None):
"""The thumbnail URL of ``media_thumbnail`` (None for nested previews)."""
return media_thumbnail(output_path, rel_path, root_index, root_scope=root_scope)[0]
def _output_image_entry(
@@ -744,7 +836,7 @@ def _output_image_entry(
extension = media_path.suffix.lower()
is_video = extension in VIDEO_EXTENSIONS
thumbnail_url = media_thumbnail_url(
thumbnail_url, thumbnail_ready = media_thumbnail(
output_path, rel_path, root_index, root_scope=root_scope
)
encoded_path = urllib.parse.quote(rel_path.as_posix(), safe="/")
@@ -763,6 +855,8 @@ def _output_image_entry(
"root_index": root_index,
"url": url,
"thumbnail_url": thumbnail_url,
# Whether the preview file exists yet (the legacy gallery's dot, #249).
"thumbnail_ready": thumbnail_ready,
"size": stat.st_size,
"modified_time": stat.st_mtime,
"extension": extension,
@@ -1682,6 +1776,11 @@ class ImageRoutesMixin:
if image_path.is_file():
return self._file_response(image_path)
# A listed preview that was not made yet is made now (#249).
preview = await self._serve_missing_thumbnail(output_dirs, filepath)
if preview is not None:
return preview
# ComfyUI's cache middleware would otherwise let browsers cache this
# miss for an hour, hiding a thumbnail generated a moment later.
return web.json_response(
@@ -1909,6 +2008,8 @@ class ImageRoutesMixin:
"total_images": pending + plan["skipped"],
"pending": pending,
"ready": plan["skipped"],
# Files whose automatic preview failed this session (#249).
"failed": self._auto_thumbnail_failures(),
"collection_id": hashlib.sha256(collection.encode()).hexdigest()[
:16
],
@@ -2003,6 +2104,8 @@ class ImageRoutesMixin:
encoding=None,
rebuild=False,
stop=None,
guard=None,
track=None,
):
"""Create one thumbnail (blocking). Never raises; returns a result dict.
@@ -2010,7 +2113,11 @@ class ImageRoutesMixin:
and touches nothing (a job's file boundary). ``rebuild`` writes a
fresh preview again and never replaces an existing video preview with
the placeholder. Every write is atomic, so a failure keeps the old
preview.
preview. ``guard()`` false just before the rename gives ``action:
"error"`` with ``held: True`` and writes nothing; ``track(process)``
is handed the ffmpeg process (both for automatic previews, #249). An
error result carries ``decode: True`` when the file itself cannot be
decoded (an I/O error does not).
"""
result = {
"file": src.name,
@@ -2027,22 +2134,43 @@ class ImageRoutesMixin:
dst.parent.mkdir(parents=True, exist_ok=True)
if is_video:
made = self._generate_video_thumbnail(
src, dst, thumbnail_size, encoding, keep_existing=rebuild
src,
dst,
thumbnail_size,
encoding,
keep_existing=rebuild,
guard=guard,
track=track,
)
if not made:
return {**result, "action": "error", "error": "Video decode failed"}
return {
**result,
"action": "error",
"error": "Video decode failed",
"decode": True,
}
else:
_write_image_thumbnail(src, dst, thumbnail_size, encoding)
_write_image_thumbnail(src, dst, thumbnail_size, encoding, guard)
return {**result, "action": "generated"}
except PreviewWriteHeld:
return {**result, "action": "error", "error": PREVIEW_HELD, "held": True}
except UnidentifiedImageError:
return {**result, "action": "error", "error": "Not a valid image file"}
return {
**result,
"action": "error",
"error": "Not a valid image file",
"decode": True,
}
except PermissionError as e:
message = _preview_write_error(e)
self.logger.warning(f"Thumbnail failed for {src.name}: {message}")
return {**result, "action": "error", "error": message}
except Exception as e:
self.logger.warning(f"Thumbnail failed for {src.name}", exc_info=True)
return {**result, "action": "error", "error": _safe_error(e)}
failed = {**result, "action": "error", "error": _safe_error(e)}
if _is_decode_error(e):
failed["decode"] = True
return failed
async def _emit_sse(self, response, event, data) -> bool:
"""Write one SSE frame. Returns False once the client has gone away."""
@@ -2061,147 +2189,59 @@ class ImageRoutesMixin:
thumbnail_size,
encoding=None,
keep_existing=False,
*,
guard=None,
track=None,
):
"""Generate thumbnail from video file. Returns True if successful.
``encoding`` is one of THUMBNAIL_ENCODINGS (medium when None); it
encodes the OpenCV frame, ffmpeg's frame (as ``-q:v``) and the
placeholder alike. Both decoders fit the frame inside
``thumbnail_size`` keeping its aspect ratio. With ``keep_existing`` an
existing preview is kept, and the run fails, when no frame could be
decoded, rather than replaced by the placeholder. A PermissionError
while replacing the preview propagates (``_generate_one`` names it).
Tries OpenCV, then ffmpeg, then writes the placeholder frame: a
decoder that cannot open or read the clip (an AV1 file OpenCV was
built without) falls through to the next one (#249 I1). ``encoding``
is one of THUMBNAIL_ENCODINGS (medium when None); it encodes the
OpenCV frame, ffmpeg's frame (as ``-q:v``) and the placeholder alike.
Both decoders fit the frame inside ``thumbnail_size`` keeping its
aspect ratio. With ``keep_existing`` an existing preview is kept, and
the run fails, when no frame could be decoded, rather than replaced
by the placeholder. A PermissionError while replacing the preview
propagates (``_generate_one`` names it), as does PreviewWriteHeld
when ``guard()`` refuses the rename. ``track(process)`` is handed the
ffmpeg process so a shutdown can kill it.
"""
encoding = encoding or DEFAULT_THUMBNAIL_ENCODING
try:
# Try using OpenCV first (most reliable)
try:
import cv2
cap = cv2.VideoCapture(str(video_path))
if not cap.isOpened():
return False
frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
target_frame = max(1, int(frame_count * 0.1))
cap.set(cv2.CAP_PROP_POS_FRAMES, target_frame)
ret, frame = cap.read()
cap.release()
if not ret or frame is None:
return False
frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
img = Image.fromarray(frame_rgb)
img.thumbnail(thumbnail_size, Image.Resampling.LANCZOS)
_atomic_save(img, thumbnail_path, "JPEG", **encoding)
frame = self._opencv_frame(video_path, thumbnail_size)
if frame is not None:
_atomic_save(frame, thumbnail_path, "JPEG", guard=guard, **encoding)
self.logger.debug(
f"Generated video thumbnail using OpenCV: {thumbnail_path}"
)
return True
except ImportError:
pass
# Fallback to ffmpeg
try:
import subprocess
tmp_path = _temp_sibling(thumbnail_path)
cmd = [
"ffmpeg",
"-i",
str(video_path),
"-ss",
"00:00:01",
"-vframes",
"1",
"-vf",
f"scale={thumbnail_size[0]}:{thumbnail_size[1]}"
":force_original_aspect_ratio=decrease",
"-q:v",
_ffmpeg_qscale(encoding),
"-y",
str(tmp_path),
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if result.returncode == 0 and tmp_path.exists():
os.replace(tmp_path, thumbnail_path)
self.logger.debug(
f"Generated video thumbnail using ffmpeg: {thumbnail_path}"
)
return True
else:
self.logger.warning(
f"ffmpeg failed for {video_path}: {result.stderr}"
)
except (ImportError, subprocess.TimeoutExpired, FileNotFoundError):
pass
finally:
try:
tmp_path.unlink()
except (NameError, OSError):
pass
if self._ffmpeg_frame(
video_path, thumbnail_path, thumbnail_size, encoding, guard, track
):
return True
if keep_existing and thumbnail_path.exists():
self.logger.warning(
f"No frame decoded for {video_path.name}; keeping its preview"
)
return False
# Last resort: create a placeholder thumbnail
# Last resort: the placeholder frame.
try:
from PIL import ImageDraw, ImageFont
img = Image.new("RGB", thumbnail_size, color=(50, 50, 50))
draw = ImageDraw.Draw(img)
center_x, center_y = thumbnail_size[0] // 2, thumbnail_size[1] // 2
triangle_size = min(thumbnail_size) // 4
points = [
(center_x - triangle_size // 2, center_y - triangle_size // 2),
(center_x - triangle_size // 2, center_y + triangle_size // 2),
(center_x + triangle_size // 2, center_y),
]
draw.polygon(points, fill=(255, 255, 255))
try:
font = ImageFont.load_default()
text = "VIDEO"
bbox = draw.textbbox((0, 0), text, font=font)
text_width = bbox[2] - bbox[0]
draw.text(
(
center_x - text_width // 2,
center_y + triangle_size // 2 + 10,
),
text,
fill=(255, 255, 255),
font=font,
)
except (OSError, AttributeError):
pass
_atomic_save(img, thumbnail_path, "JPEG", **encoding)
img = _video_placeholder_image(thumbnail_size)
_atomic_save(img, thumbnail_path, "JPEG", guard=guard, **encoding)
self.logger.debug(
f"Generated placeholder video thumbnail: {thumbnail_path}"
)
return True
except PermissionError:
except (PermissionError, PreviewWriteHeld):
raise
except Exception as e:
self.logger.warning(
f"Failed to create placeholder thumbnail for {video_path}: {e}"
)
return False
except PermissionError:
except (PermissionError, PreviewWriteHeld):
raise
except Exception as e:
self.logger.error(
@@ -2209,6 +2249,88 @@ class ImageRoutesMixin:
)
return False
def _opencv_frame(self, video_path, thumbnail_size):
"""A frame about 10% in, fitted to *thumbnail_size*, or None (no
OpenCV, or it could not open or read the clip)."""
try:
import cv2
except ImportError:
return None
try:
cap = cv2.VideoCapture(str(video_path))
try:
if not cap.isOpened():
return None
frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
cap.set(cv2.CAP_PROP_POS_FRAMES, max(1, int(frame_count * 0.1)))
ret, frame = cap.read()
finally:
cap.release()
if not ret or frame is None:
return None
img = Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB))
img.thumbnail(thumbnail_size, Image.Resampling.LANCZOS)
return img
except Exception as e:
self.logger.debug(f"OpenCV could not read {video_path}: {e}")
return None
def _ffmpeg_frame(
self, video_path, thumbnail_path, thumbnail_size, encoding, guard, track
):
"""Write ffmpeg's frame at 1 s into *thumbnail_path*; True on success."""
import subprocess # nosec B404 - fixed argv, no shell
tmp_path = _temp_sibling(thumbnail_path)
cmd = [
"ffmpeg",
"-i",
str(video_path),
"-ss",
"00:00:01",
"-vframes",
"1",
"-vf",
f"scale={thumbnail_size[0]}:{thumbnail_size[1]}"
":force_original_aspect_ratio=decrease",
"-q:v",
_ffmpeg_qscale(encoding),
"-y",
str(tmp_path),
]
try:
process = subprocess.Popen( # nosec B603 B607 - fixed argv
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
if track is not None:
track(process)
try:
_out, stderr = process.communicate(timeout=30)
except subprocess.TimeoutExpired:
process.kill()
process.communicate()
return False
if process.returncode == 0 and tmp_path.exists():
_replace_unless_held(tmp_path, thumbnail_path, guard)
self.logger.debug(
f"Generated video thumbnail using ffmpeg: {thumbnail_path}"
)
return True
self.logger.warning(f"ffmpeg failed for {video_path}: {stderr}")
return False
except (FileNotFoundError, OSError) as e:
if isinstance(e, PermissionError) and tmp_path.exists():
raise
return False
finally:
try:
tmp_path.unlink()
except OSError:
pass
async def clear_thumbnails(self, request: web.Request) -> web.Response:
"""Clear generated thumbnails across gallery roots, preserving originals.
@@ -2218,7 +2340,7 @@ class ImageRoutesMixin:
"""
return await self._run_held(
THUMBNAILS_JOB_KIND,
"Thumbnail clear (gallery page)",
THUMBNAIL_CLEAR_TITLE,
"guarded",
self._clear_thumbnails,
)
+6
View File
@@ -515,6 +515,12 @@ class JobRegistry:
def get(self, job_id):
return self._jobs.get(job_id)
def running(self):
"""The running jobs, oldest first (a snapshot, safe off the event loop)."""
jobs = [job for job in tuple(self._jobs.values()) if job.running]
jobs.sort(key=lambda job: job._started_seq)
return jobs
def active(self, kind):
"""The running job of ``kind``, or None (safe off the event loop)."""
for job in tuple(self._jobs.values()):
+843
View File
@@ -0,0 +1,843 @@
"""Automatic thumbnails (#249): previews are a cache the server keeps itself.
The approved mock has no repeated global thumbnail prompt
(``docs/concepts/workspace/README.md``), so the workspace never asks. Every
listing gives an image or video a thumbnail URL (``media_thumbnail``), and the
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
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
checked first: no traversal, drive, UNC or empty segment; then a regular
file inside the root, never through a link), made and served. When it
cannot be made an image's original is served and a video's placeholder
frame, both ``Cache-Control: no-store``: a video is never sent to an
``<img>``. A file that cannot be decoded is remembered for this session
(keyed by its path, size and mtime) and counted for the System Overview
(``failed`` in ``/images/thumbnails/status``); I/O errors and a preview
another program holds open (Windows) are not remembered. The recorded
history's thumbnail route goes through the same admission and flight key.
- **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
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.
- **After a scan.** A scan that added files checks the missing previews; from
``SCAN_THUMBNAIL_JOB_THRESHOLD`` up it starts the thumbnail Activity job
(missing only, visible and cancellable) instead of writing them silently,
and fewer go through the worker.
Every preview is written by ``_generate_one`` with the thumbnail job's default
size and quality (``DEFAULT_THUMBNAIL_EDGE``, medium), into a temp sibling
renamed over the old one, so a reader never sees a partial file.
Holds: automatic previews only write files under ``thumbnails/`` and never
touch the database, like the thumbnail job, so they run beside a scan, image
tagging and the Needs review recording (S5 M7). They are not written while a
job runs that replaces the database or removes media and previews: a restore,
a duplicate removal, a sources change, the gallery's thumbnail clear or an
``exclusive`` job (``_auto_thumbnail_blocker``). The holds are checked when a
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
(``final=True``) nothing starts a worker again. Importing this module again
(a package reload) stops the workers the previous import left.
"""
import asyncio
import collections
import functools
import io
import os
import threading
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from aiohttp import web
from .images import (
GALLERY_IMAGE_EXTENSIONS,
MEDIA_EXTENSIONS,
THUMBNAIL_CLEAR_TITLE,
THUMBNAIL_ENCODINGS,
VIDEO_EXTENSIONS,
_has_traversal,
_is_within,
_thumbnail_is_fresh,
_video_placeholder_image,
_worker_threads,
media_thumbnail_rel,
)
from .jobs import (
DUPLICATE_CLEANUP_JOB_KIND,
RESTORE_JOB_KIND,
THUMBNAILS_JOB_KIND,
JobBusy,
)
from .settings_routes import SOURCE_CHANGE_JOB_KIND
from .thumbnail_jobs import DEFAULT_THUMBNAIL_EDGE, ThumbnailOptions
try:
from ...utils.parallel import map_parallel
except ImportError: # tests import the package top-level
from utils.parallel import map_parallel
AUTO_THUMBNAIL_EDGE = DEFAULT_THUMBNAIL_EDGE
AUTO_THUMBNAIL_SIZE = (AUTO_THUMBNAIL_EDGE, AUTO_THUMBNAIL_EDGE)
AUTO_THUMBNAIL_ENCODING = THUMBNAIL_ENCODINGS["medium"]
# Previews views may write at once (the size of the view pool).
ON_DEMAND_LIMIT = 3
# 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.
FLIGHT_WAIT_SECONDS = 20
# How long close() waits for the worker's batch in hand (ffmpeg's own limit).
CLOSE_WAIT_SECONDS = 30
# Files the capture worker holds; more are made on their first view.
MAX_QUEUED = 10_000
# Undecodable files remembered per session (oldest forgotten first).
MAX_FAILURES_REMEMBERED = 4096
# Missing previews after a scan from which the visible thumbnail job starts:
# a fresh install or a newly added source. Fewer go through the worker.
SCAN_THUMBNAIL_JOB_THRESHOLD = 200
# Jobs beside which no automatic preview is written (see the module docstring).
AUTO_REFUSED_KINDS = frozenset(
{RESTORE_JOB_KIND, DUPLICATE_CLEANUP_JOB_KIND, SOURCE_CHANGE_JOB_KIND}
)
WORKER_NAME = "PromptManagerThumbnailWorker"
POOL_PREFIX = "pm-preview"
# Identifies this import's workers: a reload stops the ones it did not start.
_MODULE_TOKEN = object()
READY, FAILED, BUSY, DEFERRED = "ready", "failed", "busy", "deferred"
# What the gate answers a request past MAX_WAITING.
OVER = object()
def _fingerprint(src):
"""``(path, size, mtime_ns)`` of *src*, or None when it cannot be read."""
try:
stat = os.stat(src)
except OSError:
return None
return (os.path.normcase(str(src)), stat.st_size, stat.st_mtime_ns)
def flight_key(root, rel_parts):
"""The single-flight key of ``root/rel_parts``: its folder resolved, case
folded. The serve route, the history route and the worker share it."""
path = Path(root).joinpath(*rel_parts)
return os.path.normcase(os.path.join(os.path.realpath(path.parent), path.name))
def _dst_key(dst):
return flight_key(Path(dst).parent, (Path(dst).name,))
@functools.lru_cache(maxsize=1)
def video_placeholder_jpeg():
"""The placeholder frame as JPEG bytes (a video whose preview is missing)."""
buffer = io.BytesIO()
_video_placeholder_image(AUTO_THUMBNAIL_SIZE).save(
buffer, "JPEG", **AUTO_THUMBNAIL_ENCODING
)
return buffer.getvalue()
def _is_preview_request(filepath):
"""Text check only: ``thumbnails/<...>/<name>_thumb<lower-case ext>``.
Refuses, as text and before any path is joined, what a joined path could
take as an anchor: a backslash, a drive or stream colon, and empty, ``.``
or ``..`` segments (so a leading ``/`` and ``//server/share`` too).
"""
if "\\" in filepath or ":" in filepath:
return False
parts = filepath.split("/")
if len(parts) < 2 or parts[0] != "thumbnails":
return False
if any(part in ("", ".", "..") for part in parts) or "thumbnails" in parts[1:-1]:
return False
stem, extension = os.path.splitext(parts[-1])
return (
stem.endswith("_thumb")
and len(stem) > len("_thumb")
and extension == extension.lower()
and extension in GALLERY_IMAGE_EXTENSIONS
)
def _regular_file(path):
"""True for a regular file that is not a link (one lstat)."""
try:
return not path.is_symlink() and path.is_file()
except OSError:
return False
def _candidates(parts):
"""``(folder parts, base, extensions)`` of a checked preview request."""
stem, extension = os.path.splitext(parts[-1])
wanted = [extension] + (sorted(VIDEO_EXTENSIONS) if extension == ".jpg" else [])
return parts[1:-1], stem[: -len("_thumb")], wanted
def _quick_source(root, parts):
"""``(src, is_video)`` by exact name only (no folder listing), or None."""
root = Path(root)
folder_parts, base, wanted = _candidates(parts)
folder = root.joinpath(*folder_parts)
for extension in wanted:
for name in (base + extension, base + extension.upper()):
path = folder / name
if _regular_file(path) and _is_within(path, root):
return path, extension in VIDEO_EXTENSIONS
return None
def _thumbnail_source(root, parts):
"""``(src, dst, is_video)`` for the preview *parts* under *root*, or None.
*parts* passed ``_is_preview_request``. The source has the preview's name
without ``_thumb`` and an extension that previews as the requested one
(any case: exact names first, then one listing of the folder); a ``.jpg``
may also be a video's. It must be a regular file inside *root*. ``dst``
is None when the preview's folder resolves outside ``root/thumbnails`` (a
link): nothing is written there.
"""
root = Path(root)
folder_parts, base, wanted = _candidates(parts)
quick = _quick_source(root, parts)
if quick is not None:
found = quick[0]
else:
found = _scan_folder(root.joinpath(*folder_parts), base, set(wanted))
if found is None or not _is_within(found, root):
return None
rel = Path(*folder_parts, found.name)
dst = root / media_thumbnail_rel(rel)
if dst != root.joinpath(*parts):
return None
if not _is_within(root / "thumbnails", root) or not _is_within(
dst, root / "thumbnails"
):
dst = None
return found, dst, found.suffix.lower() in VIDEO_EXTENSIONS
def _scan_folder(folder, base, extensions):
"""A mixed-case extension (``.Png``): one listing of *folder*, or None."""
try:
with os.scandir(folder) as entries:
for entry in entries:
stem, extension = os.path.splitext(entry.name)
if stem == base and extension.lower() in extensions:
path = Path(entry.path)
if _regular_file(path):
return path
except OSError:
return None
return None
def _stop_stale_workers():
"""Stop capture workers a previous import of this module started."""
for thread in threading.enumerate():
token = getattr(thread, "pm_preview_token", None)
close = getattr(thread, "pm_preview_close", None)
if token is not None and token is not _MODULE_TOKEN and close is not None:
close()
class AutoThumbnailer:
"""Single-flight preview writes and the capture worker (thread-safe).
``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
thread.
"""
def __init__(
self,
generate,
may_write,
logger,
*,
workers=_worker_threads,
max_queued=MAX_QUEUED,
start_worker=True,
):
self._generate = generate
self._may_write = may_write
self._logger = logger
self._workers = workers
self._max_queued = max_queued
self._start_worker = start_worker
self._changed = threading.Condition()
self._inflight = {}
self._failures = collections.OrderedDict()
self._queue = collections.deque()
self._queued = set()
self._processes = []
self._worker = None
# True from a worker's start until it leaves (under the lock), so an
# idle worker can exit and the next enqueue starts a fresh one.
self._worker_running = False
self._closed = False
self._busy = False
self._batch = 0
@property
def failure_count(self):
with self._changed:
return len(self._failures)
@property
def closed(self):
return self._closed
def _allowed(self):
return not self._closed and self._may_write()
def _track(self, process):
"""Keep an ffmpeg process so close() can kill it."""
with self._changed:
self._processes = [p for p in self._processes if p.poll() is None]
self._processes.append(process)
closed = self._closed
if closed:
process.kill()
# ── one preview ───────────────────────────────────────────────────
def ensure(self, src, dst, is_video):
"""Make *dst* for *src* unless it is fresh; returns the outcome.
``ready`` (the preview is there), ``failed`` (the file cannot be
decoded, remembered), ``busy`` (an I/O error, the file is gone, or
another flight did not finish) or ``deferred`` (a hold refuses
automatic writes, checked when the write starts and before its
rename). Blocking: call it off the event loop.
"""
if _thumbnail_is_fresh(src, dst):
return READY
fingerprint = _fingerprint(src)
if fingerprint is None:
return BUSY
key = _dst_key(dst)
with self._changed:
if fingerprint in self._failures:
return FAILED
flight = self._inflight.get(key)
if flight is None:
self._inflight[key] = done = threading.Event()
if flight is not None:
flight.wait(FLIGHT_WAIT_SECONDS)
if _thumbnail_is_fresh(src, dst):
return READY
with self._changed:
return FAILED if fingerprint in self._failures else BUSY
try:
return self._lead(src, dst, is_video, fingerprint)
finally:
with self._changed:
self._inflight.pop(key, None)
self._changed.notify_all()
done.set()
def _lead(self, src, dst, is_video, fingerprint):
if not self._allowed():
return DEFERRED
if _thumbnail_is_fresh(src, dst):
return READY
result = self._generate(
src,
dst,
is_video,
stop=lambda: self._closed,
guard=self._allowed,
track=self._track,
)
if result.get("action") in ("generated", "skipped"):
return READY
if result.get("held"):
return DEFERRED
if not result.get("decode"):
return BUSY # an I/O error says nothing about the file
error = result.get("error") or "failed"
with self._changed:
self._failures[fingerprint] = error
while len(self._failures) > MAX_FAILURES_REMEMBERED:
self._failures.popitem(last=False)
self._logger.info(
f"No preview for {os.path.basename(str(src))} ({error}); "
"the original is shown"
)
return FAILED
# ── the capture worker ────────────────────────────────────────────
def enqueue(self, src, dst, is_video):
"""Queue *dst*'s preview for the worker; False when already queued,
full or closed. Never blocks on a write."""
key = _dst_key(dst)
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._busy = True
if self._start_worker and not self._worker_running:
self._start_worker_locked()
self._changed.notify_all()
return True
def _start_worker_locked(self):
"""Start the worker (lock held). A start that fails leaves the file
queued and the next enqueue tries again."""
worker = threading.Thread(target=self._run, name=WORKER_NAME, daemon=True)
worker.pm_preview_token = _MODULE_TOKEN
worker.pm_preview_close = self.close
self._worker_running = True
try:
worker.start()
except RuntimeError:
self._worker_running = False
self._busy = False
self._logger.warning(
"The automatic preview worker could not start; the next output "
"tries again",
exc_info=True,
)
return
self._worker = worker
def _run(self):
try:
self._drain()
except Exception:
self._logger.warning("The automatic preview worker stopped", exc_info=True)
finally:
# Whatever ended the loop, the next enqueue starts a fresh worker.
with self._changed:
self._worker_running = False
self._busy = False
self._batch = 0
self._changed.notify_all()
def _drain(self):
while True:
with self._changed:
if self._closed or not self._queue:
# Idle or closed: leave, so no thread outlives its work.
self._busy = False
self._worker_running = False
self._changed.notify_all()
return
take = min(len(self._queue), max(1, int(self._workers() or 1)))
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))
finally:
with self._changed:
self._batch = 0
for key, *_rest in batch:
self._queued.discard(key)
def _write_queued(self, item):
_key, src, dst, is_video = item
try:
self.ensure(src, dst, is_video)
except Exception: # one file never stops the worker
self._logger.warning(
f"Automatic preview failed for {src.name}", exc_info=True
)
def close(self):
"""Stop the worker at its next file boundary (queued files are
dropped), kill a running ffmpeg and wait up to CLOSE_WAIT_SECONDS;
a worker still running after that is logged."""
with self._changed:
self._closed = True
self._queue.clear()
self._queued.clear()
processes, self._processes = self._processes, []
self._changed.notify_all()
worker = self._worker
for process in processes:
try:
process.kill()
except OSError:
pass
if worker is None or worker is threading.current_thread():
return
worker.join(CLOSE_WAIT_SECONDS)
if worker.is_alive():
self._logger.warning(
"The automatic preview worker is still running after "
f"{CLOSE_WAIT_SECONDS} s; it stops at its next file"
)
# ── waits (tests and shutdown; never timers) ──────────────────────
def _wait_for(self, predicate, timeout):
with self._changed:
return self._changed.wait_for(predicate, timeout)
def wait_idle(self, timeout=None):
"""True once the worker has nothing queued or in hand."""
return self._wait_for(lambda: not self._busy, timeout)
def wait_batch(self, size, timeout=None):
"""True once the worker holds a batch of *size* files."""
return self._wait_for(lambda: self._batch == size, timeout)
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``
waiting requests ``run`` answers ``OVER`` at once.
"""
def __init__(self, limit, logger):
self.loop = asyncio.get_running_loop()
self._slots = asyncio.Semaphore(limit)
self._flights = {}
self._tasks = set()
self._logger = logger
self._tick = asyncio.Event()
self.waiting = 0
def _changed(self):
tick, self._tick = self._tick, asyncio.Event()
tick.set()
async def settled(self, predicate):
"""Return once ``predicate()`` holds (re-checked at each change)."""
while not predicate():
await self._tick.wait()
async def run(self, key, work, pool):
"""``work()``'s answer for *key* (shared by every request of the
flight), None when it raised, or OVER past the cap."""
if self.waiting >= MAX_WAITING:
return OVER
flight = self._flights.get(key)
if flight is None:
flight = self.loop.create_future()
self._flights[key] = flight
task = self.loop.create_task(self._lead(key, flight, work, pool))
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)
self.waiting += 1
self._changed()
try:
return await asyncio.shield(flight)
finally:
self.waiting -= 1
self._changed()
async def _lead(self, key, flight, work, pool):
result = None
try:
async with self._slots:
result = await self.loop.run_in_executor(pool, work)
except Exception:
self._logger.warning("Automatic preview failed", exc_info=True)
finally:
self._flights.pop(key, None)
if not flight.done():
flight.set_result(result)
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
_auto_thumbnailer_lock = threading.Lock()
def _auto_thumbnails(self):
"""This API's AutoThumbnailer, created on first use (no worker after
the app's cleanup)."""
with AutoThumbnailMixin._auto_thumbnailer_lock:
if self._auto_thumbnailer is None:
self._auto_thumbnailer = AutoThumbnailer(
self._write_auto_thumbnail,
lambda: self._auto_thumbnail_blocker() is None,
self.logger,
start_worker=not self._auto_thumbnails_shut,
)
return self._auto_thumbnailer
def close_auto_thumbnails(self, final=False):
"""Stop the capture worker and the view pool, and forget them.
Bounded (see AutoThumbnailer.close). ``final`` (the app's cleanup)
also keeps a late monitor event from starting a worker again; a
test reset passes False and the next use starts fresh ones.
"""
with AutoThumbnailMixin._auto_thumbnailer_lock:
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
self._preview_gate_obj = None
if thumbnailer is not None:
thumbnailer.close()
if pool is not None:
pool.shutdown(wait=False, cancel_futures=True)
def _auto_thumbnail_failures(self):
thumbnailer = self._auto_thumbnailer
return 0 if thumbnailer is None else thumbnailer.failure_count
def _write_auto_thumbnail(self, src, dst, is_video, *, stop, guard, track):
return self._generate_one(
src,
dst,
AUTO_THUMBNAIL_SIZE,
is_video,
encoding=AUTO_THUMBNAIL_ENCODING,
stop=stop,
guard=guard,
track=track,
)
def _auto_thumbnail_blocker(self):
"""The running job automatic previews wait for, or None (any thread)."""
for job in self._jobs.running():
if job.admission == "exclusive" or job.kind in AUTO_REFUSED_KINDS:
return job
if job.kind == THUMBNAILS_JOB_KIND and job.title == THUMBNAIL_CLEAR_TITLE:
return job
return None
# ── the view admission ────────────────────────────────────────────
def _preview_gate(self):
"""The gate of the running loop (a new loop gets a new one)."""
gate = self._preview_gate_obj
if gate is None or gate.loop is not asyncio.get_running_loop():
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 _preview_pool_threads(self):
pool = self._preview_pool_obj
return [] if pool is None else list(getattr(pool, "_threads", ()))
def _video_placeholder_bytes(self):
return video_placeholder_jpeg()
async def _admit_preview(self, src, dst, is_video):
"""Make one preview through the view admission (the history route).
Shares the serve route's flight key; every flight answers the
``ensure`` outcome (or None, or OVER), whichever route started it.
"""
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(),
)
def _ensure_view(self, src, dst, is_video):
"""``ensure``, reporting a preview removed under us as busy."""
outcome = self._auto_thumbnails().ensure(src, dst, is_video)
if outcome == READY and not Path(dst).is_file():
return BUSY # removed under us: the fallback this once
return outcome
# ── on first view ─────────────────────────────────────────────────
def _preview_on_demand(self, root, parts):
"""The ``ensure`` outcome for the preview *parts* under *root*
(``deferred`` when its folder is linked out of the root), or None
when no media file previews as *parts*."""
located = _thumbnail_source(root, parts)
if located is None:
return None
src, dst, is_video = located
if dst is None:
return DEFERRED
return self._ensure_view(src, dst, is_video)
def _preview_response(self, outcome, src, dst, is_video):
if outcome == READY and dst is not None:
return self._file_response(dst)
if is_video:
return web.Response(
body=video_placeholder_jpeg(),
content_type="image/jpeg",
headers={"Cache-Control": "no-store"},
)
response = web.FileResponse(src)
response.headers["Cache-Control"] = "no-store"
return response
async def _serve_missing_thumbnail(self, output_dirs, filepath):
"""The made preview, or its fallback; None when *filepath* names no
preview of a media file in *output_dirs*.
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).
"""
if _has_traversal(filepath) or not _is_preview_request(filepath):
return None
parts = tuple(filepath.split("/"))
gate = self._preview_gate()
pool = self._preview_pool()
for root in output_dirs:
root = Path(root)
outcome = await gate.run(
flight_key(root, parts),
functools.partial(self._preview_on_demand, root, parts),
pool,
)
if outcome is None:
continue
if outcome is OVER:
# Past the cap: exact names only, no listing, no write.
quick = _quick_source(root, parts)
if quick is None:
continue
return self._preview_response(BUSY, quick[0], None, quick[1])
dst = root.joinpath(*parts)
if outcome == READY:
return self._file_response(dst)
located = await gate.loop.run_in_executor(
pool, _thumbnail_source, root, parts
)
if located is None:
continue
src, _dst, is_video = located
return self._preview_response(outcome, src, None, is_video)
return None
# ── on capture ────────────────────────────────────────────────────
def queue_capture_thumbnail(self, image_path):
"""Queue the preview of a new output (the monitor's thread).
Returns ``queued``, ``fresh`` (its preview is current) or ``skipped``
(not a media file under a current root, a link, a preview itself,
already queued, or after the app's cleanup). Never writes on the
caller's thread.
"""
if self._auto_thumbnails_shut:
return "skipped"
located = self._capture_target(image_path)
if located is None:
return "skipped"
src, dst, is_video = located
if _thumbnail_is_fresh(src, dst):
return "fresh"
if self._auto_thumbnails().enqueue(src, dst, is_video):
return "queued"
return "skipped"
def _capture_target(self, image_path):
path = Path(os.path.abspath(str(image_path)))
if path.suffix.lower() not in MEDIA_EXTENSIONS or not _regular_file(path):
return None
folded = os.path.normcase(str(path))
for root in self._get_all_output_dirs():
root = Path(os.path.abspath(str(root)))
base = os.path.normcase(str(root)).rstrip("\\/") + os.sep
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]:
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 None
# ── after a scan ──────────────────────────────────────────────────
async def _thumbnails_after_scan(self, job, output_dirs=None):
"""Hand a finished scan's missing previews on; returns what it did.
None (the scan added nothing), ``job`` (the thumbnail job started for
``SCAN_THUMBNAIL_JOB_THRESHOLD`` or more missing previews), ``busy``
(a job refused it; the previews are made on view), ``queued`` (fewer,
through the worker) or ``none`` (nothing missing). It counts the
sources the scan walked (``output_dirs``, narrowed to the job's
``roots``; every source when None). The server log says which.
"""
if job.counters.get("added", 0) <= 0:
return None
if output_dirs is None:
output_dirs = await self._run_in_executor(self._get_all_output_dirs)
roots = job.scope.get("roots")
if roots:
output_dirs = [output_dirs[i] for i in roots if 0 <= i < len(output_dirs)]
plan = await self._run_in_executor(self._plan_gallery_thumbnails, output_dirs)
missing = len(plan["targets"]) + plan["remaining"]
if missing >= SCAN_THUMBNAIL_JOB_THRESHOLD:
options = ThumbnailOptions("missing", AUTO_THUMBNAIL_EDGE, "medium")
try:
self._start_thumbnail_job(options)
except JobBusy as busy:
self.logger.info(
f"{missing:,} previews missing after the scan; the thumbnail "
f"job did not start while {busy.running.title} runs"
)
return "busy"
self.logger.info(
f"{missing:,} previews missing after the scan; thumbnail job started"
)
return "job"
if not missing:
return "none"
thumbnailer = self._auto_thumbnails()
for src, dst, is_video in plan["targets"]:
thumbnailer.enqueue(src, dst, is_video)
self.logger.info(f"{missing:,} previews missing after the scan; queued")
return "queued"
async def _hand_off_scan_thumbnails(self, job, output_dirs=None):
"""``_thumbnails_after_scan`` for the scan runner: never raises."""
try:
return await self._thumbnails_after_scan(job, output_dirs)
except asyncio.CancelledError:
raise
except Exception:
self.logger.warning("Previews after the scan failed", exc_info=True)
return None
# A package reload re-runs this module: stop the workers the old one left.
_stop_stale_workers()