Skip to content

Commit fe98d57

Browse files
authored
fix(viewer): use writable cache dir for thumbnail generation (#167)
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).
1 parent e2c9ce5 commit fe98d57

4 files changed

Lines changed: 58 additions & 10 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "telegram-archive"
7-
version = "7.10.10"
7+
version = "7.10.11"
88
description = "Automated Telegram backup with Docker. Performs incremental backups of messages and media on a configurable schedule."
99
readme = "README.md"
1010
requires-python = ">=3.14"

src/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
Telegram Backup Automation - Main Package
33
"""
44

5-
__version__ = "7.10.10"
5+
__version__ = "7.10.11"

src/web/main.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -867,6 +867,9 @@ async def serve_service_worker():
867867
# Media is served via authenticated endpoint below (not StaticFiles)
868868
_media_root = Path(config.media_path).resolve() if os.path.exists(config.media_path) else None
869869

870+
# Thumbnail cache lives outside media root so it works with read-only media volumes
871+
_thumb_cache_dir: Path | None = None
872+
870873

871874
# Thumbnail endpoint MUST be defined before the catch-all /media/{path:path} route
872875
@app.get("/media/thumb/{size}/{folder:path}/{filename}")
@@ -881,9 +884,13 @@ async def serve_thumbnail(size: int, folder: str, filename: str, user: UserConte
881884
# Chat-level access check
882885
_enforce_media_acl(f"{folder}/{filename}", user, thumbnail=True)
883886

884-
from .thumbnails import ensure_thumbnail
887+
from .thumbnails import ensure_thumbnail, resolve_cache_dir
888+
889+
global _thumb_cache_dir
890+
if _thumb_cache_dir is None:
891+
_thumb_cache_dir = resolve_cache_dir(_media_root)
885892

886-
thumb_path = await ensure_thumbnail(_media_root, size, folder, filename)
893+
thumb_path = await ensure_thumbnail(_media_root, size, folder, filename, cache_dir=_thumb_cache_dir)
887894
if not thumb_path:
888895
raise HTTPException(status_code=404, detail="Thumbnail not available")
889896

src/web/thumbnails.py

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
"""On-demand thumbnail generation with disk caching.
22
33
Generates WebP thumbnails at whitelisted sizes, stored under
4-
{media_root}/.thumbs/{size}/{folder}/{stem}.webp.
4+
{cache_dir}/{size}/{folder}/{stem}.webp.
55
Pillow 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

811
import asyncio
912
import logging
13+
import os
1014
from pathlib import Path
1115

1216
from PIL import Image
@@ -25,6 +29,32 @@
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

2959
def _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

Comments
 (0)