feat: enhance TelegramAdapter with message caching and connection management

This commit is contained in:
shiyu
2026-05-06 22:12:35 +08:00
parent 7b5f5e986e
commit 93d5e5e313
+149 -119
View File
@@ -1,8 +1,10 @@
from typing import List, Dict, Tuple, AsyncIterator, Optional from typing import List, Dict, Tuple, AsyncIterator, Optional
import asyncio
import base64 import base64
import io import io
import os import os
import struct import struct
import time
from models import StorageAdapter from models import StorageAdapter
from telethon import TelegramClient, utils from telethon import TelegramClient, utils
from telethon.crypto import AuthKey from telethon.crypto import AuthKey
@@ -51,6 +53,9 @@ CONFIG_SCHEMA = [
class TelegramAdapter: class TelegramAdapter:
"""Telegram 存储适配器 (使用用户 Session)""" """Telegram 存储适配器 (使用用户 Session)"""
native_video_thumbnail_only = True native_video_thumbnail_only = True
_message_cache_ttl = 300
_message_cache_limit = 200
_download_chunk_size = 512 * 1024
def __init__(self, record: StorageAdapter): def __init__(self, record: StorageAdapter):
self.record = record self.record = record
@@ -83,6 +88,10 @@ class TelegramAdapter:
if not all([self.api_id, self.api_hash, self.session_string, self.chat_id]): if not all([self.api_id, self.api_hash, self.session_string, self.chat_id]):
raise ValueError("Telegram 适配器需要 api_id, api_hash, session_string 和 chat_id") raise ValueError("Telegram 适配器需要 api_id, api_hash, session_string 和 chat_id")
self._client: TelegramClient | None = None
self._client_lock = asyncio.Lock()
self._message_cache: Dict[int, Tuple[float, object]] = {}
@staticmethod @staticmethod
def _parse_legacy_session_string(value: str) -> StringSession: def _parse_legacy_session_string(value: str) -> StringSession:
""" """
@@ -184,6 +193,67 @@ class TelegramAdapter:
"""创建一个新的 TelegramClient 实例""" """创建一个新的 TelegramClient 实例"""
return TelegramClient(self._build_session(), self.api_id, self.api_hash, proxy=self.proxy) return TelegramClient(self._build_session(), self.api_id, self.api_hash, proxy=self.proxy)
async def _get_connected_client(self) -> TelegramClient:
async with self._client_lock:
if self._client is None:
self._client = self._get_client()
if not self._client.is_connected():
await self._client.connect()
return self._client
async def _disconnect_shared_client(self):
if self._client and self._client.is_connected():
await self._client.disconnect()
def _clear_message_cache(self):
self._message_cache.clear()
async def _get_cached_message(self, message_id: int):
now = time.monotonic()
cached = self._message_cache.get(message_id)
if cached and cached[0] > now:
return cached[1]
client = await self._get_connected_client()
message = await client.get_messages(self.chat_id, ids=message_id)
if message:
if len(self._message_cache) >= self._message_cache_limit:
oldest_key = min(self._message_cache, key=lambda k: self._message_cache[k][0])
self._message_cache.pop(oldest_key, None)
self._message_cache[message_id] = (now + self._message_cache_ttl, message)
else:
self._message_cache.pop(message_id, None)
return message
@staticmethod
def _get_message_media(message):
return message.document or message.video or message.photo
@staticmethod
def _get_message_file_size(message, media) -> int:
file_meta = message.file
size = file_meta.size if file_meta and file_meta.size is not None else None
if size is None:
if hasattr(media, "size") and media.size is not None:
size = media.size
elif message.photo and getattr(message.photo, "sizes", None):
photo_size = message.photo.sizes[-1]
size = getattr(photo_size, "size", 0) or 0
else:
size = 0
return int(size or 0)
@staticmethod
def _get_message_mime_type(message, media) -> str:
file_meta = message.file
if file_meta and getattr(file_meta, "mime_type", None):
return file_meta.mime_type
if hasattr(media, "mime_type") and media.mime_type:
return media.mime_type
if message.photo:
return "image/jpeg"
return "application/octet-stream"
@staticmethod @staticmethod
def _parse_message_id(rel: str) -> int: def _parse_message_id(rel: str) -> int:
try: try:
@@ -274,62 +344,57 @@ class TelegramAdapter:
async def read_file(self, root: str, rel: str) -> bytes: async def read_file(self, root: str, rel: str) -> bytes:
message_id = self._parse_message_id(rel) message_id = self._parse_message_id(rel)
client = self._get_client() client = await self._get_connected_client()
try: message = await self._get_cached_message(message_id)
await client.connect() if not message or not self._get_message_media(message):
message = await client.get_messages(self.chat_id, ids=message_id) raise FileNotFoundError(f"在频道 {self.chat_id} 中未找到消息ID为 {message_id} 的文件")
if not message or not (message.document or message.video or message.photo):
raise FileNotFoundError(f"在频道 {self.chat_id} 中未找到消息ID为 {message_id} 的文件") file_bytes = await client.download_media(message, file=bytes)
return file_bytes
file_bytes = await client.download_media(message, file=bytes)
return file_bytes
finally:
if client.is_connected():
await client.disconnect()
async def read_file_range(self, root: str, rel: str, start: int, end: Optional[int] = None) -> bytes: async def read_file_range(self, root: str, rel: str, start: int, end: Optional[int] = None) -> bytes:
from fastapi import HTTPException from fastapi import HTTPException
message_id = self._parse_message_id(rel) message_id = self._parse_message_id(rel)
client = self._get_client() client = await self._get_connected_client()
try: message = await self._get_cached_message(message_id)
await client.connect() if not message:
message = await client.get_messages(self.chat_id, ids=message_id) raise FileNotFoundError(f"在频道 {self.chat_id} 中未找到消息ID为 {message_id} 的文件")
if not message:
raise FileNotFoundError(f"在频道 {self.chat_id} 中未找到消息ID为 {message_id} 的文件")
media = message.document or message.video or message.photo media = self._get_message_media(message)
if not media: if not media:
raise FileNotFoundError(f"在频道 {self.chat_id} 中未找到消息ID为 {message_id} 的文件") raise FileNotFoundError(f"在频道 {self.chat_id} 中未找到消息ID为 {message_id} 的文件")
file_meta = message.file file_size = self._get_message_file_size(message, media)
file_size = file_meta.size if file_meta and file_meta.size is not None else getattr(media, "size", 0) or 0 if file_size > 0:
if file_size > 0: if start >= file_size:
if start >= file_size:
raise HTTPException(status_code=416, detail="Requested Range Not Satisfiable")
if end is None or end >= file_size:
end = file_size - 1
elif end is None:
end = start
if end < start:
raise HTTPException(status_code=416, detail="Requested Range Not Satisfiable") raise HTTPException(status_code=416, detail="Requested Range Not Satisfiable")
if end is None or end >= file_size:
end = file_size - 1
elif end is None:
end = start
limit = end - start + 1 if end < start:
data = bytearray() raise HTTPException(status_code=416, detail="Requested Range Not Satisfiable")
async for chunk in client.iter_download(media, offset=start):
if not chunk: limit = end - start + 1
continue data = bytearray()
need = limit - len(data) async for chunk in client.iter_download(
if need <= 0: media,
break offset=start,
data.extend(chunk[:need]) request_size=self._download_chunk_size,
if len(data) >= limit: chunk_size=self._download_chunk_size,
break file_size=file_size or None,
return bytes(data) ):
finally: if not chunk:
if client.is_connected(): continue
await client.disconnect() need = limit - len(data)
if need <= 0:
break
data.extend(chunk[:need])
if len(data) >= limit:
break
return bytes(data)
async def write_file(self, root: str, rel: str, data: bytes): async def write_file(self, root: str, rel: str, data: bytes):
"""将字节数据作为文件上传""" """将字节数据作为文件上传"""
@@ -349,6 +414,7 @@ class TelegramAdapter:
stored_name = file_meta.name stored_name = file_meta.name
if getattr(message, "id", None) is not None: if getattr(message, "id", None) is not None:
actual_rel = f"{message.id}_{stored_name}" actual_rel = f"{message.id}_{stored_name}"
self._clear_message_cache()
return {"rel": actual_rel, "size": len(data)} return {"rel": actual_rel, "size": len(data)}
finally: finally:
if client.is_connected(): if client.is_connected():
@@ -378,6 +444,7 @@ class TelegramAdapter:
stored_name = file_meta.name stored_name = file_meta.name
if getattr(message, "id", None) is not None: if getattr(message, "id", None) is not None:
actual_rel = f"{message.id}_{stored_name}" actual_rel = f"{message.id}_{stored_name}"
self._clear_message_cache()
if file_meta and getattr(file_meta, "size", None): if file_meta and getattr(file_meta, "size", None):
size = int(file_meta.size) size = int(file_meta.size)
return {"rel": actual_rel, "size": size} return {"rel": actual_rel, "size": size}
@@ -413,6 +480,7 @@ class TelegramAdapter:
stored_name = file_meta.name stored_name = file_meta.name
if getattr(message, "id", None) is not None: if getattr(message, "id", None) is not None:
actual_rel = f"{message.id}_{stored_name}" actual_rel = f"{message.id}_{stored_name}"
self._clear_message_cache()
finally: finally:
if os.path.exists(temp_path): if os.path.exists(temp_path):
@@ -431,10 +499,9 @@ class TelegramAdapter:
except (ValueError, IndexError): except (ValueError, IndexError):
return None return None
client = self._get_client()
try: try:
await client.connect() client = await self._get_connected_client()
message = await client.get_messages(self.chat_id, ids=message_id) message = await self._get_cached_message(message_id)
if not message: if not message:
return None return None
@@ -454,9 +521,6 @@ class TelegramAdapter:
return None return None
except Exception: except Exception:
return None return None
finally:
if client.is_connected():
await client.disconnect()
async def delete(self, root: str, rel: str): async def delete(self, root: str, rel: str):
"""删除一个文件 (即一条消息)""" """删除一个文件 (即一条消息)"""
@@ -472,9 +536,12 @@ class TelegramAdapter:
result = await client.delete_messages(self.chat_id, [message_id]) result = await client.delete_messages(self.chat_id, [message_id])
if not result or not result[0].pts: if not result or not result[0].pts:
raise FileNotFoundError(f"{self.chat_id} 中删除消息 {message_id} 失败,可能消息不存在或无权限") raise FileNotFoundError(f"{self.chat_id} 中删除消息 {message_id} 失败,可能消息不存在或无权限")
self._message_cache.pop(message_id, None)
finally: finally:
if client.is_connected(): if client.is_connected():
await client.disconnect() await client.disconnect()
if self._client is client:
self._client = None
async def move(self, root: str, src_rel: str, dst_rel: str): async def move(self, root: str, src_rel: str, dst_rel: str):
raise NotImplementedError("Telegram 适配器不支持移动。") raise NotImplementedError("Telegram 适配器不支持移动。")
@@ -494,38 +561,17 @@ class TelegramAdapter:
except FileNotFoundError: except FileNotFoundError:
raise HTTPException(status_code=400, detail=f"无效的文件路径格式: {rel}") raise HTTPException(status_code=400, detail=f"无效的文件路径格式: {rel}")
client = self._get_client()
try: try:
await client.connect() client = await self._get_connected_client()
message = await client.get_messages(self.chat_id, ids=message_id) message = await self._get_cached_message(message_id)
if not message: if not message:
raise FileNotFoundError(f"在频道 {self.chat_id} 中未找到消息ID为 {message_id} 的文件") raise FileNotFoundError(f"在频道 {self.chat_id} 中未找到消息ID为 {message_id} 的文件")
media = message.document or message.video or message.photo media = self._get_message_media(message)
if not media: if not media:
raise FileNotFoundError(f"在频道 {self.chat_id} 中未找到消息ID为 {message_id} 的文件") raise FileNotFoundError(f"在频道 {self.chat_id} 中未找到消息ID为 {message_id} 的文件")
file_meta = message.file file_size = self._get_message_file_size(message, media)
file_size = file_meta.size if file_meta and file_meta.size is not None else None mime_type = self._get_message_mime_type(message, media)
if file_size is None:
if hasattr(media, "size") and media.size is not None:
file_size = media.size
elif message.photo and getattr(message.photo, "sizes", None):
photo_size = message.photo.sizes[-1]
file_size = getattr(photo_size, "size", 0) or 0
else:
file_size = 0
mime_type = None
if file_meta and getattr(file_meta, "mime_type", None):
mime_type = file_meta.mime_type
if not mime_type:
if hasattr(media, "mime_type") and media.mime_type:
mime_type = media.mime_type
elif message.photo:
mime_type = "image/jpeg"
else:
mime_type = "application/octet-stream"
start = 0 start = 0
end = file_size - 1 end = file_size - 1
@@ -538,8 +584,6 @@ class TelegramAdapter:
if file_size <= 0: if file_size <= 0:
headers["Content-Length"] = "0" headers["Content-Length"] = "0"
if client.is_connected():
await client.disconnect()
return StreamingResponse(iter(()), status_code=status, headers=headers) return StreamingResponse(iter(()), status_code=status, headers=headers)
if range_header: if range_header:
@@ -562,7 +606,13 @@ class TelegramAdapter:
limit = end - start + 1 limit = end - start + 1
downloaded = 0 downloaded = 0
async for chunk in client.iter_download(media, offset=start): async for chunk in client.iter_download(
media,
offset=start,
request_size=self._download_chunk_size,
chunk_size=self._download_chunk_size,
file_size=file_size,
):
if downloaded + len(chunk) > limit: if downloaded + len(chunk) > limit:
yield chunk[:limit - downloaded] yield chunk[:limit - downloaded]
break break
@@ -570,23 +620,18 @@ class TelegramAdapter:
downloaded += len(chunk) downloaded += len(chunk)
if downloaded >= limit: if downloaded >= limit:
break break
finally: except Exception:
if client.is_connected(): await self._disconnect_shared_client()
await client.disconnect() raise
return StreamingResponse(iterator(), status_code=status, headers=headers) return StreamingResponse(iterator(), status_code=status, headers=headers)
except HTTPException: except HTTPException:
if client.is_connected():
await client.disconnect()
raise raise
except FileNotFoundError as e: except FileNotFoundError as e:
if client.is_connected():
await client.disconnect()
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
except Exception as e: except Exception as e:
if client.is_connected(): await self._disconnect_shared_client()
await client.disconnect()
raise HTTPException(status_code=500, detail=f"Streaming failed: {str(e)}") raise HTTPException(status_code=500, detail=f"Streaming failed: {str(e)}")
async def stat_file(self, root: str, rel: str): async def stat_file(self, root: str, rel: str):
@@ -596,36 +641,21 @@ class TelegramAdapter:
except (ValueError, IndexError): except (ValueError, IndexError):
raise FileNotFoundError(f"无效的文件路径格式: {rel}") raise FileNotFoundError(f"无效的文件路径格式: {rel}")
client = self._get_client() message = await self._get_cached_message(message_id)
try: media = self._get_message_media(message) if message else None
await client.connect() if not message or not media:
message = await client.get_messages(self.chat_id, ids=message_id) raise FileNotFoundError(f"在频道 {self.chat_id} 中未找到消息ID为 {message_id} 的文件")
media = message.document or message.video or message.photo
if not message or not media:
raise FileNotFoundError(f"在频道 {self.chat_id} 中未找到消息ID为 {message_id} 的文件")
file_meta = message.file size = self._get_message_file_size(message, media)
size = file_meta.size if file_meta and file_meta.size is not None else None
if size is None:
if hasattr(media, "size") and media.size is not None:
size = media.size
elif message.photo and getattr(message.photo, "sizes", None):
photo_size = message.photo.sizes[-1]
size = getattr(photo_size, "size", 0) or 0
else:
size = 0
return { return {
"name": rel, "name": rel,
"is_dir": False, "is_dir": False,
"size": size, "size": size,
"mtime": int(message.date.timestamp()), "mtime": int(message.date.timestamp()),
"type": "file", "type": "file",
"has_thumbnail": self._message_has_thumbnail(message), "has_thumbnail": self._message_has_thumbnail(message),
} }
finally:
if client.is_connected():
await client.disconnect()
def ADAPTER_FACTORY(rec: StorageAdapter) -> TelegramAdapter: def ADAPTER_FACTORY(rec: StorageAdapter) -> TelegramAdapter:
return TelegramAdapter(rec) return TelegramAdapter(rec)