Skip to content

Commit c2015dd

Browse files
committed
feat: enhance thumbnail handling and add native thumbnail support in VirtualFS
1 parent ca500cb commit c2015dd

4 files changed

Lines changed: 64 additions & 31 deletions

File tree

domain/adapters/providers/telegram.py

Lines changed: 32 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import os
66
import struct
77
from models import StorageAdapter
8-
from telethon import TelegramClient
8+
from telethon import TelegramClient, utils
99
from telethon.crypto import AuthKey
1010
from telethon.sessions import StringSession
1111
from telethon.tl import types
@@ -132,29 +132,42 @@ def _pick_photo_thumb(thumbs: list | None):
132132
return None
133133

134134
cached = []
135-
others = []
135+
downloadable = []
136136
for t in thumbs:
137137
if isinstance(t, (types.PhotoCachedSize, types.PhotoStrippedSize)):
138138
cached.append(t)
139139
elif isinstance(t, (types.PhotoSize, types.PhotoSizeProgressive)):
140140
if not isinstance(t, types.PhotoSizeEmpty):
141-
others.append(t)
141+
downloadable.append(t)
142142

143-
if cached:
144-
cached.sort(key=lambda x: len(getattr(x, "bytes", b"") or b""))
145-
return cached[-1]
146-
147-
if others:
143+
if downloadable:
148144
def _sz(x):
149145
if isinstance(x, types.PhotoSizeProgressive):
150146
return max(x.sizes or [0])
151147
return int(getattr(x, "size", 0) or 0)
152148

153-
others.sort(key=_sz)
154-
return others[-1]
149+
downloadable.sort(key=_sz)
150+
return downloadable[-1]
151+
152+
if cached:
153+
cached.sort(key=lambda x: len(getattr(x, "bytes", b"") or b""))
154+
return cached[-1]
155155

156156
return None
157157

158+
@staticmethod
159+
def _get_message_thumbs(message) -> list:
160+
doc = message.document or message.video
161+
if doc and getattr(doc, "thumbs", None):
162+
return list(doc.thumbs or [])
163+
if message.photo and getattr(message.photo, "sizes", None):
164+
return list(message.photo.sizes or [])
165+
return []
166+
167+
@classmethod
168+
def _message_has_thumbnail(cls, message) -> bool:
169+
return cls._pick_photo_thumb(cls._get_message_thumbs(message)) is not None
170+
158171
def _build_session(self) -> StringSession:
159172
s = (self.session_string or "").strip()
160173
if not s:
@@ -229,6 +242,7 @@ async def list_dir(self, root: str, rel: str, page_num: int = 1, page_size: int
229242
"size": size,
230243
"mtime": int(message.date.timestamp()),
231244
"type": "file",
245+
"has_thumbnail": self._message_has_thumbnail(message),
232246
})
233247
finally:
234248
if client.is_connected():
@@ -386,17 +400,16 @@ async def get_thumbnail(self, root: str, rel: str, size: str = "medium"):
386400
if not message:
387401
return None
388402

389-
doc = message.document or message.video
390-
thumbs = None
391-
if doc and getattr(doc, "thumbs", None):
392-
thumbs = list(doc.thumbs or [])
393-
elif message.photo and getattr(message.photo, "sizes", None):
394-
thumbs = list(message.photo.sizes or [])
395-
396-
thumb = self._pick_photo_thumb(thumbs)
403+
thumb = self._pick_photo_thumb(self._get_message_thumbs(message))
397404
if not thumb:
398405
return None
399406

407+
embedded = getattr(thumb, "bytes", None)
408+
if embedded and isinstance(thumb, types.PhotoCachedSize):
409+
return bytes(embedded)
410+
if embedded and isinstance(thumb, types.PhotoStrippedSize):
411+
return utils.stripped_photo_to_jpg(bytes(embedded))
412+
400413
result = await client.download_media(message, bytes, thumb=thumb)
401414
if isinstance(result, (bytes, bytearray)):
402415
return bytes(result)
@@ -569,6 +582,7 @@ async def stat_file(self, root: str, rel: str):
569582
"size": size,
570583
"mtime": int(message.date.timestamp()),
571584
"type": "file",
585+
"has_thumbnail": self._message_has_thumbnail(message),
572586
}
573587
finally:
574588
if client.is_connected():

domain/virtual_fs/listing.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,9 @@ def build_sort_key(item: Dict) -> Tuple:
8989

9090
def annotate_entry(entry: Dict) -> None:
9191
if not entry.get("is_dir"):
92+
if entry.get("has_thumbnail") is not None:
93+
entry["has_thumbnail"] = bool(entry.get("has_thumbnail"))
94+
return
9295
name = entry.get("name", "")
9396
entry["has_thumbnail"] = bool(is_image_filename(name) or is_video_filename(name))
9497
else:
@@ -273,7 +276,10 @@ async def stat_file(cls, path: str, verbose: bool = False):
273276
is_dir = False
274277
rel_name = rel.rstrip("/").split("/")[-1] if rel else path.rstrip("/").split("/")[-1]
275278
name_hint = str(info.get("name") or rel_name or "")
276-
info["has_thumbnail"] = bool(not is_dir and (is_image_filename(name_hint) or is_video_filename(name_hint)))
279+
if not is_dir and info.get("has_thumbnail") is not None:
280+
info["has_thumbnail"] = bool(info.get("has_thumbnail"))
281+
else:
282+
info["has_thumbnail"] = bool(not is_dir and (is_image_filename(name_hint) or is_video_filename(name_hint)))
277283
if verbose and not is_dir:
278284
vector_index = await cls._gather_vector_index(path)
279285
if vector_index is not None:

