feat(thumbnails): keep previews in one folder, set from config.json (#320)

thumbnails.location in config.json can now name one folder for every
source's previews: <folder>/<source key>/<rel>_thumb<ext>. A source key is
a readable slug plus a short hash of the source's identity, stored in the
folder's marker (.promptmanager-thumbnails.json) with the install that
made it. Serving, on-demand and capture writes, listings, clear, delete,
duplicates, the temp sweep and History covers all follow it; a leftover in
<source>/thumbnails is never served and other text under thumbnails/ is
refused.

A missing, unplugged or foreign folder is unavailable (a probe checks it
every 5 s within 2 s; a stalled or failing store call flips it at once):
originals are served, nothing is written anywhere, the folder is never
re-created, and generate, convert and clear answer 409
location-unavailable. A bad config value is unavailable too, never a
fall-back to the sources.

No UI yet: POST /settings refuses thumbnail_location (409
location-needs-move); GET /settings reports it with its path, the
thumbnail status without one. Sources inside or around the folder and a
library move into it are refused.
This commit is contained in:
Vito Sansevero
2026-10-31 19:35:30 -07:00
parent 38f5491da1
commit 57ab85b002
21 changed files with 2211 additions and 40 deletions
+9 -3
View File
@@ -175,7 +175,7 @@ class DeferredPreview:
def resolve(self):
"""The preview's path now, or None when nothing may be written."""
dst = media_preview_path(self.root, self.rel)
dst = media_preview_path(self.root, self.rel, create=True)
if dst is None:
return None
if self.strict:
@@ -298,8 +298,12 @@ def _thumbnail_source(root, parts):
if in_trash_below_roots(found, [root]):
return None
rel = Path(*folder_parts, found.name)
dst = media_preview_path(root, rel)
if dst is None or dst != _current_preview(root, parts):
dst = media_preview_path(root, rel, create=True)
if dst is None:
# Nowhere to write (the thumbnail folder is unavailable): the
# original is served and nothing is written.
return found, None, found.suffix.lower() in VIDEO_EXTENSIONS
if dst != _current_preview(root, parts):
return None
if not preview_store.owns(root, dst):
dst = None
@@ -745,6 +749,8 @@ class AutoThumbnailMixin:
pool.shutdown(wait=False, cancel_futures=True)
if final:
shutdown_encoders() # kill JPEG XL / ffmpeg encoders still running
# The thumbnail folder's probe (#320), bounded like the rest.
closed = preview_store.stop_probe() and closed
return closed
def _auto_thumbnail_failures(self):