11"""On-demand thumbnail generation with disk caching.
22
33Generates WebP thumbnails at whitelisted sizes, stored under
4- {media_root}/.thumbs /{size}/{folder}/{stem}.webp.
4+ {cache_dir} /{size}/{folder}/{stem}.webp.
55Pillow runs in a thread executor to avoid blocking the async event loop.
6+
7+ The cache directory is separate from the media root so thumbnails work
8+ even when the media volume is mounted read-only.
69"""
710
811import asyncio
912import logging
13+ import os
1014from pathlib import Path
1115
1216from PIL import Image
2529# Limit concurrent thumbnail generations to cap peak memory (~15MB per decode)
2630_generation_semaphore = asyncio .Semaphore (8 )
2731
32+ _DEFAULT_CACHE_DIR = "/tmp/telegram-archive-thumbs"
33+
34+
35+ def resolve_cache_dir (media_root : Path | None ) -> Path :
36+ """Determine the thumbnail cache directory.
37+
38+ Priority: THUMBNAIL_CACHE_DIR env > {media_root}/.thumbs (if writable) > /tmp fallback.
39+ """
40+ env_dir = os .environ .get ("THUMBNAIL_CACHE_DIR" )
41+ if env_dir :
42+ p = Path (env_dir )
43+ p .mkdir (parents = True , exist_ok = True )
44+ return p
45+
46+ if media_root :
47+ candidate = media_root / ".thumbs"
48+ try :
49+ candidate .mkdir (parents = True , exist_ok = True )
50+ return candidate
51+ except OSError :
52+ pass
53+
54+ p = Path (_DEFAULT_CACHE_DIR )
55+ p .mkdir (parents = True , exist_ok = True )
56+ return p
57+
2858
2959def _is_image (filename : str ) -> bool :
3060 return Path (filename ).suffix .lower () in _IMAGE_EXTENSIONS
@@ -51,11 +81,16 @@ def _generate_sync(source: Path, dest: Path, size: int) -> bool:
5181 return False
5282
5383
54- async def ensure_thumbnail (media_root : Path , size : int , folder : str , filename : str ) -> Path | None :
84+ async def ensure_thumbnail (
85+ media_root : Path , size : int , folder : str , filename : str , * , cache_dir : Path | None = None
86+ ) -> Path | None :
5587 """Return the path to a cached thumbnail, generating it if needed.
5688
5789 Returns None when the request is invalid or generation fails.
5890 Includes path traversal protection.
91+
92+ When cache_dir is provided, thumbnails are written there instead of
93+ under {media_root}/.thumbs/ — this supports read-only media volumes.
5994 """
6095 if size not in ALLOWED_SIZES :
6196 return None
@@ -70,10 +105,16 @@ async def ensure_thumbnail(media_root: Path, size: int, folder: str, filename: s
70105 if not source .is_relative_to (media_root_resolved ):
71106 return None
72107
73- dest = _thumb_path (media_root , size , folder , filename ).resolve ()
74- thumbs_root = (media_root / ".thumbs" ).resolve ()
75- if not dest .is_relative_to (thumbs_root ):
76- return None
108+ if cache_dir :
109+ stem = Path (filename ).stem
110+ dest = (cache_dir / str (size ) / folder / f"{ stem } .webp" ).resolve ()
111+ if not dest .is_relative_to (cache_dir .resolve ()):
112+ return None
113+ else :
114+ dest = _thumb_path (media_root , size , folder , filename ).resolve ()
115+ thumbs_root = (media_root / ".thumbs" ).resolve ()
116+ if not dest .is_relative_to (thumbs_root ):
117+ return None
77118
78119 if dest .exists ():
79120 return dest
0 commit comments