domain/virtual_fs/routes.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,17 @@ async def get_thumbnail(cls, full_path: str, w: int, h: int, fit: str) -> Respon
8989
adapter, mount, root, rel = await cls.resolve_adapter_and_rel(full_path)
9090
if not rel or rel.endswith("/"):
9191
raise HTTPException(400, detail="Not a file")
92-
if not (is_image_filename(rel) or is_video_filename(rel)):
93-
raise HTTPException(404, detail="Not an image or video")
92+
has_native_thumb = False
93+
if callable(getattr(adapter, "get_thumbnail", None)):
94+
stat_file = getattr(adapter, "stat_file", None)
95+
if callable(stat_file):
96+
try:
97+
stat = await stat_file(root, rel)
98+
has_native_thumb = bool(isinstance(stat, dict) and stat.get("has_thumbnail"))
99+
except Exception:
100+
has_native_thumb = False
101+
if not (is_image_filename(rel) or is_video_filename(rel) or has_native_thumb):
102+
raise HTTPException(404, detail="Not an image, video, or native thumbnail file")
94103
data, mime, key = await get_or_create_thumb(adapter, mount.id, root, rel, w, h, fit) # type: ignore
95104
headers = {
96105
"Cache-Control": "public, max-age=3600",

domain/virtual_fs/thumbnail.py

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
VIDEO_THUMB_SEEK_SECONDS = (15, 10, 5, 3, 1, 0)
2424
VIDEO_BLACK_FRAME_MEAN_THRESHOLD = 12.0
2525
CACHE_ROOT = Path('data/.thumb_cache')
26+
THUMB_CACHE_VERSION = "v2"
2627

2728

2829
def is_image_filename(name: str) -> bool:
@@ -47,7 +48,7 @@ def is_video_filename(name: str) -> bool:
4748

4849

4950
def _cache_key(adapter_id: int, rel: str, size: int, mtime: int, w: int, h: int, fit: str) -> str:
50-
raw = f"{adapter_id}|{rel}|{size}|{mtime}|{w}x{h}|{fit}".encode()
51+
raw = f"{THUMB_CACHE_VERSION}|{adapter_id}|{rel}|{size}|{mtime}|{w}x{h}|{fit}".encode()
5152
return hashlib.sha1(raw).hexdigest()
5253

5354

@@ -385,8 +386,11 @@ async def get_or_create_thumb(adapter, adapter_id: int, root: str, rel: str, w:
385386
stat = await adapter.stat_file(root, rel)
386387
size = int(stat.get('size') or 0)
387388
is_video = is_video_filename(rel)
388-
if not is_video and size > MAX_IMAGE_SOURCE_SIZE:
389-
raise HTTPException(400, detail="Image too large for thumbnail")
389+
is_image = is_image_filename(rel)
390+
get_thumb_impl = getattr(adapter, "get_thumbnail", None)
391+
should_try_native_thumb = callable(get_thumb_impl) and (
392+
is_image or is_video or bool(stat.get("has_thumbnail"))
393+
)
390394

391395
key = _cache_key(adapter_id, rel, size, int(
392396
stat.get('mtime', 0)), w, h, fit)
@@ -397,19 +401,15 @@ async def get_or_create_thumb(adapter, adapter_id: int, root: str, rel: str, w:
397401
_ensure_cache_dir(path)
398402
thumb_bytes, mime = None, None
399403

400-
get_thumb_impl = getattr(adapter, "get_thumbnail", None)
401-
if callable(get_thumb_impl):
404+
if should_try_native_thumb:
402405
size_str = "large" if w > 400 else "medium" if w > 100 else "small"
403406
native_thumb_bytes = await get_thumb_impl(root, rel, size_str)
404407

405408
if native_thumb_bytes:
406409
try:
407410
from PIL import Image
408411
im = Image.open(io.BytesIO(native_thumb_bytes))
409-
buf = io.BytesIO()
410-
im.save(buf, 'WEBP', quality=85)
411-
thumb_bytes = buf.getvalue()
412-
mime = 'image/webp'
412+
thumb_bytes, mime = _image_to_webp(im, w, h, fit)
413413
except Exception as e:
414414
print(
415415
f"Failed to convert native thumbnail to WebP: {e}, falling back.")
@@ -493,7 +493,9 @@ async def _read_tail(limit: int) -> Tuple[bytes, int]:
493493
thumb_bytes, mime = retry_thumb, retry_mime
494494
except Exception:
495495
pass
496-
else:
496+
elif is_image:
497+
if size > MAX_IMAGE_SOURCE_SIZE:
498+
raise HTTPException(400, detail="Image too large for thumbnail")
497499
read_data = await adapter.read_file(root, rel)
498500
try:
499501
thumb_bytes, mime = generate_thumb(
@@ -502,6 +504,8 @@ async def _read_tail(limit: int) -> Tuple[bytes, int]:
502504
print(e)
503505
raise HTTPException(
504506
500, detail=f"Thumbnail generation failed: {e}")
507+
else:
508+
raise HTTPException(500, detail="Native thumbnail unavailable")
505509

506510
if thumb_bytes:
507511
path.write_bytes(thumb_bytes)

0 commit comments

Comments
 (0)