From f70bff3ca149267d7ab4abcf8dadd74e512ce0e8 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sun, 24 May 2026 16:34:05 +0200 Subject: [PATCH] fix(viewer): use writable cache dir for thumbnail generation The viewer container mounts the media volume read-only, causing thumbnail generation to fail with EROFS. Thumbnails are now cached in a separate writable directory (THUMBNAIL_CACHE_DIR env, or /tmp/telegram-archive-thumbs as fallback). --- pyproject.toml | 2 +- src/__init__.py | 2 +- src/web/main.py | 11 +++++++-- src/web/thumbnails.py | 53 ++++++++++++++++++++++++++++++++++++++----- 4 files changed, 58 insertions(+), 10 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2b8db59..aa682e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "telegram-archive" -version = "7.10.10" +version = "7.10.11" description = "Automated Telegram backup with Docker. Performs incremental backups of messages and media on a configurable schedule." readme = "README.md" requires-python = ">=3.14" diff --git a/src/__init__.py b/src/__init__.py index c52ae70..7efa668 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -2,4 +2,4 @@ Telegram Backup Automation - Main Package """ -__version__ = "7.10.10" +__version__ = "7.10.11" diff --git a/src/web/main.py b/src/web/main.py index dda88c8..bb78943 100644 --- a/src/web/main.py +++ b/src/web/main.py @@ -867,6 +867,9 @@ async def serve_service_worker(): # Media is served via authenticated endpoint below (not StaticFiles) _media_root = Path(config.media_path).resolve() if os.path.exists(config.media_path) else None +# Thumbnail cache lives outside media root so it works with read-only media volumes +_thumb_cache_dir: Path | None = None + # Thumbnail endpoint MUST be defined before the catch-all /media/{path:path} route @app.get("/media/thumb/{size}/{folder:path}/{filename}") @@ -881,9 +884,13 @@ async def serve_thumbnail(size: int, folder: str, filename: str, user: UserConte # Chat-level access check _enforce_media_acl(f"{folder}/{filename}", user, thumbnail=True) - from .thumbnails import ensure_thumbnail + from .thumbnails import ensure_thumbnail, resolve_cache_dir + + global _thumb_cache_dir + if _thumb_cache_dir is None: + _thumb_cache_dir = resolve_cache_dir(_media_root) - thumb_path = await ensure_thumbnail(_media_root, size, folder, filename) + thumb_path = await ensure_thumbnail(_media_root, size, folder, filename, cache_dir=_thumb_cache_dir) if not thumb_path: raise HTTPException(status_code=404, detail="Thumbnail not available") diff --git a/src/web/thumbnails.py b/src/web/thumbnails.py index 438de29..ec61da0 100644 --- a/src/web/thumbnails.py +++ b/src/web/thumbnails.py @@ -1,12 +1,16 @@ """On-demand thumbnail generation with disk caching. Generates WebP thumbnails at whitelisted sizes, stored under -{media_root}/.thumbs/{size}/{folder}/{stem}.webp. +{cache_dir}/{size}/{folder}/{stem}.webp. Pillow runs in a thread executor to avoid blocking the async event loop. + +The cache directory is separate from the media root so thumbnails work +even when the media volume is mounted read-only. """ import asyncio import logging +import os from pathlib import Path from PIL import Image @@ -25,6 +29,32 @@ # Limit concurrent thumbnail generations to cap peak memory (~15MB per decode) _generation_semaphore = asyncio.Semaphore(8) +_DEFAULT_CACHE_DIR = "/tmp/telegram-archive-thumbs" + + +def resolve_cache_dir(media_root: Path | None) -> Path: + """Determine the thumbnail cache directory. + + Priority: THUMBNAIL_CACHE_DIR env > {media_root}/.thumbs (if writable) > /tmp fallback. + """ + env_dir = os.environ.get("THUMBNAIL_CACHE_DIR") + if env_dir: + p = Path(env_dir) + p.mkdir(parents=True, exist_ok=True) + return p + + if media_root: + candidate = media_root / ".thumbs" + try: + candidate.mkdir(parents=True, exist_ok=True) + return candidate + except OSError: + pass + + p = Path(_DEFAULT_CACHE_DIR) + p.mkdir(parents=True, exist_ok=True) + return p + def _is_image(filename: str) -> bool: return Path(filename).suffix.lower() in _IMAGE_EXTENSIONS @@ -51,11 +81,16 @@ def _generate_sync(source: Path, dest: Path, size: int) -> bool: return False -async def ensure_thumbnail(media_root: Path, size: int, folder: str, filename: str) -> Path | None: +async def ensure_thumbnail( + media_root: Path, size: int, folder: str, filename: str, *, cache_dir: Path | None = None +) -> Path | None: """Return the path to a cached thumbnail, generating it if needed. Returns None when the request is invalid or generation fails. Includes path traversal protection. + + When cache_dir is provided, thumbnails are written there instead of + under {media_root}/.thumbs/ — this supports read-only media volumes. """ if size not in ALLOWED_SIZES: return None @@ -70,10 +105,16 @@ async def ensure_thumbnail(media_root: Path, size: int, folder: str, filename: s if not source.is_relative_to(media_root_resolved): return None - dest = _thumb_path(media_root, size, folder, filename).resolve() - thumbs_root = (media_root / ".thumbs").resolve() - if not dest.is_relative_to(thumbs_root): - return None + if cache_dir: + stem = Path(filename).stem + dest = (cache_dir / str(size) / folder / f"{stem}.webp").resolve() + if not dest.is_relative_to(cache_dir.resolve()): + return None + else: + dest = _thumb_path(media_root, size, folder, filename).resolve() + thumbs_root = (media_root / ".thumbs").resolve() + if not dest.is_relative_to(thumbs_root): + return None if dest.exists(): return dest