mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-09-04 23:29:00 +08:00
feat: enhance TelegramAdapter with message caching and connection management
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user