feat(thumbnails): every preview writer and lookup follows the thumbnail settings
Automatic previews (capture and first view), the thumbnail job, the legacy gallery routes and the history route write <stem>_thumb.webp (or .jxl/.jpg) with the configured quality and size limit, for images and videos alike: video frames, ffmpeg's included (a PNG on stdout, never upscaled), and the placeholder are encoded like images. Listings, duplicates, enrichment, the autotag scan and the history route show any preview the file owns, the current format first, so older _thumb.png previews keep working until converted. A request for an older name answers the current preview. Previews are served with an explicit image/webp or image/jxl type and the same cache headers. The plan and status count missing, outdated (another format) and ready previews; the missing job never writes beside an outdated one. Removing a file removes its previews in every format; the clear removes .webp and .jxl too. Encoder processes are killed at shutdown. A preview is only a placeholder: inspection refuses paths under thumbnails/ and always reads the original, and the inspector cover accepts the listing's preview in any format the file owns.
This commit is contained in:
+11
-7
@@ -630,16 +630,20 @@ class PromptManagerAPI(
|
||||
f"{url_quote(rel_path.as_posix(), safe='/')}"
|
||||
)
|
||||
|
||||
# Check for thumbnail
|
||||
rel_no_ext = rel_path.with_suffix("")
|
||||
thumb_rel = (
|
||||
f"thumbnails/{rel_no_ext.as_posix()}_thumb{rel_path.suffix}"
|
||||
)
|
||||
# Any preview the file owns, the current format first (#317)
|
||||
from . import thumbnail_settings
|
||||
from .prompts import thumbnail_url_for
|
||||
|
||||
thumbnail_url = thumbnail_url_for(
|
||||
thumb_rel, output_path / thumb_rel
|
||||
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)
|
||||
if thumbnail_url:
|
||||
image["thumbnail_url"] = thumbnail_url
|
||||
break # Found matching root, stop searching
|
||||
|
||||
+43
-28
@@ -18,6 +18,7 @@ from pathlib import Path
|
||||
from aiohttp import web
|
||||
|
||||
from .prompts import thumbnail_url_for
|
||||
from . import thumbnail_settings
|
||||
from .jobs import (
|
||||
DEFAULT_SCAN_SCOPE,
|
||||
SCAN_GROUP_NAMES,
|
||||
@@ -146,39 +147,48 @@ def _physical_file_key(path, stat):
|
||||
return ("path", os.path.normcase(os.path.realpath(str(path))))
|
||||
|
||||
|
||||
def _thumbnail_rel_path(rel_path, thumbnail_ext):
|
||||
"""Relative path of the thumbnail the gallery would generate for ``rel_path``."""
|
||||
return f"thumbnails/{rel_path.with_suffix('').as_posix()}_thumb{thumbnail_ext}"
|
||||
|
||||
|
||||
def _media_thumbnail_rel_path(rel_path):
|
||||
"""The thumbnail of a media file: a video's preview is a ``.jpg``."""
|
||||
extension = rel_path.suffix.lower()
|
||||
return _thumbnail_rel_path(
|
||||
rel_path, ".jpg" if extension in VIDEO_EXTENSIONS else rel_path.suffix
|
||||
)
|
||||
def _other_sources(output_path, rel_path):
|
||||
"""Whether another media file shares *rel_path*'s stem in its folder."""
|
||||
folder = Path(output_path) / Path(rel_path).parent
|
||||
stem = Path(rel_path).stem
|
||||
own = Path(rel_path).suffix.lower()
|
||||
for extension in IMAGE_EXTENSIONS + VIDEO_EXTENSIONS:
|
||||
if extension == own:
|
||||
continue
|
||||
for name in (stem + extension, stem + extension.upper()):
|
||||
if (folder / name).is_file():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _remove_thumbnail(output_path, rel_path, logger=None):
|
||||
"""Remove the thumbnail of ``rel_path`` under ``output_path``, if any.
|
||||
"""Remove the previews of ``rel_path`` under ``output_path``, if any.
|
||||
|
||||
Shared by the legacy duplicate delete and the reviewed removal job. A
|
||||
thumbnail that is a link, or one that cannot be removed, is left alone
|
||||
(a stale preview never fails a removal). Returns whether one was removed.
|
||||
Shared by the legacy duplicate delete and the reviewed removal job.
|
||||
Every preview the file owns goes, in any format (#317); a preview name
|
||||
another same-stem file still shares (``x_thumb.webp`` of ``x.png`` and
|
||||
``x.mp4``, ruling R2) stays for it. A preview that is a link, or one that
|
||||
cannot be removed, is left alone (a stale preview never fails a removal).
|
||||
Returns whether one was removed.
|
||||
"""
|
||||
output_path = Path(output_path)
|
||||
thumbnail_path = output_path / _media_thumbnail_rel_path(Path(rel_path))
|
||||
try:
|
||||
if thumbnail_path.is_symlink() or not thumbnail_path.is_file():
|
||||
return False
|
||||
os.remove(thumbnail_path)
|
||||
except OSError as e:
|
||||
rel_path = Path(rel_path)
|
||||
shared = _other_sources(output_path, rel_path)
|
||||
removed = False
|
||||
for thumbnail_path in thumbnail_settings.owned_thumbnails(output_path, rel_path):
|
||||
extension = thumbnail_path.suffix.lower()
|
||||
if shared and extension in thumbnail_settings.WRITTEN_EXTENSIONS:
|
||||
continue
|
||||
try:
|
||||
os.remove(thumbnail_path)
|
||||
except OSError as e:
|
||||
if logger is not None:
|
||||
logger.warning(f"Could not delete thumbnail for {rel_path}: {e}")
|
||||
continue
|
||||
removed = True
|
||||
if logger is not None:
|
||||
logger.warning(f"Could not delete thumbnail for {rel_path}: {e}")
|
||||
return False
|
||||
if logger is not None:
|
||||
logger.debug(f"Deleted associated thumbnail: {thumbnail_path}")
|
||||
return True
|
||||
logger.debug(f"Deleted associated thumbnail: {thumbnail_path}")
|
||||
return removed
|
||||
|
||||
|
||||
async def _read_json(request):
|
||||
@@ -845,8 +855,13 @@ class AdminRoutesMixin:
|
||||
extension = media_path.suffix.lower()
|
||||
is_video = extension in VIDEO_EXTENSIONS
|
||||
|
||||
thumb_rel = _thumbnail_rel_path(rel_path, ".jpg" if is_video else extension)
|
||||
thumbnail_url = thumbnail_url_for(thumb_rel, output_path / thumb_rel)
|
||||
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)
|
||||
|
||||
return {
|
||||
"id": str(hash(str(media_path))),
|
||||
|
||||
@@ -10,6 +10,7 @@ from urllib.parse import quote
|
||||
|
||||
from .jobs import AUTOTAG_BATCH_JOB_KIND
|
||||
from .prompts import thumbnail_url_for
|
||||
from . import thumbnail_settings
|
||||
from .. import model_gate
|
||||
|
||||
from aiohttp import web
|
||||
@@ -792,13 +793,16 @@ class AutotagRoutesMixin:
|
||||
rel_path = image_path.relative_to(output_path)
|
||||
thumbnail_url = None
|
||||
if has_thumbnails:
|
||||
thumb_rel = (
|
||||
f"thumbnails/{rel_path.with_suffix('').as_posix()}"
|
||||
f"_thumb{image_path.suffix}"
|
||||
)
|
||||
thumbnail_url = thumbnail_url_for(
|
||||
thumb_rel, output_path / thumb_rel
|
||||
# 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)
|
||||
images.append(
|
||||
{
|
||||
"filename": image_path.name,
|
||||
|
||||
+10
-10
@@ -15,7 +15,9 @@ from typing import BinaryIO
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from . import thumbnail_settings
|
||||
from .images import _canonical, _public_path, SERVABLE_EXTENSIONS, VIDEO_EXTENSIONS
|
||||
from .images import media_thumbnail_rel
|
||||
|
||||
try:
|
||||
from ...utils.execution_outputs import (
|
||||
@@ -338,15 +340,11 @@ def _prepare_current_media(
|
||||
if thumbnail:
|
||||
relative = target.relative_to(resolved.gallery_root)
|
||||
is_video = target.suffix.lower() in VIDEO_EXTENSIONS
|
||||
extension = ".jpg" if is_video else target.suffix.lower()
|
||||
preview = (
|
||||
resolved.gallery_root
|
||||
/ "thumbnails"
|
||||
/ f"{relative.with_suffix('').as_posix()}_thumb{extension}"
|
||||
)
|
||||
try:
|
||||
preview.stat()
|
||||
except FileNotFoundError:
|
||||
# #317: any preview the file owns (the current format first); a
|
||||
# missing one is made under the current format's name.
|
||||
preview = thumbnail_settings.existing_thumbnail(resolved.gallery_root, relative)
|
||||
if preview is None:
|
||||
preview = resolved.gallery_root / media_thumbnail_rel(relative)
|
||||
if ensure_preview is not None and _preview_writable(
|
||||
resolved.gallery_root, preview
|
||||
):
|
||||
@@ -362,7 +360,9 @@ def _prepare_current_media(
|
||||
return _OpenedCurrentMedia(
|
||||
handle,
|
||||
os.fstat(handle.fileno()).st_size,
|
||||
mimetypes.guess_type(target.name)[0] or "application/octet-stream",
|
||||
thumbnail_settings.content_type(target)
|
||||
or mimetypes.guess_type(target.name)[0]
|
||||
or "application/octet-stream",
|
||||
)
|
||||
except BaseException:
|
||||
handle.close()
|
||||
|
||||
+298
-264
@@ -6,10 +6,11 @@ import binascii
|
||||
import functools
|
||||
import hashlib
|
||||
import hmac
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import subprocess # nosec B404 - ffmpeg frames through run_encoder, fixed argv
|
||||
import time as _time
|
||||
import urllib.parse
|
||||
from bisect import bisect_left, bisect_right
|
||||
@@ -21,6 +22,18 @@ from aiohttp import web
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
|
||||
from .jobs import MAX_OUTCOME_ROWS, THUMBNAILS_JOB_KIND
|
||||
from . import thumbnail_settings
|
||||
from .thumbnail_encode import (
|
||||
PREVIEW_HELD,
|
||||
EncoderClosed,
|
||||
PreviewWriteHeld,
|
||||
replace_unless_held,
|
||||
run_encoder,
|
||||
temp_sibling,
|
||||
thumbnail_frame,
|
||||
writer_settings,
|
||||
write_thumbnail,
|
||||
)
|
||||
from .prompts import bad_request, parse_page_params, publish_image_paths
|
||||
from .prompts import thumbnail_url_for
|
||||
from .prompts import safe_error_message as _safe_error
|
||||
@@ -212,6 +225,13 @@ def _has_traversal(relative):
|
||||
return ".." in re.split(r"[\\/]+", relative)
|
||||
|
||||
|
||||
def _is_thumbnail_cache_path(relative):
|
||||
"""True when *relative* names something in a root's ``thumbnails/``
|
||||
cache (any case, either separator): never inspected as a media file."""
|
||||
first = re.split(r"[\\/]+", relative.lstrip("\\/"), maxsplit=1)[0]
|
||||
return first.casefold() == "thumbnails"
|
||||
|
||||
|
||||
def _forbidden(message="Access denied"):
|
||||
return web.json_response({"success": False, "error": message}, status=403)
|
||||
|
||||
@@ -256,55 +276,10 @@ def _thumbnail_batch_size(workers):
|
||||
return max(4, workers * 2)
|
||||
|
||||
|
||||
def _temp_sibling(dst):
|
||||
"""Hidden temp path next to *dst* that keeps its extension (for format sniffing)."""
|
||||
dst = Path(dst)
|
||||
token = f"{os.getpid()}-{threading.get_ident()}"
|
||||
return dst.with_name(f".{dst.stem}.part-{token}{dst.suffix}")
|
||||
|
||||
|
||||
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.
|
||||
|
||||
``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)
|
||||
_replace_unless_held(tmp, dst, guard)
|
||||
finally:
|
||||
if tmp.exists():
|
||||
tmp.unlink()
|
||||
|
||||
|
||||
# Encoder settings per thumbnail quality (the System thumbnail dialog's
|
||||
# "Medium · optimized", "High · original", "Low · fastest"). The legacy
|
||||
# routes always encode as "medium", which is what they always wrote.
|
||||
THUMBNAIL_ENCODINGS = {
|
||||
"low": {"quality": 70, "optimize": False},
|
||||
"medium": {"quality": 85, "optimize": True},
|
||||
"high": {"quality": 95, "optimize": True},
|
||||
}
|
||||
DEFAULT_THUMBNAIL_ENCODING = THUMBNAIL_ENCODINGS["medium"]
|
||||
# ffmpeg's JPEG scale (-q:v, 2 best to 31 worst) for each encoding's quality.
|
||||
_FFMPEG_QSCALE = {95: 2, 85: 4, 70: 8}
|
||||
# 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
|
||||
@@ -321,26 +296,15 @@ def _preview_write_error(exc):
|
||||
return PREVIEW_NOT_WRITABLE
|
||||
|
||||
|
||||
def _ffmpeg_qscale(encoding):
|
||||
"""ffmpeg's ``-q:v`` for a THUMBNAIL_ENCODINGS entry."""
|
||||
return str(_FFMPEG_QSCALE.get(encoding["quality"], 4))
|
||||
def _write_image_thumbnail(src, dst, settings, guard=None, track=None):
|
||||
"""Write *src*'s preview into *dst* with *settings* (blocking, atomic).
|
||||
|
||||
|
||||
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. ``guard`` see _atomic_save.
|
||||
The first frame, fitted inside the edge, in the configured format;
|
||||
returns the path written (see thumbnail_encode.write_thumbnail).
|
||||
"""
|
||||
encoding = encoding or DEFAULT_THUMBNAIL_ENCODING
|
||||
with Image.open(src) as img:
|
||||
if img.mode in ("RGBA", "LA", "P"):
|
||||
img = img.convert("RGB")
|
||||
img.thumbnail(thumbnail_size, Image.Resampling.LANCZOS)
|
||||
save_kwargs = dict(encoding)
|
||||
if dst.suffix.lower() == ".png":
|
||||
save_kwargs = {"optimize": encoding["optimize"]}
|
||||
_atomic_save(img, dst, guard=guard, **save_kwargs)
|
||||
frame = thumbnail_frame(img, settings.edge)
|
||||
return write_thumbnail(frame, dst, settings, guard=guard, track=track)
|
||||
|
||||
|
||||
def _video_placeholder_image(thumbnail_size):
|
||||
@@ -452,6 +416,58 @@ def _thumbnail_complete_payload(total, stats, start, remaining=0, processed=None
|
||||
}
|
||||
|
||||
|
||||
# The per-file states a thumbnail plan counts (#317).
|
||||
_PLAN_COUNTS = ("skipped", "remaining", "fresh", "outdated", "missing")
|
||||
|
||||
|
||||
def _preview_names(folders, folder):
|
||||
"""{normcased name: mtime} of the regular files in *folder*, listed
|
||||
once per plan (a missing folder is empty)."""
|
||||
names = folders.get(folder)
|
||||
if names is None:
|
||||
names = {}
|
||||
try:
|
||||
with os.scandir(folder) as entries:
|
||||
for entry in entries:
|
||||
try:
|
||||
if entry.is_file(follow_symlinks=False):
|
||||
stat = entry.stat(follow_symlinks=False)
|
||||
names[os.path.normcase(entry.name)] = stat.st_mtime
|
||||
except OSError:
|
||||
continue
|
||||
except OSError:
|
||||
pass
|
||||
folders[folder] = names
|
||||
return names
|
||||
|
||||
|
||||
def _preview_state(src, folder, stem, settings, folders):
|
||||
"""fresh, outdated or missing for *src* (see _plan_thumbnails)."""
|
||||
names = _preview_names(folders, folder)
|
||||
try:
|
||||
src_mtime = src.stat().st_mtime
|
||||
except OSError:
|
||||
return "missing"
|
||||
current = names.get(os.path.normcase(stem + settings.extension))
|
||||
others = [
|
||||
names[key]
|
||||
for key in (
|
||||
os.path.normcase(stem + extension)
|
||||
for extension in thumbnail_settings.candidate_extensions(
|
||||
src.suffix, settings.extension
|
||||
)
|
||||
if extension != settings.extension
|
||||
)
|
||||
if key in names
|
||||
]
|
||||
current_fresh = current is not None and current >= src_mtime
|
||||
if current_fresh and not others:
|
||||
return "fresh"
|
||||
if others and (current_fresh or max(others) >= src_mtime):
|
||||
return "outdated"
|
||||
return "missing"
|
||||
|
||||
|
||||
def _thumbnail_is_fresh(src, dst):
|
||||
"""True when ``dst`` exists and is at least as new as ``src`` (blocking stat)."""
|
||||
try:
|
||||
@@ -774,39 +790,42 @@ def _iter_media_files(output_path, max_files):
|
||||
yield root_path / name
|
||||
|
||||
|
||||
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()
|
||||
return (
|
||||
f"thumbnails/{rel_path.with_suffix('').as_posix()}_thumb"
|
||||
f"{'.jpg' if extension in VIDEO_EXTENSIONS else extension}"
|
||||
)
|
||||
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)."""
|
||||
settings = settings or writer_settings()
|
||||
return thumbnail_settings.thumbnail_rel(rel_path, settings.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
|
||||
"""``(thumbnail_url, ready)`` for *rel_path* under *output_path* (a few
|
||||
blocking stats). 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.
|
||||
An existing preview the file owns (the configured format first, then an
|
||||
older name such as ``_thumb.png``, #317) is listed with its mtime as
|
||||
``v=`` and ``ready`` True. A missing one is still listed under the name
|
||||
it will get (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)
|
||||
settings = writer_settings()
|
||||
existing = thumbnail_settings.existing_thumbnail(
|
||||
output_path, rel_path, settings.extension
|
||||
)
|
||||
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)
|
||||
ready = thumbnail_url is not None
|
||||
if thumbnail_url is None:
|
||||
thumb_rel = media_thumbnail_rel(rel_path, settings)
|
||||
quoted = urllib.parse.quote(thumb_rel, safe="/")
|
||||
thumbnail_url = f"/prompt_manager/images/serve/{quoted}?root={root_index}"
|
||||
else:
|
||||
@@ -1733,6 +1752,11 @@ class ImageRoutesMixin:
|
||||
def _file_response(path):
|
||||
response = web.FileResponse(path)
|
||||
response.headers["Cache-Control"] = "public, max-age=3600"
|
||||
# #317: name the image types explicitly; the platform's mimetypes
|
||||
# table (the Windows registry) may not know .webp or .jxl.
|
||||
content_type = thumbnail_settings.content_type(path)
|
||||
if content_type is not None:
|
||||
response.headers["Content-Type"] = content_type
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
@@ -1758,7 +1782,12 @@ class ImageRoutesMixin:
|
||||
filepath = request.match_info["filepath"]
|
||||
if _has_traversal(filepath):
|
||||
return _forbidden()
|
||||
if Path(filepath).suffix.lower() not in SERVABLE_EXTENSIONS:
|
||||
suffix = Path(filepath).suffix.lower()
|
||||
if suffix not in SERVABLE_EXTENSIONS and not (
|
||||
# A JPEG XL preview is served from thumbnails/ only (#317).
|
||||
suffix in thumbnail_settings.THUMBNAIL_EXTENSIONS
|
||||
and _is_thumbnail_cache_path(filepath)
|
||||
):
|
||||
return _forbidden("Only media files can be served")
|
||||
|
||||
output_dirs = self._select_root(roots, request.query.get("root"))
|
||||
@@ -1873,22 +1902,14 @@ class ImageRoutesMixin:
|
||||
{"success": False, "error": _safe_error(e)}, status=500
|
||||
)
|
||||
|
||||
# Fixed quality presets: clients choose a name, never a raw pixel size.
|
||||
THUMBNAIL_SIZES = {"low": (150, 150), "medium": (300, 300), "high": (600, 600)}
|
||||
|
||||
@classmethod
|
||||
def _thumbnail_size(cls, quality):
|
||||
"""Map a quality preset name to a pixel size; anything else is medium."""
|
||||
if not isinstance(quality, str):
|
||||
return cls.THUMBNAIL_SIZES["medium"]
|
||||
return cls.THUMBNAIL_SIZES.get(quality, cls.THUMBNAIL_SIZES["medium"])
|
||||
|
||||
async def generate_thumbnails(self, request):
|
||||
"""Generate every thumbnail in one executor job (POST, blocking).
|
||||
|
||||
Holds the registry as guarded work: refused while a record-changing
|
||||
job runs, and such jobs are refused while it runs. A body that cannot
|
||||
be read is refused before the hold, so it is never listed as a job.
|
||||
The legacy ``quality`` preset is accepted and ignored: previews follow
|
||||
the server's thumbnail settings (#317 R3).
|
||||
"""
|
||||
data, error = await _json_body_or_error(request)
|
||||
if error is not None:
|
||||
@@ -1902,8 +1923,6 @@ class ImageRoutesMixin:
|
||||
|
||||
async def _generate_thumbnails(self, data):
|
||||
try:
|
||||
thumbnail_size = self._thumbnail_size(data.get("quality", "medium"))
|
||||
|
||||
output_dirs = self._get_all_output_dirs()
|
||||
if not output_dirs:
|
||||
return web.json_response(
|
||||
@@ -1912,7 +1931,7 @@ class ImageRoutesMixin:
|
||||
)
|
||||
|
||||
result = await self._run_in_executor(
|
||||
self._generate_gallery_thumbnails_sync, output_dirs, thumbnail_size
|
||||
self._generate_gallery_thumbnails_sync, output_dirs
|
||||
)
|
||||
self.invalidate_gallery_cache()
|
||||
return web.json_response(result)
|
||||
@@ -1923,30 +1942,30 @@ class ImageRoutesMixin:
|
||||
{"success": False, "error": _safe_error(e)}, status=500
|
||||
)
|
||||
|
||||
def _generate_thumbnails_sync(self, output_path, thumbnails_dir, thumbnail_size):
|
||||
def _generate_thumbnails_sync(self, output_path, thumbnails_dir, settings=None):
|
||||
"""Blocking thumbnail generation loop (run in executor)."""
|
||||
settings = writer_settings(settings)
|
||||
start = _time.monotonic()
|
||||
plan = self._plan_thumbnails(output_path, thumbnails_dir)
|
||||
payload = self._execute_thumbnail_plan(plan, thumbnail_size, start)
|
||||
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)
|
||||
return payload
|
||||
|
||||
def _generate_gallery_thumbnails_sync(
|
||||
self, output_dirs: list[Path], thumbnail_size: tuple[int, int]
|
||||
self, output_dirs: list[Path], settings=None
|
||||
) -> dict:
|
||||
settings = writer_settings(settings)
|
||||
start = _time.monotonic()
|
||||
plan = self._plan_gallery_thumbnails(output_dirs)
|
||||
payload = self._execute_thumbnail_plan(plan, thumbnail_size, start)
|
||||
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")
|
||||
return payload
|
||||
|
||||
def _execute_thumbnail_plan(
|
||||
self, plan: dict, thumbnail_size: tuple[int, int], start: float
|
||||
) -> dict:
|
||||
def _execute_thumbnail_plan(self, plan: dict, settings, start: float) -> dict:
|
||||
targets = plan["targets"]
|
||||
stats = _new_thumbnail_stats()
|
||||
stats["skipped"] = plan["skipped"]
|
||||
for result in self._generate_batch(targets, thumbnail_size):
|
||||
for result in self._generate_batch(targets, settings):
|
||||
_record_thumbnail_result(stats, result)
|
||||
payload = _thumbnail_complete_payload(
|
||||
len(targets) + plan["skipped"] + plan["remaining"],
|
||||
@@ -1960,7 +1979,11 @@ class ImageRoutesMixin:
|
||||
return payload
|
||||
|
||||
def _plan_gallery_thumbnails(
|
||||
self, output_dirs: list[Path], rebuild: bool = False, stop=None
|
||||
self,
|
||||
output_dirs: list[Path],
|
||||
mode: str = "missing",
|
||||
stop=None,
|
||||
settings=None,
|
||||
) -> dict:
|
||||
"""Plan every gallery root with one shared per-run generation cap.
|
||||
|
||||
@@ -1968,33 +1991,32 @@ class ImageRoutesMixin:
|
||||
MAX_FRESH_LISTED files across the roots. ``stop()`` true ends the walk
|
||||
at the next file (a cancelled job); the partial plan is returned.
|
||||
"""
|
||||
combined = {
|
||||
"targets": [],
|
||||
"skipped": 0,
|
||||
"remaining": 0,
|
||||
"fresh": 0,
|
||||
"fresh_files": [],
|
||||
}
|
||||
settings = writer_settings(settings)
|
||||
combined = {key: 0 for key in _PLAN_COUNTS}
|
||||
combined.update(targets=[], fresh_files=[])
|
||||
for root in output_dirs:
|
||||
if stop is not None and stop():
|
||||
break
|
||||
root = Path(root)
|
||||
plan = self._plan_thumbnails(
|
||||
root, root / "thumbnails", rebuild=rebuild, stop=stop
|
||||
root, root / "thumbnails", mode=mode, stop=stop, settings=settings
|
||||
)
|
||||
available = max(0, MAX_THUMBNAIL_FILES - len(combined["targets"]))
|
||||
combined["targets"].extend(plan["targets"][:available])
|
||||
combined["skipped"] += plan["skipped"]
|
||||
combined["fresh"] += plan["fresh"]
|
||||
for key in _PLAN_COUNTS:
|
||||
combined[key] += plan[key]
|
||||
room = max(0, MAX_FRESH_LISTED - len(combined["fresh_files"]))
|
||||
combined["fresh_files"].extend(plan["fresh_files"][:room])
|
||||
combined["remaining"] += plan["remaining"] + max(
|
||||
0, len(plan["targets"]) - available
|
||||
)
|
||||
combined["remaining"] += max(0, len(plan["targets"]) - available)
|
||||
return combined
|
||||
|
||||
async def get_thumbnail_status(self, request: web.Request) -> web.Response:
|
||||
"""Count pending thumbnails off-loop without generating files."""
|
||||
"""Count previews off-loop without generating files.
|
||||
|
||||
``pending`` previews are missing, ``outdated`` ones exist in another
|
||||
format than the one written now (Convert outdated, #317) and ``ready``
|
||||
counts every file with a preview, outdated or not.
|
||||
"""
|
||||
try:
|
||||
output_dirs = self._get_all_output_dirs()
|
||||
plan = await self._run_in_executor(
|
||||
@@ -2008,6 +2030,8 @@ class ImageRoutesMixin:
|
||||
"total_images": pending + plan["skipped"],
|
||||
"pending": pending,
|
||||
"ready": plan["skipped"],
|
||||
"outdated": plan["outdated"],
|
||||
"format": writer_settings().format,
|
||||
# Files whose automatic preview failed this session (#249).
|
||||
"failed": self._auto_thumbnail_failures(),
|
||||
"collection_id": hashlib.sha256(collection.encode()).hexdigest()[
|
||||
@@ -2022,76 +2046,81 @@ class ImageRoutesMixin:
|
||||
{"success": False, "error": _safe_error(exc)}, status=500
|
||||
)
|
||||
|
||||
def _plan_thumbnails(self, output_path, thumbnails_dir, rebuild=False, stop=None):
|
||||
"""Decide which media files need a thumbnail (blocking walk).
|
||||
def _plan_thumbnails(
|
||||
self, output_path, thumbnails_dir, mode="missing", stop=None, settings=None
|
||||
):
|
||||
"""Decide which media files need a preview (blocking walk).
|
||||
|
||||
Returns ``{"targets": [(src, dst, is_video)], "skipped": n,
|
||||
"remaining": n, "fresh": n, "fresh_files": [src]}``. Files whose
|
||||
thumbnail is already newer than the source are ``fresh``. Without
|
||||
``rebuild`` they are counted as skipped and never queued, so a large
|
||||
library that is mostly done only pays for the files that changed;
|
||||
``fresh_files`` lists the first MAX_FRESH_LISTED of them. With
|
||||
``rebuild`` every file is a target (nothing is skipped). The walk is
|
||||
bounded like the gallery listing (MAX_SCAN_DEPTH, MAX_GALLERY_FILES,
|
||||
no symlinks) and at most MAX_THUMBNAIL_FILES targets are queued per
|
||||
run; ``remaining`` counts the targets beyond that cap. ``stop()`` true
|
||||
ends the walk before the next file.
|
||||
Each file is ``fresh`` (its preview in the format written now is at
|
||||
least as new as the file, and no other owned preview is left),
|
||||
``outdated`` (an owned preview in another format: a legacy
|
||||
``_thumb.png`` or a format switched away from, #317) or ``missing``.
|
||||
``mode`` picks the targets: ``missing`` the missing ones (fresh and
|
||||
outdated are ``skipped`` and never queued, so a large library that is
|
||||
mostly done only pays for the files that changed), ``rebuild`` every
|
||||
file, ``convert`` the outdated ones (missing ones are left to the
|
||||
missing run). Returns ``{"targets": [(src, dst, is_video)], "skipped",
|
||||
"remaining", "fresh", "outdated", "missing", "fresh_files": [src]}``;
|
||||
``fresh_files`` lists the first MAX_FRESH_LISTED fresh files. The walk
|
||||
is bounded like the gallery listing (MAX_SCAN_DEPTH, MAX_GALLERY_FILES,
|
||||
no symlinks), each preview folder is listed once, and at most
|
||||
MAX_THUMBNAIL_FILES targets are queued per run; ``remaining`` counts
|
||||
the targets beyond that cap. ``stop()`` true ends the walk before the
|
||||
next file.
|
||||
"""
|
||||
settings = writer_settings(settings)
|
||||
output_path = Path(output_path)
|
||||
targets = []
|
||||
skipped = 0
|
||||
remaining = 0
|
||||
fresh = 0
|
||||
fresh_files = []
|
||||
thumbnails_dir = Path(thumbnails_dir)
|
||||
plan = {key: 0 for key in _PLAN_COUNTS}
|
||||
plan.update(targets=[], fresh_files=[])
|
||||
folders = {}
|
||||
for src in _iter_media_files(output_path, MAX_GALLERY_FILES):
|
||||
if stop is not None and stop():
|
||||
break
|
||||
suffix = src.suffix.lower()
|
||||
is_video = suffix in VIDEO_EXTENSIONS
|
||||
rel_no_ext = src.relative_to(output_path).with_suffix("")
|
||||
thumb_suffix = ".jpg" if is_video else suffix
|
||||
dst = thumbnails_dir / f"{rel_no_ext.as_posix()}_thumb{thumb_suffix}"
|
||||
stem = f"{rel_no_ext.name}{thumbnail_settings.THUMB_SUFFIX}"
|
||||
dst = thumbnails_dir / rel_no_ext.parent / (stem + settings.extension)
|
||||
if not _is_within(dst, thumbnails_dir):
|
||||
self.logger.warning(f"Skipping thumbnail outside safe dir: {src.name}")
|
||||
continue
|
||||
if _thumbnail_is_fresh(src, dst):
|
||||
fresh += 1
|
||||
if not rebuild:
|
||||
skipped += 1
|
||||
if len(fresh_files) < MAX_FRESH_LISTED:
|
||||
fresh_files.append(src)
|
||||
continue
|
||||
if len(targets) >= MAX_THUMBNAIL_FILES:
|
||||
remaining += 1
|
||||
state = _preview_state(src, dst.parent, stem, settings, folders)
|
||||
plan[state] += 1
|
||||
target = (
|
||||
mode == "rebuild"
|
||||
or (mode == "missing" and state == "missing")
|
||||
or (mode == "convert" and state == "outdated")
|
||||
)
|
||||
if not target:
|
||||
plan["skipped"] += 1
|
||||
if state == "fresh" and len(plan["fresh_files"]) < MAX_FRESH_LISTED:
|
||||
plan["fresh_files"].append(src)
|
||||
continue
|
||||
targets.append((src, dst, is_video))
|
||||
if remaining:
|
||||
if len(plan["targets"]) >= MAX_THUMBNAIL_FILES:
|
||||
plan["remaining"] += 1
|
||||
continue
|
||||
is_video = src.suffix.lower() in VIDEO_EXTENSIONS
|
||||
plan["targets"].append((src, dst, is_video))
|
||||
if plan["remaining"]:
|
||||
self.logger.warning(
|
||||
f"Thumbnail run capped at {MAX_THUMBNAIL_FILES} new files; "
|
||||
f"{remaining} more will be generated on the next run"
|
||||
f"{plan['remaining']} more will be generated on the next run"
|
||||
)
|
||||
return {
|
||||
"targets": targets,
|
||||
"skipped": skipped,
|
||||
"remaining": remaining,
|
||||
"fresh": fresh,
|
||||
"fresh_files": fresh_files,
|
||||
}
|
||||
return plan
|
||||
|
||||
def _thumbnail_targets(self, output_path, thumbnails_dir):
|
||||
"""Pending (src, dst, is_video) triples; see _plan_thumbnails."""
|
||||
return self._plan_thumbnails(output_path, thumbnails_dir)["targets"]
|
||||
|
||||
def _generate_batch(self, targets, thumbnail_size, **options):
|
||||
def _generate_batch(self, targets, settings, **options):
|
||||
"""Create thumbnails for ``targets`` on the configured worker threads.
|
||||
|
||||
Blocking; returns one result dict per target, in order. ``options``
|
||||
go to ``_generate_one`` (``encoding``, ``rebuild``, ``stop``).
|
||||
go to ``_generate_one`` (``rebuild``, ``stop``).
|
||||
"""
|
||||
|
||||
def one(target):
|
||||
src, dst, is_video = target
|
||||
return self._generate_one(src, dst, thumbnail_size, is_video, **options)
|
||||
return self._generate_one(src, dst, settings, is_video, **options)
|
||||
|
||||
return map_parallel(one, targets, _worker_threads())
|
||||
|
||||
@@ -2099,9 +2128,8 @@ class ImageRoutesMixin:
|
||||
self,
|
||||
src,
|
||||
dst,
|
||||
thumbnail_size,
|
||||
settings,
|
||||
is_video,
|
||||
encoding=None,
|
||||
rebuild=False,
|
||||
stop=None,
|
||||
guard=None,
|
||||
@@ -2109,35 +2137,40 @@ class ImageRoutesMixin:
|
||||
):
|
||||
"""Create one thumbnail (blocking). Never raises; returns a result dict.
|
||||
|
||||
``stop()`` true before the file starts gives ``action: "cancelled"``
|
||||
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. ``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).
|
||||
*settings* (``thumbnail_settings.ThumbnailSettings``) give the format,
|
||||
quality and edge; *dst* takes the format's extension. ``stop()`` true
|
||||
before the file starts gives ``action: "cancelled"`` 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 and verified, so a failure keeps
|
||||
the old preview. ``guard()`` false just before the rename gives
|
||||
``action: "error"`` with ``held: True`` and writes nothing;
|
||||
``track(process)`` is handed every encoder 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). A generated
|
||||
result names the file written as ``path``.
|
||||
"""
|
||||
result = {
|
||||
"file": src.name,
|
||||
"dir": src.parent.name,
|
||||
"type": "video" if is_video else "image",
|
||||
}
|
||||
dst = Path(dst)
|
||||
if dst.suffix != settings.extension:
|
||||
dst = dst.with_suffix(settings.extension)
|
||||
try:
|
||||
if stop is not None and stop():
|
||||
return {**result, "action": "cancelled"}
|
||||
if not src.is_file():
|
||||
return {**result, "action": "error", "error": "File no longer exists"}
|
||||
if not rebuild and _thumbnail_is_fresh(src, dst):
|
||||
return {**result, "action": "skipped"}
|
||||
return {**result, "action": "skipped", "path": str(dst)}
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
if is_video:
|
||||
made = self._generate_video_thumbnail(
|
||||
src,
|
||||
dst,
|
||||
thumbnail_size,
|
||||
encoding,
|
||||
settings,
|
||||
keep_existing=rebuild,
|
||||
guard=guard,
|
||||
track=track,
|
||||
@@ -2149,9 +2182,10 @@ class ImageRoutesMixin:
|
||||
"error": "Video decode failed",
|
||||
"decode": True,
|
||||
}
|
||||
written = made if isinstance(made, Path) else dst
|
||||
else:
|
||||
_write_image_thumbnail(src, dst, thumbnail_size, encoding, guard)
|
||||
return {**result, "action": "generated"}
|
||||
written = _write_image_thumbnail(src, dst, settings, guard, track)
|
||||
return {**result, "action": "generated", "path": str(written)}
|
||||
except PreviewWriteHeld:
|
||||
return {**result, "action": "error", "error": PREVIEW_HELD, "held": True}
|
||||
except UnidentifiedImageError:
|
||||
@@ -2186,41 +2220,44 @@ class ImageRoutesMixin:
|
||||
self,
|
||||
video_path,
|
||||
thumbnail_path,
|
||||
thumbnail_size,
|
||||
encoding=None,
|
||||
settings,
|
||||
keep_existing=False,
|
||||
*,
|
||||
guard=None,
|
||||
track=None,
|
||||
):
|
||||
"""Generate thumbnail from video file. Returns True if successful.
|
||||
"""Write a video's preview; returns the path written, or False.
|
||||
|
||||
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.
|
||||
The frame comes from OpenCV, else ffmpeg, else 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). Every frame
|
||||
is fitted inside the edge and encoded in *settings*' format like an
|
||||
image's preview (#317). 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 do
|
||||
PreviewWriteHeld when ``guard()`` refuses the rename and
|
||||
EncoderClosed at shutdown. ``track(process)`` is handed the ffmpeg
|
||||
process so a shutdown can kill it.
|
||||
"""
|
||||
encoding = encoding or DEFAULT_THUMBNAIL_ENCODING
|
||||
try:
|
||||
frame = self._opencv_frame(video_path, thumbnail_size)
|
||||
frame = self._opencv_frame(video_path, settings.size)
|
||||
source = "OpenCV"
|
||||
if frame is None:
|
||||
frame = self._ffmpeg_frame(video_path, settings.size, track)
|
||||
source = "ffmpeg"
|
||||
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}"
|
||||
written = write_thumbnail(
|
||||
thumbnail_frame(frame, settings.edge),
|
||||
thumbnail_path,
|
||||
settings,
|
||||
guard=guard,
|
||||
track=track,
|
||||
)
|
||||
return True
|
||||
if self._ffmpeg_frame(
|
||||
video_path, thumbnail_path, thumbnail_size, encoding, guard, track
|
||||
):
|
||||
return True
|
||||
self.logger.debug(
|
||||
f"Generated video thumbnail using {source}: {written}"
|
||||
)
|
||||
return written
|
||||
if keep_existing and thumbnail_path.exists():
|
||||
self.logger.warning(
|
||||
f"No frame decoded for {video_path.name}; keeping its preview"
|
||||
@@ -2228,20 +2265,23 @@ class ImageRoutesMixin:
|
||||
return False
|
||||
# Last resort: the placeholder frame.
|
||||
try:
|
||||
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}"
|
||||
written = write_thumbnail(
|
||||
_video_placeholder_image(settings.size),
|
||||
thumbnail_path,
|
||||
settings,
|
||||
guard=guard,
|
||||
track=track,
|
||||
)
|
||||
return True
|
||||
except (PermissionError, PreviewWriteHeld):
|
||||
self.logger.debug(f"Generated placeholder video thumbnail: {written}")
|
||||
return written
|
||||
except (PermissionError, PreviewWriteHeld, EncoderClosed):
|
||||
raise
|
||||
except Exception as e:
|
||||
self.logger.warning(
|
||||
f"Failed to create placeholder thumbnail for {video_path}: {e}"
|
||||
)
|
||||
return False
|
||||
except (PermissionError, PreviewWriteHeld):
|
||||
except (PermissionError, PreviewWriteHeld, EncoderClosed):
|
||||
raise
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
@@ -2275,15 +2315,14 @@ class ImageRoutesMixin:
|
||||
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)
|
||||
def _ffmpeg_frame(self, video_path, thumbnail_size, track=None):
|
||||
"""ffmpeg's frame at 1 s, fitted to *thumbnail_size*, as a PIL image
|
||||
(a PNG on stdout, never a temp file), or None. EncoderClosed (the
|
||||
shutdown) propagates."""
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-v",
|
||||
"error",
|
||||
"-i",
|
||||
str(video_path),
|
||||
"-ss",
|
||||
@@ -2291,45 +2330,36 @@ class ImageRoutesMixin:
|
||||
"-vframes",
|
||||
"1",
|
||||
"-vf",
|
||||
f"scale={thumbnail_size[0]}:{thumbnail_size[1]}"
|
||||
# Fit inside the edge, never upscale a small clip.
|
||||
f"scale='min(iw,{thumbnail_size[0]})':'min(ih,{thumbnail_size[1]})'"
|
||||
":force_original_aspect_ratio=decrease",
|
||||
"-q:v",
|
||||
_ffmpeg_qscale(encoding),
|
||||
"-y",
|
||||
str(tmp_path),
|
||||
"-f",
|
||||
"image2pipe",
|
||||
"-vcodec",
|
||||
"png",
|
||||
"-",
|
||||
]
|
||||
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
|
||||
done = run_encoder(cmd, track=track)
|
||||
except EncoderClosed:
|
||||
raise
|
||||
except (OSError, subprocess.SubprocessError) as e:
|
||||
self.logger.debug(f"ffmpeg could not read {video_path}: {e}")
|
||||
return None
|
||||
if done.returncode != 0 or not done.stdout:
|
||||
detail = done.stderr or b""
|
||||
if isinstance(detail, bytes):
|
||||
detail = detail.decode("utf-8", "replace")
|
||||
detail = detail[-300:]
|
||||
self.logger.warning(f"ffmpeg failed for {video_path}: {detail}")
|
||||
return None
|
||||
try:
|
||||
frame = Image.open(io.BytesIO(done.stdout))
|
||||
frame.load()
|
||||
return frame
|
||||
except Exception as e:
|
||||
self.logger.warning(f"ffmpeg gave no readable frame for {video_path}: {e}")
|
||||
return None
|
||||
|
||||
async def clear_thumbnails(self, request: web.Request) -> web.Response:
|
||||
"""Clear generated thumbnails across gallery roots, preserving originals.
|
||||
@@ -2423,7 +2453,8 @@ class ImageRoutesMixin:
|
||||
path = Path(root) / name
|
||||
if (
|
||||
"_thumb" not in name.lower()
|
||||
or path.suffix.lower() not in GALLERY_IMAGE_EXTENSIONS
|
||||
or path.suffix.lower()
|
||||
not in thumbnail_settings.THUMBNAIL_EXTENSIONS
|
||||
or path.is_symlink()
|
||||
):
|
||||
continue
|
||||
@@ -2746,6 +2777,9 @@ class ImageRoutesMixin:
|
||||
or re.match(r"^[a-zA-Z]:", relative)
|
||||
):
|
||||
return _forbidden()
|
||||
if _is_thumbnail_cache_path(relative):
|
||||
# A preview is a placeholder: it carries no metadata (#317).
|
||||
return _forbidden("Previews are not inspected; inspect the original")
|
||||
path = Path(roots[index]) / relative
|
||||
denied = _media_access_error(path, [roots[index]])
|
||||
if denied is not None:
|
||||
|
||||
+48
-24
@@ -33,9 +33,11 @@ previews behind those URLs are made here, in three ways:
|
||||
(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.
|
||||
Every preview is written by ``_generate_one`` with the server's thumbnail
|
||||
settings (format, quality, size limit; ``thumbnail_settings``, #317), into a
|
||||
temp sibling verified and renamed over the old one, so a reader never sees a
|
||||
partial file. A request for an older preview name (``_thumb.png``) is answered
|
||||
with the preview in the current format.
|
||||
|
||||
Holds: automatic previews only write files under ``thumbnails/`` and never
|
||||
touch the database, like the thumbnail job, so they run beside a scan, image
|
||||
@@ -63,11 +65,10 @@ from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from . import thumbnail_settings
|
||||
from .images import (
|
||||
GALLERY_IMAGE_EXTENSIONS,
|
||||
MEDIA_EXTENSIONS,
|
||||
THUMBNAIL_CLEAR_TITLE,
|
||||
THUMBNAIL_ENCODINGS,
|
||||
VIDEO_EXTENSIONS,
|
||||
_has_traversal,
|
||||
_is_within,
|
||||
@@ -83,16 +84,22 @@ from .jobs import (
|
||||
JobBusy,
|
||||
)
|
||||
from .settings_routes import SOURCE_CHANGE_JOB_KIND
|
||||
from .thumbnail_jobs import DEFAULT_THUMBNAIL_EDGE, ThumbnailOptions
|
||||
from .thumbnail_encode import shutdown_encoders, writer_settings
|
||||
from .thumbnail_jobs import 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"]
|
||||
# The placeholder frame a video without a preview is answered with (JPEG
|
||||
# bytes, never written to disk); written previews follow the settings.
|
||||
PLACEHOLDER_SIZE = (thumbnail_settings.DEFAULT_EDGE, thumbnail_settings.DEFAULT_EDGE)
|
||||
PLACEHOLDER_QUALITY = 85
|
||||
# Source extensions in the order a shared preview name looks for them (R2).
|
||||
_SOURCE_ORDER = (".png", ".jpg", ".jpeg", ".webp", ".gif") + tuple(
|
||||
sorted(VIDEO_EXTENSIONS)
|
||||
)
|
||||
# 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.
|
||||
@@ -146,8 +153,8 @@ def _dst_key(dst):
|
||||
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
|
||||
_video_placeholder_image(PLACEHOLDER_SIZE).save(
|
||||
buffer, "JPEG", quality=PLACEHOLDER_QUALITY, optimize=True
|
||||
)
|
||||
return buffer.getvalue()
|
||||
|
||||
@@ -171,7 +178,7 @@ def _is_preview_request(filepath):
|
||||
stem.endswith("_thumb")
|
||||
and len(stem) > len("_thumb")
|
||||
and extension == extension.lower()
|
||||
and extension in GALLERY_IMAGE_EXTENSIONS
|
||||
and extension in thumbnail_settings.THUMBNAIL_EXTENSIONS
|
||||
)
|
||||
|
||||
|
||||
@@ -184,12 +191,27 @@ def _regular_file(path):
|
||||
|
||||
|
||||
def _candidates(parts):
|
||||
"""``(folder parts, base, extensions)`` of a checked preview request."""
|
||||
"""``(folder parts, base, source extensions)`` of a checked preview
|
||||
request: every media extension that owns the requested preview name
|
||||
(``thumbnail_settings.owns``), images first (ruling R2)."""
|
||||
stem, extension = os.path.splitext(parts[-1])
|
||||
wanted = [extension] + (sorted(VIDEO_EXTENSIONS) if extension == ".jpg" else [])
|
||||
wanted = [
|
||||
source
|
||||
for source in _SOURCE_ORDER
|
||||
if source in MEDIA_EXTENSIONS and thumbnail_settings.owns(extension, source)
|
||||
]
|
||||
return parts[1:-1], stem[: -len("_thumb")], wanted
|
||||
|
||||
|
||||
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."""
|
||||
_folder, base, _wanted = _candidates(parts)
|
||||
name = base + thumbnail_settings.THUMB_SUFFIX + writer_settings().extension
|
||||
return Path(root).joinpath(*parts[:-1], name)
|
||||
|
||||
|
||||
def _quick_source(root, parts):
|
||||
"""``(src, is_video)`` by exact name only (no folder listing), or None."""
|
||||
root = Path(root)
|
||||
@@ -207,11 +229,12 @@ 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.
|
||||
without ``_thumb`` and an extension that owns the requested one (any case:
|
||||
exact names first, then one listing of the folder; ruling R4). It must be
|
||||
a regular file inside *root*. ``dst`` is the preview in the format written
|
||||
now (#317), whatever extension was requested; it 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)
|
||||
@@ -224,7 +247,7 @@ def _thumbnail_source(root, parts):
|
||||
return None
|
||||
rel = Path(*folder_parts, found.name)
|
||||
dst = root / media_thumbnail_rel(rel)
|
||||
if dst != root.joinpath(*parts):
|
||||
if dst != _current_preview(root, parts):
|
||||
return None
|
||||
if not _is_within(root / "thumbnails", root) or not _is_within(
|
||||
dst, root / "thumbnails"
|
||||
@@ -605,6 +628,8 @@ class AutoThumbnailMixin:
|
||||
thumbnailer.close()
|
||||
if pool is not None:
|
||||
pool.shutdown(wait=False, cancel_futures=True)
|
||||
if final:
|
||||
shutdown_encoders() # kill JPEG XL / ffmpeg encoders still running
|
||||
|
||||
def _auto_thumbnail_failures(self):
|
||||
thumbnailer = self._auto_thumbnailer
|
||||
@@ -614,9 +639,8 @@ class AutoThumbnailMixin:
|
||||
return self._generate_one(
|
||||
src,
|
||||
dst,
|
||||
AUTO_THUMBNAIL_SIZE,
|
||||
writer_settings(),
|
||||
is_video,
|
||||
encoding=AUTO_THUMBNAIL_ENCODING,
|
||||
stop=stop,
|
||||
guard=guard,
|
||||
track=track,
|
||||
@@ -730,7 +754,7 @@ class AutoThumbnailMixin:
|
||||
if quick is None:
|
||||
continue
|
||||
return self._preview_response(BUSY, quick[0], None, quick[1])
|
||||
dst = root.joinpath(*parts)
|
||||
dst = _current_preview(root, parts)
|
||||
if outcome == READY:
|
||||
return self._file_response(dst)
|
||||
located = await gate.loop.run_in_executor(
|
||||
@@ -807,7 +831,7 @@ class AutoThumbnailMixin:
|
||||
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")
|
||||
options = ThumbnailOptions("missing", writer_settings())
|
||||
try:
|
||||
self._start_thumbnail_job(options)
|
||||
except JobBusy as busy:
|
||||
|
||||
@@ -34,10 +34,13 @@ import os
|
||||
import shutil
|
||||
import subprocess # nosec B404 - fixed argv lists, never a shell
|
||||
import threading
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from . import thumbnail_settings
|
||||
|
||||
try:
|
||||
from ...utils.logging_config import get_logger
|
||||
except ImportError: # tests import the package top-level
|
||||
@@ -383,6 +386,17 @@ def _jxl_fallback():
|
||||
)
|
||||
|
||||
|
||||
def writer_settings(settings=None):
|
||||
"""The settings a preview is written with now: the configured ones, but
|
||||
WebP while JPEG XL has no encoder (logged once). Its extension names the
|
||||
file, so "outdated" means a preview this server would not write now."""
|
||||
settings = settings or thumbnail_settings.current()
|
||||
if settings.format == "jxl" and detect_jxl_encoder() is None:
|
||||
_jxl_fallback()
|
||||
return replace(settings, format="webp")
|
||||
return settings
|
||||
|
||||
|
||||
def write_thumbnail(frame, dst, settings, *, guard=None, track=None):
|
||||
"""Encode *frame* into *dst* in ``settings``' format; returns the path
|
||||
written (``.webp`` when JPEG XL fell back). Atomic and verified; raises
|
||||
|
||||
+56
-85
@@ -1,29 +1,31 @@
|
||||
"""Thumbnail generation as an Activity job (#249 System S5, Task 12).
|
||||
"""Thumbnail generation as an Activity job (#249 System S5, Task 12; #317).
|
||||
|
||||
- ``GET /prompt_manager/images/thumbnails/plan?mode=&size=&quality=`` counts
|
||||
what a run would do over every gallery root and writes nothing:
|
||||
``{success, targets, fresh, remaining, roots, mode, size, quality}``.
|
||||
- ``POST /prompt_manager/jobs/thumbnails {mode, size, quality}`` starts the
|
||||
``thumbnails`` job (202 ``{job}``). Its admission class is ``guarded``: it
|
||||
never runs beside a record-changing job, and one thumbnail job runs at a
|
||||
time. It stops at a file boundary when cancelled, keeping the previews it
|
||||
wrote. Its outcome groups are ``generated``, ``skipped`` (already fresh)
|
||||
and ``errors``.
|
||||
- ``GET /prompt_manager/images/thumbnails/plan?mode=`` counts what a run
|
||||
would do over every gallery root and writes nothing: ``{success, targets,
|
||||
fresh, outdated, remaining, roots, mode, format, quality, size}``.
|
||||
- ``POST /prompt_manager/jobs/thumbnails {mode}`` starts the ``thumbnails``
|
||||
job (202 ``{job}``). Its admission class is ``guarded``: it never runs
|
||||
beside a record-changing job, and one thumbnail job runs at a time. It
|
||||
stops at a file boundary when cancelled, keeping the previews it wrote.
|
||||
Its outcome groups are ``generated``, ``skipped`` (already fresh) and
|
||||
``errors``.
|
||||
|
||||
``mode`` is ``missing`` (previews that are absent or older than their file)
|
||||
or ``rebuild`` (every preview again, fresh ones too). ``size`` is the
|
||||
preview's longest edge, 128 to 2048 pixels. ``quality`` picks the encoder
|
||||
settings (``THUMBNAIL_ENCODINGS``). Every preview is written next to the old
|
||||
one and renamed over it, so nothing is cleared first and a failure keeps the
|
||||
old preview; originals are only read.
|
||||
or ``rebuild`` (every preview again, fresh ones too). Format, quality and
|
||||
size limit are the server's thumbnail settings (``thumbnail_settings``,
|
||||
#317), read once when the job starts and shown in its scope; a ``size`` or
|
||||
``quality`` an older client sends is ignored. Every preview is written next
|
||||
to the old one, verified and renamed over it, so nothing is cleared first and
|
||||
a failure keeps the old preview; originals are only read.
|
||||
|
||||
The legacy gallery stream ``POST /images/generate-thumbnails/progress``
|
||||
starts this job (its quality preset still sizes the previews) or attaches to
|
||||
the running one. Its first frame, ``job {id, started}``, names that job and
|
||||
whether this stream started it (the gallery cancels by that id, and only a
|
||||
job it started); then come the job's legacy ``status``/``start``/
|
||||
``progress``/``file_error``/``complete`` frames. A client that goes away no
|
||||
longer stops the work: the job outlives the request, as a scan does.
|
||||
starts this job (missing previews, the server's settings; its ``quality``
|
||||
preset is ignored, #317 R3) or attaches to the running one. Its first frame,
|
||||
``job {id, started}``, names that job and whether this stream started it (the
|
||||
gallery cancels by that id, and only a job it started); then come the job's
|
||||
legacy ``status``/``start``/``progress``/``file_error``/``complete`` frames. A
|
||||
client that goes away no longer stops the work: the job outlives the request,
|
||||
as a scan does.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -36,7 +38,6 @@ from aiohttp import web
|
||||
|
||||
from .admin import _plural, _scan_location, _scan_roots, _scan_status_line
|
||||
from .images import (
|
||||
THUMBNAIL_ENCODINGS,
|
||||
_BodyTooLarge,
|
||||
_new_thumbnail_stats,
|
||||
_read_json_body,
|
||||
@@ -48,24 +49,12 @@ from .images import (
|
||||
)
|
||||
from .job_routes import busy_response
|
||||
from .jobs import THUMBNAIL_GROUP_NAMES, THUMBNAILS_JOB_KIND, JobBusy
|
||||
from .thumbnail_encode import writer_settings
|
||||
from .thumbnail_settings import ThumbnailSettings
|
||||
|
||||
THUMBNAIL_JOB_TITLE = "Thumbnail generation"
|
||||
THUMBNAIL_MODES = ("missing", "rebuild")
|
||||
THUMBNAIL_QUALITIES = tuple(THUMBNAIL_ENCODINGS)
|
||||
MIN_THUMBNAIL_EDGE = 128
|
||||
MAX_THUMBNAIL_EDGE = 2048
|
||||
# The concept's "Preview size limit" default.
|
||||
DEFAULT_THUMBNAIL_EDGE = 512
|
||||
DEFAULT_THUMBNAIL_OPTIONS = {
|
||||
"mode": "missing",
|
||||
"size": DEFAULT_THUMBNAIL_EDGE,
|
||||
"quality": "medium",
|
||||
}
|
||||
_OPTIONS_ERROR = (
|
||||
'mode must be "missing" or "rebuild", quality "low", "medium" or "high", '
|
||||
f"and size a whole number of pixels from {MIN_THUMBNAIL_EDGE} "
|
||||
f"to {MAX_THUMBNAIL_EDGE}"
|
||||
)
|
||||
_OPTIONS_ERROR = 'mode must be "missing" or "rebuild"'
|
||||
CANCELLED_ERROR = "Thumbnail generation was cancelled"
|
||||
CANCELLED_BEFORE_WRITING = "Cancelled before any preview was written"
|
||||
NOTHING_TO_PREVIEW = "No images or videos to preview"
|
||||
@@ -80,56 +69,35 @@ _LOG_PREFIXES = {"generated": "+", "errors": "!"}
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ThumbnailOptions:
|
||||
"""One run's choices: the mode, the preview's longest edge and quality."""
|
||||
"""One run's choices: the mode and the thumbnail settings it writes with."""
|
||||
|
||||
mode: str
|
||||
edge: int
|
||||
quality: str
|
||||
|
||||
@property
|
||||
def size(self):
|
||||
return (self.edge, self.edge)
|
||||
|
||||
@property
|
||||
def encoding(self):
|
||||
return THUMBNAIL_ENCODINGS[self.quality]
|
||||
settings: ThumbnailSettings
|
||||
|
||||
@property
|
||||
def rebuild(self):
|
||||
return self.mode == "rebuild"
|
||||
|
||||
def scope(self):
|
||||
return {"mode": self.mode, "size": self.edge, "quality": self.quality}
|
||||
|
||||
|
||||
def _edge(value, *, from_query):
|
||||
"""The preview edge as an int, or None when it is not one in range."""
|
||||
if from_query and isinstance(value, str):
|
||||
if not value.isascii() or not value.isdigit() or len(value) > 5:
|
||||
return None
|
||||
value = int(value)
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
return None
|
||||
return value if MIN_THUMBNAIL_EDGE <= value <= MAX_THUMBNAIL_EDGE else None
|
||||
return {
|
||||
"mode": self.mode,
|
||||
"format": self.settings.format,
|
||||
"quality": self.settings.quality,
|
||||
"size": self.settings.edge,
|
||||
}
|
||||
|
||||
|
||||
def thumbnail_options(raw, *, from_query=False):
|
||||
"""``(ThumbnailOptions, None)`` from a body or query, or ``(None, message)``.
|
||||
|
||||
Absent fields take ``DEFAULT_THUMBNAIL_OPTIONS``. Query values are
|
||||
strings, so a query ``size`` may be decimal digits; a JSON ``size`` must
|
||||
be an int.
|
||||
An absent mode is ``missing``; the settings are the server's now.
|
||||
"""
|
||||
if not isinstance(raw, dict) and not hasattr(raw, "get"):
|
||||
return None, _OPTIONS_ERROR
|
||||
mode = raw.get("mode", DEFAULT_THUMBNAIL_OPTIONS["mode"])
|
||||
quality = raw.get("quality", DEFAULT_THUMBNAIL_OPTIONS["quality"])
|
||||
edge = _edge(raw.get("size", DEFAULT_THUMBNAIL_EDGE), from_query=from_query)
|
||||
if mode not in THUMBNAIL_MODES or quality not in THUMBNAIL_QUALITIES:
|
||||
mode = raw.get("mode", "missing")
|
||||
if mode not in THUMBNAIL_MODES:
|
||||
return None, _OPTIONS_ERROR
|
||||
if edge is None:
|
||||
return None, _OPTIONS_ERROR
|
||||
return ThumbnailOptions(mode, edge, quality), None
|
||||
return ThumbnailOptions(mode, writer_settings()), None
|
||||
|
||||
|
||||
def _invalid(message):
|
||||
@@ -168,7 +136,10 @@ class ThumbnailJobsMixin:
|
||||
try:
|
||||
output_dirs = await self._run_in_executor(self._get_all_output_dirs)
|
||||
plan = await self._run_in_executor(
|
||||
self._plan_gallery_thumbnails, output_dirs, rebuild=options.rebuild
|
||||
self._plan_gallery_thumbnails,
|
||||
output_dirs,
|
||||
options.mode,
|
||||
settings=options.settings,
|
||||
)
|
||||
except Exception:
|
||||
self.logger.exception("Thumbnail plan failed")
|
||||
@@ -185,6 +156,7 @@ class ThumbnailJobsMixin:
|
||||
"success": True,
|
||||
"targets": len(plan["targets"]),
|
||||
"fresh": plan["fresh"],
|
||||
"outdated": plan["outdated"],
|
||||
"remaining": plan["remaining"],
|
||||
"roots": [self._public_path(root) for root in output_dirs],
|
||||
**options.scope(),
|
||||
@@ -265,7 +237,7 @@ class ThumbnailJobsMixin:
|
||||
job.update(status_line=f"Looking for media files in {folders}")
|
||||
job.log_line(
|
||||
f"Thumbnails started · {folders} · {_MODE_LOG[options.mode]} · "
|
||||
f"{options.edge} px · {options.quality}"
|
||||
f"{options.settings.describe()}"
|
||||
)
|
||||
job.publish(
|
||||
_frame(
|
||||
@@ -276,8 +248,9 @@ class ThumbnailJobsMixin:
|
||||
plan = await self._run_in_executor(
|
||||
self._plan_gallery_thumbnails,
|
||||
output_dirs,
|
||||
rebuild=options.rebuild,
|
||||
options.mode,
|
||||
stop=lambda: job.cancelled,
|
||||
settings=options.settings,
|
||||
)
|
||||
if job.cancelled: # M2: stopped during the walk, before any start frame
|
||||
return self._end_cancelled(job, 0, CANCELLED_BEFORE_WRITING)
|
||||
@@ -367,7 +340,6 @@ class ThumbnailJobsMixin:
|
||||
start = time.monotonic()
|
||||
batch_size = _thumbnail_batch_size(_worker_threads())
|
||||
generate = {
|
||||
"encoding": options.encoding,
|
||||
"rebuild": options.rebuild,
|
||||
"stop": lambda: job.cancelled,
|
||||
}
|
||||
@@ -378,7 +350,7 @@ class ThumbnailJobsMixin:
|
||||
break
|
||||
batch = targets[batch_start : batch_start + batch_size]
|
||||
results = await self._run_in_executor(
|
||||
self._generate_batch, batch, options.size, **generate
|
||||
self._generate_batch, batch, options.settings, **generate
|
||||
)
|
||||
for (src, _dst, _video), result in zip(batch, results):
|
||||
if result["action"] == "cancelled":
|
||||
@@ -430,14 +402,13 @@ class ThumbnailJobsMixin:
|
||||
async def generate_thumbnails_with_progress(self, request):
|
||||
"""The gallery's thumbnail stream (POST, server-sent events).
|
||||
|
||||
Starts the thumbnail job with the gallery's quality preset as the
|
||||
preview size (missing previews, medium encoding, as before), or
|
||||
attaches to the running thumbnail job, and streams its frames. A
|
||||
Starts the thumbnail job (missing previews with the server's
|
||||
thumbnail settings; the gallery's ``quality`` preset is ignored, R3),
|
||||
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.
|
||||
"""
|
||||
thumbnail_size = self._thumbnail_size(request.query.get("quality", "medium"))
|
||||
response = web.StreamResponse(
|
||||
status=200,
|
||||
headers={
|
||||
@@ -449,7 +420,7 @@ class ThumbnailJobsMixin:
|
||||
await response.prepare(request)
|
||||
# M3: read the registry after the last await, so a job that started
|
||||
# while the headers went out is attached to, not refused.
|
||||
job, started, busy = self._legacy_stream_job(thumbnail_size)
|
||||
job, started, busy = self._legacy_stream_job()
|
||||
if busy is not None:
|
||||
message = f"{busy.title} already running"
|
||||
await self._emit_sse(
|
||||
@@ -476,12 +447,12 @@ class ThumbnailJobsMixin:
|
||||
await events.aclose()
|
||||
return response
|
||||
|
||||
def _legacy_stream_job(self, thumbnail_size):
|
||||
def _legacy_stream_job(self):
|
||||
"""``(job, started, busy)`` for the legacy stream, synchronously.
|
||||
|
||||
Attaches to the running cancellable thumbnail job, or starts one with
|
||||
the gallery's preset as the size (missing previews, medium encoding,
|
||||
as the gallery always wrote), or names the job in the way.
|
||||
Attaches to the running cancellable thumbnail job, or starts one for
|
||||
the missing previews with the server's settings, or names the job in
|
||||
the way.
|
||||
"""
|
||||
running = self._jobs.active(THUMBNAILS_JOB_KIND)
|
||||
if running is not None and running.cancellable:
|
||||
@@ -490,5 +461,5 @@ class ThumbnailJobsMixin:
|
||||
if busy is not None:
|
||||
return None, False, busy
|
||||
# blocker() said free and nothing awaited since, so the start is admitted.
|
||||
options = ThumbnailOptions("missing", thumbnail_size[0], "medium")
|
||||
options = ThumbnailOptions("missing", writer_settings())
|
||||
return self._start_thumbnail_job(options), True, None
|
||||
|
||||
@@ -164,10 +164,26 @@ def _is_preview_file(path):
|
||||
def _owned(root, rel_path, preferred):
|
||||
rel_path = Path(rel_path)
|
||||
stem = Path(root) / thumbnail_stem_rel(rel_path)
|
||||
for extension in candidate_extensions(rel_path.suffix, preferred):
|
||||
extensions = list(candidate_extensions(rel_path.suffix, preferred))
|
||||
if rel_path.suffix != rel_path.suffix.lower() and owns(
|
||||
rel_path.suffix, rel_path.suffix
|
||||
):
|
||||
# An older release kept the source's own spelling (``b_thumb.JPG``).
|
||||
extensions.append(rel_path.suffix)
|
||||
seen = set()
|
||||
for extension in extensions:
|
||||
path = stem.with_name(stem.name + extension)
|
||||
if _is_preview_file(path):
|
||||
yield path
|
||||
if not _is_preview_file(path):
|
||||
continue
|
||||
try:
|
||||
stat = path.stat()
|
||||
identity = (stat.st_dev, stat.st_ino) if stat.st_ino else str(path)
|
||||
except OSError:
|
||||
continue
|
||||
if identity in seen:
|
||||
continue # the same file under another case (Windows, macOS)
|
||||
seen.add(identity)
|
||||
yield path
|
||||
|
||||
|
||||
def owned_thumbnails(root, rel_path, preferred=None):
|
||||
|
||||
Reference in New Issue
Block a user