|
6 | 6 | import struct |
7 | 7 | import time |
8 | 8 | from models import StorageAdapter |
9 | | -from telethon import TelegramClient, utils |
| 9 | +from telethon import TelegramClient, errors, utils |
10 | 10 | from telethon.crypto import AuthKey |
11 | 11 | from telethon.sessions import StringSession |
12 | 12 | from telethon.tl import types |
@@ -90,6 +90,7 @@ def __init__(self, record: StorageAdapter): |
90 | 90 |
|
91 | 91 | self._client: TelegramClient | None = None |
92 | 92 | self._client_lock = asyncio.Lock() |
| 93 | + self._download_lock = asyncio.Lock() |
93 | 94 | self._message_cache: Dict[int, Tuple[float, object]] = {} |
94 | 95 |
|
95 | 96 | @staticmethod |
@@ -229,6 +230,19 @@ async def _get_cached_message(self, message_id: int): |
229 | 230 | def _get_message_media(message): |
230 | 231 | return message.document or message.video or message.photo |
231 | 232 |
|
| 233 | + @staticmethod |
| 234 | + def _flood_wait_http_exception(exc: errors.FloodWaitError): |
| 235 | + from fastapi import HTTPException |
| 236 | + |
| 237 | + seconds = int(getattr(exc, "seconds", 0) or 0) |
| 238 | + if seconds > 0: |
| 239 | + return HTTPException( |
| 240 | + status_code=429, |
| 241 | + detail=f"Telegram 请求过于频繁,请等待 {seconds} 秒后重试", |
| 242 | + headers={"Retry-After": str(seconds)}, |
| 243 | + ) |
| 244 | + return HTTPException(status_code=429, detail="Telegram 请求过于频繁,请稍后重试") |
| 245 | + |
232 | 246 | @staticmethod |
233 | 247 | def _get_message_file_size(message, media) -> int: |
234 | 248 | file_meta = message.file |
@@ -310,7 +324,7 @@ async def list_dir(self, root: str, rel: str, page_num: int = 1, page_size: int |
310 | 324 | "size": size, |
311 | 325 | "mtime": int(message.date.timestamp()), |
312 | 326 | "type": "file", |
313 | | - "has_thumbnail": self._message_has_thumbnail(message), |
| 327 | + "has_thumbnail": False, |
314 | 328 | }) |
315 | 329 | finally: |
316 | 330 | if client.is_connected(): |
@@ -349,8 +363,13 @@ async def read_file(self, root: str, rel: str) -> bytes: |
349 | 363 | if not message or not self._get_message_media(message): |
350 | 364 | raise FileNotFoundError(f"在频道 {self.chat_id} 中未找到消息ID为 {message_id} 的文件") |
351 | 365 |
|
352 | | - file_bytes = await client.download_media(message, file=bytes) |
353 | | - return file_bytes |
| 366 | + try: |
| 367 | + async with self._download_lock: |
| 368 | + file_bytes = await client.download_media(message, file=bytes) |
| 369 | + return file_bytes |
| 370 | + except errors.FloodWaitError as exc: |
| 371 | + await self._disconnect_shared_client() |
| 372 | + raise self._flood_wait_http_exception(exc) |
354 | 373 |
|
355 | 374 | async def read_file_range(self, root: str, rel: str, start: int, end: Optional[int] = None) -> bytes: |
356 | 375 | from fastapi import HTTPException |
@@ -379,22 +398,27 @@ async def read_file_range(self, root: str, rel: str, start: int, end: Optional[i |
379 | 398 |
|
380 | 399 | limit = end - start + 1 |
381 | 400 | data = bytearray() |
382 | | - async for chunk in client.iter_download( |
383 | | - media, |
384 | | - offset=start, |
385 | | - request_size=self._download_chunk_size, |
386 | | - chunk_size=self._download_chunk_size, |
387 | | - file_size=file_size or None, |
388 | | - ): |
389 | | - if not chunk: |
390 | | - continue |
391 | | - need = limit - len(data) |
392 | | - if need <= 0: |
393 | | - break |
394 | | - data.extend(chunk[:need]) |
395 | | - if len(data) >= limit: |
396 | | - break |
397 | | - return bytes(data) |
| 401 | + try: |
| 402 | + async with self._download_lock: |
| 403 | + async for chunk in client.iter_download( |
| 404 | + media, |
| 405 | + offset=start, |
| 406 | + request_size=self._download_chunk_size, |
| 407 | + chunk_size=self._download_chunk_size, |
| 408 | + file_size=file_size or None, |
| 409 | + ): |
| 410 | + if not chunk: |
| 411 | + continue |
| 412 | + need = limit - len(data) |
| 413 | + if need <= 0: |
| 414 | + break |
| 415 | + data.extend(chunk[:need]) |
| 416 | + if len(data) >= limit: |
| 417 | + break |
| 418 | + return bytes(data) |
| 419 | + except errors.FloodWaitError as exc: |
| 420 | + await self._disconnect_shared_client() |
| 421 | + raise self._flood_wait_http_exception(exc) |
398 | 422 |
|
399 | 423 | async def write_file(self, root: str, rel: str, data: bytes): |
400 | 424 | """将字节数据作为文件上传""" |
@@ -515,7 +539,8 @@ async def get_thumbnail(self, root: str, rel: str, size: str = "medium"): |
515 | 539 | if embedded and isinstance(thumb, types.PhotoStrippedSize): |
516 | 540 | return utils.stripped_photo_to_jpg(bytes(embedded)) |
517 | 541 |
|
518 | | - result = await client.download_media(message, bytes, thumb=thumb) |
| 542 | + async with self._download_lock: |
| 543 | + result = await client.download_media(message, bytes, thumb=thumb) |
519 | 544 | if isinstance(result, (bytes, bytearray)): |
520 | 545 | return bytes(result) |
521 | 546 | return None |
@@ -602,29 +627,56 @@ async def stream_file(self, root: str, rel: str, range_header: str | None): |
602 | 627 | headers["Content-Length"] = str(end - start + 1) |
603 | 628 |
|
604 | 629 | async def iterator(): |
| 630 | + downloaded = 0 |
605 | 631 | try: |
606 | 632 | limit = end - start + 1 |
607 | | - downloaded = 0 |
608 | | - |
609 | | - async for chunk in client.iter_download( |
610 | | - media, |
611 | | - offset=start, |
612 | | - request_size=self._download_chunk_size, |
613 | | - chunk_size=self._download_chunk_size, |
614 | | - file_size=file_size, |
615 | | - ): |
616 | | - if downloaded + len(chunk) > limit: |
617 | | - yield chunk[:limit - downloaded] |
618 | | - break |
619 | | - yield chunk |
620 | | - downloaded += len(chunk) |
621 | | - if downloaded >= limit: |
622 | | - break |
| 633 | + async with self._download_lock: |
| 634 | + async for chunk in client.iter_download( |
| 635 | + media, |
| 636 | + offset=start, |
| 637 | + request_size=self._download_chunk_size, |
| 638 | + chunk_size=self._download_chunk_size, |
| 639 | + file_size=file_size, |
| 640 | + ): |
| 641 | + if not chunk: |
| 642 | + continue |
| 643 | + remaining = limit - downloaded |
| 644 | + if remaining <= 0: |
| 645 | + break |
| 646 | + data = chunk[:remaining] |
| 647 | + downloaded += len(data) |
| 648 | + yield data |
| 649 | + if downloaded >= limit: |
| 650 | + break |
| 651 | + except errors.FloodWaitError as exc: |
| 652 | + await self._disconnect_shared_client() |
| 653 | + if downloaded == 0: |
| 654 | + raise self._flood_wait_http_exception(exc) |
| 655 | + seconds = int(getattr(exc, "seconds", 0) or 0) |
| 656 | + print(f"Telegram streaming stopped by FloodWait after partial response, wait={seconds}s") |
| 657 | + return |
623 | 658 | except Exception: |
624 | 659 | await self._disconnect_shared_client() |
625 | 660 | raise |
626 | 661 |
|
627 | | - return StreamingResponse(iterator(), status_code=status, headers=headers) |
| 662 | + agen = iterator() |
| 663 | + try: |
| 664 | + first_chunk = await agen.__anext__() |
| 665 | + except StopAsyncIteration: |
| 666 | + first_chunk = b"" |
| 667 | + except HTTPException: |
| 668 | + raise |
| 669 | + |
| 670 | + async def response_iterator(): |
| 671 | + try: |
| 672 | + if first_chunk: |
| 673 | + yield first_chunk |
| 674 | + async for chunk in agen: |
| 675 | + yield chunk |
| 676 | + finally: |
| 677 | + await agen.aclose() |
| 678 | + |
| 679 | + return StreamingResponse(response_iterator(), status_code=status, headers=headers) |
628 | 680 |
|
629 | 681 | except HTTPException: |
630 | 682 | raise |
@@ -654,7 +706,7 @@ async def stat_file(self, root: str, rel: str): |
654 | 706 | "size": size, |
655 | 707 | "mtime": int(message.date.timestamp()), |
656 | 708 | "type": "file", |
657 | | - "has_thumbnail": self._message_has_thumbnail(message), |
| 709 | + "has_thumbnail": False, |
658 | 710 | } |
659 | 711 |
|
660 | 712 | def ADAPTER_FACTORY(rec: StorageAdapter) -> TelegramAdapter: |
|
0 commit comments