Spaces:
Running
Running
github-actions[bot] commited on
Commit ·
529b973
1
Parent(s): a00abaf
Automated sync from GitHub
Browse files- .gitattributes +3 -3
- bot/app.py +8 -12
- bot/config.py +2 -0
- bot/db.py +7 -4
- bot/fasttransfer.py +136 -1
- bot/formats.py +12 -7
- bot/main.py +2 -2
- bot/panels.py +23 -6
- bot/pipeline.py +181 -33
- bot/sysinfo.py +24 -9
- tests/test_fasttransfer.py +216 -0
- tests/test_formats.py +78 -4
- tests/test_formats_app.py +43 -4
- tests/test_panels.py +13 -1
- tests/test_pipeline.py +37 -1
- tests/test_share_app.py +17 -2
- tests/test_stream_rename.py +442 -0
- tests/test_sysinfo.py +23 -0
.gitattributes
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
| 1 |
*.mp4 filter=lfs diff=lfs merge=lfs -text
|
| 2 |
*.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 3 |
*.jpg filter=lfs diff=lfs merge=lfs -text
|
| 4 |
*.jpeg filter=lfs diff=lfs merge=lfs -text
|
| 5 |
*.png filter=lfs diff=lfs merge=lfs -text
|
| 6 |
-
*.ttf filter=lfs diff=lfs merge=lfs -text
|
| 7 |
-
*.otf filter=lfs diff=lfs merge=lfs -text
|
| 8 |
-
*.webp filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 1 |
+
*.ttf filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.otf filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.webp filter=lfs diff=lfs merge=lfs -text
|
| 4 |
*.mp4 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
*.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 6 |
*.jpg filter=lfs diff=lfs merge=lfs -text
|
| 7 |
*.jpeg filter=lfs diff=lfs merge=lfs -text
|
| 8 |
*.png filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
bot/app.py
CHANGED
|
@@ -27,7 +27,7 @@ from .db import MAX_FORMATS, Database
|
|
| 27 |
from .errors import FormatError
|
| 28 |
from .formats import FIELD_KINDS, NAME_MAX, FormatSettings, validate_name
|
| 29 |
from .parser import compute_new_name, missing_variables, parse_caption, parse_info, split_ext
|
| 30 |
-
from .pipeline import PROBE_EXTS, Renamer, original_name_of, source_ext
|
| 31 |
from .queue import Job, JobQueue
|
| 32 |
from .sequence import SeqItem, SequenceState, analyse, format_report
|
| 33 |
from .settings_ops import META_COMMANDS, apply_setting, parse_channel, preview_template
|
|
@@ -1117,15 +1117,7 @@ class App:
|
|
| 1117 |
await self._reply(event, text + (f"\n\n{mode.dropped}" if mode.dropped else ""), rows)
|
| 1118 |
|
| 1119 |
async def _embedded_thumb(self, msg: Any, allow_stripped: bool = False) -> Optional[bytes]:
|
| 1120 |
-
|
| 1121 |
-
thumbs = getattr(getattr(msg, "document", None), "thumbs", None) or []
|
| 1122 |
-
if not any(not isinstance(t, types.PhotoStrippedSize) for t in thumbs) and not (allow_stripped and thumbs):
|
| 1123 |
-
return None
|
| 1124 |
-
try:
|
| 1125 |
-
return await self.client.download_media(msg, file=bytes, thumb=-1) or None
|
| 1126 |
-
except Exception as exc: # noqa: BLE001
|
| 1127 |
-
log.debug("could not download the embedded thumbnail: %s", exc)
|
| 1128 |
-
return None
|
| 1129 |
|
| 1130 |
async def _extract_thumbnail(self, event: Any, msg: Any) -> Optional[bytes]:
|
| 1131 |
data = await self._embedded_thumb(msg)
|
|
@@ -1455,7 +1447,7 @@ class App:
|
|
| 1455 |
)
|
| 1456 |
|
| 1457 |
def _speed_text(self) -> str:
|
| 1458 |
-
return sysinfo.format_speed(self.cfg.fast_transfer, self.cfg.connections, transfer.stats)
|
| 1459 |
|
| 1460 |
async def cmd_stats(self, event: Any) -> None:
|
| 1461 |
await self._reply(event, await self._stats_text())
|
|
@@ -1733,6 +1725,10 @@ class App:
|
|
| 1733 |
await self.db.set_format_thumb(uid, fid, None)
|
| 1734 |
await self._show_format(event, uid, fid)
|
| 1735 |
await event.answer("Thumbnail deleted.")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1736 |
elif action == "sh": # Share format, shown while inline mode wasn't known to be on: just try the real button
|
| 1737 |
text, rows = panels.format_view(fmt, share_inline=True)
|
| 1738 |
try:
|
|
@@ -1780,7 +1776,7 @@ class App:
|
|
| 1780 |
ok, value = validate_name(text)
|
| 1781 |
if ok and field == "new":
|
| 1782 |
fmt = await self.db.create_format(uid, value, template=(await self.db.get_settings(uid))["template"])
|
| 1783 |
-
msg = f"✅ Format <b>{esc(value)}</b> created. Now set its template, thumbnail and metadata."
|
| 1784 |
p.fid = fmt["id"]
|
| 1785 |
elif ok:
|
| 1786 |
if await self.db.update_format(uid, p.fid, name=value) is None:
|
|
|
|
| 27 |
from .errors import FormatError
|
| 28 |
from .formats import FIELD_KINDS, NAME_MAX, FormatSettings, validate_name
|
| 29 |
from .parser import compute_new_name, missing_variables, parse_caption, parse_info, split_ext
|
| 30 |
+
from .pipeline import PROBE_EXTS, Renamer, embedded_thumb, original_name_of, source_ext
|
| 31 |
from .queue import Job, JobQueue
|
| 32 |
from .sequence import SeqItem, SequenceState, analyse, format_report
|
| 33 |
from .settings_ops import META_COMMANDS, apply_setting, parse_channel, preview_template
|
|
|
|
| 1117 |
await self._reply(event, text + (f"\n\n{mode.dropped}" if mode.dropped else ""), rows)
|
| 1118 |
|
| 1119 |
async def _embedded_thumb(self, msg: Any, allow_stripped: bool = False) -> Optional[bytes]:
|
| 1120 |
+
return await embedded_thumb(self.client, msg, allow_stripped)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1121 |
|
| 1122 |
async def _extract_thumbnail(self, event: Any, msg: Any) -> Optional[bytes]:
|
| 1123 |
data = await self._embedded_thumb(msg)
|
|
|
|
| 1447 |
)
|
| 1448 |
|
| 1449 |
def _speed_text(self) -> str:
|
| 1450 |
+
return sysinfo.format_speed(self.cfg.fast_transfer, self.cfg.connections, transfer.stats, self.cfg.stream_transfer)
|
| 1451 |
|
| 1452 |
async def cmd_stats(self, event: Any) -> None:
|
| 1453 |
await self._reply(event, await self._stats_text())
|
|
|
|
| 1725 |
await self.db.set_format_thumb(uid, fid, None)
|
| 1726 |
await self._show_format(event, uid, fid)
|
| 1727 |
await event.answer("Thumbnail deleted.")
|
| 1728 |
+
elif action == "cr":
|
| 1729 |
+
await apply_setting(FormatSettings(self.db, fid), uid, "caption", "reset")
|
| 1730 |
+
await self._show_format(event, uid, fid)
|
| 1731 |
+
await event.answer("Caption reset.")
|
| 1732 |
elif action == "sh": # Share format, shown while inline mode wasn't known to be on: just try the real button
|
| 1733 |
text, rows = panels.format_view(fmt, share_inline=True)
|
| 1734 |
try:
|
|
|
|
| 1776 |
ok, value = validate_name(text)
|
| 1777 |
if ok and field == "new":
|
| 1778 |
fmt = await self.db.create_format(uid, value, template=(await self.db.get_settings(uid))["template"])
|
| 1779 |
+
msg = f"✅ Format <b>{esc(value)}</b> created. Now set its template, caption, thumbnail and metadata."
|
| 1780 |
p.fid = fmt["id"]
|
| 1781 |
elif ok:
|
| 1782 |
if await self.db.update_format(uid, p.fid, name=value) is None:
|
bot/config.py
CHANGED
|
@@ -57,6 +57,7 @@ class Config:
|
|
| 57 |
max_file_mb: int
|
| 58 |
fast_transfer: bool
|
| 59 |
progress_interval: float
|
|
|
|
| 60 |
|
| 61 |
@property
|
| 62 |
def max_file_bytes(self) -> int:
|
|
@@ -97,4 +98,5 @@ def load_config(env: Optional[Mapping[str, str]] = None) -> Config:
|
|
| 97 |
max_file_mb=max(1, _get_int(env, "MAX_FILE_MB", 2000)),
|
| 98 |
fast_transfer=_get_bool(env, "FAST_TRANSFER", True),
|
| 99 |
progress_interval=max(2.0, float(env.get("PROGRESS_INTERVAL", "4") or 4)),
|
|
|
|
| 100 |
)
|
|
|
|
| 57 |
max_file_mb: int
|
| 58 |
fast_transfer: bool
|
| 59 |
progress_interval: float
|
| 60 |
+
stream_transfer: bool = True # big files go straight from the download into the upload instead of through the disk
|
| 61 |
|
| 62 |
@property
|
| 63 |
def max_file_bytes(self) -> int:
|
|
|
|
| 98 |
max_file_mb=max(1, _get_int(env, "MAX_FILE_MB", 2000)),
|
| 99 |
fast_transfer=_get_bool(env, "FAST_TRANSFER", True),
|
| 100 |
progress_interval=max(2.0, float(env.get("PROGRESS_INTERVAL", "4") or 4)),
|
| 101 |
+
stream_transfer=_get_bool(env, "STREAM_RENAME", True),
|
| 102 |
)
|
bot/db.py
CHANGED
|
@@ -41,9 +41,11 @@ OLD_DEFAULT_CAPTION = "{filename}"
|
|
| 41 |
BOLD_CAPTION_MIGRATION = "migrated:bold_caption"
|
| 42 |
|
| 43 |
MAX_FORMATS = 12
|
| 44 |
-
# what a saved /rename format holds; everything else (mode,
|
|
|
|
| 45 |
DEFAULT_FORMAT: Dict[str, Any] = {
|
| 46 |
"template": DEFAULT_TEMPLATE,
|
|
|
|
| 47 |
"meta": {"title": "", "author": "", "artist": "", "audio": "", "subtitle": "", "video": ""},
|
| 48 |
"keep_audio": [],
|
| 49 |
"keep_subtitle": [],
|
|
@@ -142,8 +144,9 @@ def _merge_settings(stored: Dict[str, Any]) -> Dict[str, Any]:
|
|
| 142 |
|
| 143 |
def _merge_format(stored: Dict[str, Any]) -> Dict[str, Any]:
|
| 144 |
merged = copy.deepcopy(DEFAULT_FORMAT)
|
| 145 |
-
|
| 146 |
-
|
|
|
|
| 147 |
if isinstance(stored.get("meta"), dict):
|
| 148 |
merged["meta"].update({k: str(v) for k, v in stored["meta"].items() if k in merged["meta"]})
|
| 149 |
for key in ("keep_audio", "keep_subtitle"):
|
|
@@ -327,7 +330,7 @@ class Database:
|
|
| 327 |
return _format_dict(fid, name, json.dumps(data, ensure_ascii=False), False)
|
| 328 |
|
| 329 |
async def update_format(self, uid: int, fid: int, **changes: Any) -> Optional[Dict[str, Any]]:
|
| 330 |
-
"""Change ``name``, ``template``, ``keep_audio``, ``keep_subtitle`` or merge a ``meta`` dict; ``None`` if it is gone."""
|
| 331 |
async with self._sessions() as session:
|
| 332 |
row = await session.get(Format, fid)
|
| 333 |
if row is None or row.user_id != uid:
|
|
|
|
| 41 |
BOLD_CAPTION_MIGRATION = "migrated:bold_caption"
|
| 42 |
|
| 43 |
MAX_FORMATS = 12
|
| 44 |
+
# what a saved /rename format holds; everything else (mode, file type, ...) comes from the user's settings. A format never
|
| 45 |
+
# borrows the user's own caption: without a caption of its own it sends the new file name in bold.
|
| 46 |
DEFAULT_FORMAT: Dict[str, Any] = {
|
| 47 |
"template": DEFAULT_TEMPLATE,
|
| 48 |
+
"caption": DEFAULT_SETTINGS["caption"],
|
| 49 |
"meta": {"title": "", "author": "", "artist": "", "audio": "", "subtitle": "", "video": ""},
|
| 50 |
"keep_audio": [],
|
| 51 |
"keep_subtitle": [],
|
|
|
|
| 144 |
|
| 145 |
def _merge_format(stored: Dict[str, Any]) -> Dict[str, Any]:
|
| 146 |
merged = copy.deepcopy(DEFAULT_FORMAT)
|
| 147 |
+
for key in ("template", "caption"):
|
| 148 |
+
if isinstance(stored.get(key), str) and stored[key].strip():
|
| 149 |
+
merged[key] = stored[key]
|
| 150 |
if isinstance(stored.get("meta"), dict):
|
| 151 |
merged["meta"].update({k: str(v) for k, v in stored["meta"].items() if k in merged["meta"]})
|
| 152 |
for key in ("keep_audio", "keep_subtitle"):
|
|
|
|
| 330 |
return _format_dict(fid, name, json.dumps(data, ensure_ascii=False), False)
|
| 331 |
|
| 332 |
async def update_format(self, uid: int, fid: int, **changes: Any) -> Optional[Dict[str, Any]]:
|
| 333 |
+
"""Change ``name``, ``template``, ``caption``, ``keep_audio``, ``keep_subtitle`` or merge a ``meta`` dict; ``None`` if it is gone."""
|
| 334 |
async with self._sessions() as session:
|
| 335 |
row = await session.get(Format, fid)
|
| 336 |
if row is None or row.user_id != uid:
|
bot/fasttransfer.py
CHANGED
|
@@ -17,7 +17,7 @@ import os
|
|
| 17 |
import threading
|
| 18 |
from dataclasses import dataclass
|
| 19 |
from pathlib import Path
|
| 20 |
-
from typing import Any, Awaitable, Callable, List, Optional, Protocol, Sequence
|
| 21 |
|
| 22 |
from .errors import Cancelled
|
| 23 |
|
|
@@ -26,6 +26,7 @@ log = logging.getLogger(__name__)
|
|
| 26 |
DOWNLOAD_PART = 1024 * 1024 # upload.getFile allows up to 1 MiB per request
|
| 27 |
UPLOAD_PART = 512 * 1024 # upload.saveBigFilePart parts are at most 512 KiB
|
| 28 |
BIG_FILE_THRESHOLD = 10 * 1024 * 1024 # Telegram wants saveBigFilePart above 10 MB
|
|
|
|
| 29 |
PART_TIMEOUT = 120.0
|
| 30 |
MAX_RETRIES = 5
|
| 31 |
RETRY_BASE_DELAY = 1.0
|
|
@@ -52,11 +53,18 @@ class TransferStats:
|
|
| 52 |
standard_up: int = 0 # uploads by Telethon itself: parallel transfer is off, or it failed
|
| 53 |
fallbacks: int = 0 # how often the parallel path failed and the standard one took over
|
| 54 |
last_fallback: str = "" # what failed last, e.g. "download: RuntimeError: ..."
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
def fell_back(self, what: str, exc: BaseException) -> None:
|
| 57 |
self.fallbacks += 1
|
| 58 |
self.last_fallback = f"{what}: {type(exc).__name__}: {exc}"
|
| 59 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
|
| 61 |
stats = TransferStats()
|
| 62 |
|
|
@@ -243,6 +251,83 @@ async def upload_parts(
|
|
| 243 |
src.close()
|
| 244 |
|
| 245 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
async def close_all(transports: Sequence[Transport]) -> None:
|
| 247 |
await asyncio.gather(*(t.close() for t in transports), return_exceptions=True)
|
| 248 |
|
|
@@ -374,6 +459,56 @@ async def fast_upload(
|
|
| 374 |
return InputFileBig(id=file_id, parts=parts, name=file_name)
|
| 375 |
|
| 376 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 377 |
# ---------------------------------------------------------------------------
|
| 378 |
# public wrappers with fallback
|
| 379 |
# ---------------------------------------------------------------------------
|
|
|
|
| 17 |
import threading
|
| 18 |
from dataclasses import dataclass
|
| 19 |
from pathlib import Path
|
| 20 |
+
from typing import Any, Awaitable, Callable, List, Optional, Protocol, Sequence, Tuple
|
| 21 |
|
| 22 |
from .errors import Cancelled
|
| 23 |
|
|
|
|
| 26 |
DOWNLOAD_PART = 1024 * 1024 # upload.getFile allows up to 1 MiB per request
|
| 27 |
UPLOAD_PART = 512 * 1024 # upload.saveBigFilePart parts are at most 512 KiB
|
| 28 |
BIG_FILE_THRESHOLD = 10 * 1024 * 1024 # Telegram wants saveBigFilePart above 10 MB
|
| 29 |
+
RELAY_BUFFER_MIN = 4 # downloaded parts a streamed rename may hold back at least (2 per upload connection otherwise)
|
| 30 |
PART_TIMEOUT = 120.0
|
| 31 |
MAX_RETRIES = 5
|
| 32 |
RETRY_BASE_DELAY = 1.0
|
|
|
|
| 53 |
standard_up: int = 0 # uploads by Telethon itself: parallel transfer is off, or it failed
|
| 54 |
fallbacks: int = 0 # how often the parallel path failed and the standard one took over
|
| 55 |
last_fallback: str = "" # what failed last, e.g. "download: RuntimeError: ..."
|
| 56 |
+
streamed: int = 0 # renames that went straight from the download into the upload, without touching the disk
|
| 57 |
+
stream_fallbacks: int = 0 # streamed renames that failed, so the file was saved to disk first
|
| 58 |
+
last_stream_fallback: str = "" # what failed last, e.g. "RuntimeError: ..."
|
| 59 |
|
| 60 |
def fell_back(self, what: str, exc: BaseException) -> None:
|
| 61 |
self.fallbacks += 1
|
| 62 |
self.last_fallback = f"{what}: {type(exc).__name__}: {exc}"
|
| 63 |
|
| 64 |
+
def stream_failed(self, exc: BaseException) -> None:
|
| 65 |
+
self.stream_fallbacks += 1
|
| 66 |
+
self.last_stream_fallback = f"{type(exc).__name__}: {exc}"
|
| 67 |
+
|
| 68 |
|
| 69 |
stats = TransferStats()
|
| 70 |
|
|
|
|
| 251 |
src.close()
|
| 252 |
|
| 253 |
|
| 254 |
+
async def relay_parts(
|
| 255 |
+
down: Sequence[Transport],
|
| 256 |
+
up: Sequence[Transport],
|
| 257 |
+
location: Any,
|
| 258 |
+
size: int,
|
| 259 |
+
file_id: int,
|
| 260 |
+
*,
|
| 261 |
+
part_size: int = DOWNLOAD_PART,
|
| 262 |
+
send_size: int = UPLOAD_PART,
|
| 263 |
+
progress: Optional[ProgressCb] = None,
|
| 264 |
+
cancel: Optional[asyncio.Event] = None,
|
| 265 |
+
) -> int:
|
| 266 |
+
"""Copy ``size`` bytes of ``location`` into an upload without saving them anywhere; returns the number of upload parts.
|
| 267 |
+
|
| 268 |
+
The ``down`` connections fetch ``part_size`` pieces and hand them to the ``up`` connections through a small
|
| 269 |
+
buffer, which cut each piece into ``send_size`` parts for ``saveBigFilePart``. Parts can be uploaded in any
|
| 270 |
+
order, so the two sides only wait for each other when the buffer is full or empty: the file takes about as long as
|
| 271 |
+
the slower direction, not as long as both together. Progress counts what has been uploaded.
|
| 272 |
+
"""
|
| 273 |
+
if size <= 0:
|
| 274 |
+
raise ValueError("there is nothing to copy")
|
| 275 |
+
if part_size % send_size:
|
| 276 |
+
raise ValueError("a download part must hold a whole number of upload parts")
|
| 277 |
+
if not down or not up:
|
| 278 |
+
raise ValueError("a streamed copy needs connections both ways")
|
| 279 |
+
n_fetch = math.ceil(size / part_size)
|
| 280 |
+
n_parts = math.ceil(size / send_size)
|
| 281 |
+
per_piece = part_size // send_size
|
| 282 |
+
buffer: asyncio.Queue = asyncio.Queue(maxsize=max(RELAY_BUFFER_MIN, 2 * len(up)))
|
| 283 |
+
fetch_counter, take_counter = itertools.count(), itertools.count()
|
| 284 |
+
sent = 0
|
| 285 |
+
|
| 286 |
+
async def fetch_worker(transport: Transport) -> None:
|
| 287 |
+
while True:
|
| 288 |
+
if cancel is not None and cancel.is_set():
|
| 289 |
+
raise Cancelled()
|
| 290 |
+
idx = next(fetch_counter)
|
| 291 |
+
if idx >= n_fetch:
|
| 292 |
+
return
|
| 293 |
+
offset = idx * part_size
|
| 294 |
+
want = min(part_size, size - offset)
|
| 295 |
+
|
| 296 |
+
async def fetch() -> bytes:
|
| 297 |
+
data = await transport.get_file(location, offset, part_size)
|
| 298 |
+
if len(data) < want:
|
| 299 |
+
raise IOError(f"short read: got {len(data)} of {want} bytes at offset {offset}")
|
| 300 |
+
return data
|
| 301 |
+
|
| 302 |
+
data = await _retry(fetch, cancel=cancel, what=f"relay download part {idx + 1}/{n_fetch}")
|
| 303 |
+
await buffer.put((idx, data[:want]))
|
| 304 |
+
|
| 305 |
+
async def send_worker(transport: Transport) -> None:
|
| 306 |
+
nonlocal sent
|
| 307 |
+
while True:
|
| 308 |
+
if cancel is not None and cancel.is_set():
|
| 309 |
+
raise Cancelled()
|
| 310 |
+
if next(take_counter) >= n_fetch: # every downloaded part already has a sender
|
| 311 |
+
return
|
| 312 |
+
idx, data = await buffer.get()
|
| 313 |
+
for start in range(0, len(data), send_size):
|
| 314 |
+
part = idx * per_piece + start // send_size
|
| 315 |
+
piece = data[start:start + send_size]
|
| 316 |
+
await _retry(
|
| 317 |
+
lambda p=piece, i=part: transport.save_part(file_id, i, n_parts, p),
|
| 318 |
+
cancel=cancel,
|
| 319 |
+
what=f"relay upload part {part + 1}/{n_parts}",
|
| 320 |
+
)
|
| 321 |
+
sent += len(piece)
|
| 322 |
+
if progress is not None:
|
| 323 |
+
progress(sent, size)
|
| 324 |
+
|
| 325 |
+
await _run_workers([lambda t=t: fetch_worker(t) for t in down] + [lambda t=t: send_worker(t) for t in up])
|
| 326 |
+
if sent != size:
|
| 327 |
+
raise IOError(f"relay incomplete: {sent} of {size} bytes uploaded")
|
| 328 |
+
return n_parts
|
| 329 |
+
|
| 330 |
+
|
| 331 |
async def close_all(transports: Sequence[Transport]) -> None:
|
| 332 |
await asyncio.gather(*(t.close() for t in transports), return_exceptions=True)
|
| 333 |
|
|
|
|
| 459 |
return InputFileBig(id=file_id, parts=parts, name=file_name)
|
| 460 |
|
| 461 |
|
| 462 |
+
async def open_relay_transports(
|
| 463 |
+
client: Any, dc_id: int, connections: int
|
| 464 |
+
) -> Tuple[List[TelethonTransport], List[TelethonTransport]]:
|
| 465 |
+
"""Connections to fetch from ``dc_id`` and connections to upload to our own DC, opened together."""
|
| 466 |
+
results = await asyncio.gather(
|
| 467 |
+
open_transports(client, dc_id, connections),
|
| 468 |
+
open_transports(client, client.session.dc_id, connections),
|
| 469 |
+
return_exceptions=True,
|
| 470 |
+
)
|
| 471 |
+
failed = next((r for r in results if isinstance(r, BaseException)), None)
|
| 472 |
+
if failed is not None:
|
| 473 |
+
for r in results:
|
| 474 |
+
if not isinstance(r, BaseException):
|
| 475 |
+
await close_all(r)
|
| 476 |
+
raise failed
|
| 477 |
+
return results[0], results[1] # type: ignore[return-value]
|
| 478 |
+
|
| 479 |
+
|
| 480 |
+
async def relay(
|
| 481 |
+
client: Any,
|
| 482 |
+
document: Any,
|
| 483 |
+
file_name: str,
|
| 484 |
+
*,
|
| 485 |
+
connections: int = 8,
|
| 486 |
+
progress: Optional[ProgressCb] = None,
|
| 487 |
+
cancel: Optional[asyncio.Event] = None,
|
| 488 |
+
) -> Any:
|
| 489 |
+
"""Upload a copy of ``document`` named ``file_name`` while it downloads, without saving it; returns an ``InputFileBig``.
|
| 490 |
+
|
| 491 |
+
Telethon has no equivalent to fall back to here: when this fails the caller downloads and uploads the usual way.
|
| 492 |
+
"""
|
| 493 |
+
from telethon import utils
|
| 494 |
+
from telethon.tl.types import InputFileBig
|
| 495 |
+
|
| 496 |
+
dc_id, location = utils.get_input_location(document)
|
| 497 |
+
size = int(document.size)
|
| 498 |
+
file_id = int.from_bytes(os.urandom(8), "big", signed=True)
|
| 499 |
+
down, up = await open_relay_transports(client, dc_id, connections)
|
| 500 |
+
try:
|
| 501 |
+
parts = await relay_parts(down, up, location, size, file_id, progress=progress, cancel=cancel)
|
| 502 |
+
finally:
|
| 503 |
+
await close_all(down + up)
|
| 504 |
+
return InputFileBig(id=file_id, parts=parts, name=file_name)
|
| 505 |
+
|
| 506 |
+
|
| 507 |
+
def is_file_part_error(exc: BaseException) -> bool:
|
| 508 |
+
"""Is this Telegram refusing an upload because of its parts (one missing, or the wrong size or number of them)?"""
|
| 509 |
+
return type(exc).__name__.startswith("FilePart")
|
| 510 |
+
|
| 511 |
+
|
| 512 |
# ---------------------------------------------------------------------------
|
| 513 |
# public wrappers with fallback
|
| 514 |
# ---------------------------------------------------------------------------
|
bot/formats.py
CHANGED
|
@@ -1,8 +1,9 @@
|
|
| 1 |
"""Saved /rename formats: the pieces shared by the menus, the handlers and the pipeline.
|
| 2 |
|
| 3 |
-
A format is a named bundle of a naming template, a thumbnail, the six metadata titles and the Keep
|
| 4 |
-
Keep Subtitle lists. Every other setting (mode,
|
| 5 |
-
|
|
|
|
| 6 |
"""
|
| 7 |
from __future__ import annotations
|
| 8 |
|
|
@@ -14,12 +15,12 @@ from .db import DEFAULT_SETTINGS, Database
|
|
| 14 |
NAME_MAX = 30
|
| 15 |
# format field -> the kind settings_ops.apply_setting() understands
|
| 16 |
FIELD_KINDS: Dict[str, str] = {
|
| 17 |
-
"template": "template",
|
| 18 |
"title": "mt_title", "author": "mt_author", "artist": "mt_artist",
|
| 19 |
"audio": "mt_audio", "subtitle": "mt_subtitle", "video": "mt_video",
|
| 20 |
"keep_audio": "keep_audio", "keep_subtitle": "keep_subtitle",
|
| 21 |
}
|
| 22 |
-
_EDITABLE = ("template", "meta", "keep_audio", "keep_subtitle")
|
| 23 |
|
| 24 |
|
| 25 |
def validate_name(text: Optional[str]) -> Tuple[bool, str]:
|
|
@@ -37,13 +38,14 @@ def has_metadata(fmt: Dict[str, Any]) -> bool:
|
|
| 37 |
|
| 38 |
|
| 39 |
def apply_format(settings: Dict[str, Any], fmt: Dict[str, Any]) -> Dict[str, Any]:
|
| 40 |
-
"""The user's settings with the format's template, metadata and keep lists laid over them.
|
| 41 |
|
| 42 |
Metadata is on whenever the format sets anything, whatever the global Metadata switch says: the format
|
| 43 |
carries its own metadata, and a format with none leaves the file alone (no ffmpeg pass).
|
| 44 |
"""
|
| 45 |
out = copy.deepcopy(settings)
|
| 46 |
out["template"] = fmt["template"]
|
|
|
|
| 47 |
out["meta"] = dict(fmt["meta"])
|
| 48 |
out["keep_audio"] = list(fmt["keep_audio"])
|
| 49 |
out["keep_subtitle"] = list(fmt["keep_subtitle"])
|
|
@@ -67,7 +69,10 @@ class FormatSettings:
|
|
| 67 |
if fmt is None:
|
| 68 |
raise LookupError("that format no longer exists")
|
| 69 |
out = copy.deepcopy(DEFAULT_SETTINGS)
|
| 70 |
-
out.update(
|
|
|
|
|
|
|
|
|
|
| 71 |
out["meta"] = dict(fmt["meta"])
|
| 72 |
return out
|
| 73 |
|
|
|
|
| 1 |
"""Saved /rename formats: the pieces shared by the menus, the handlers and the pipeline.
|
| 2 |
|
| 3 |
+
A format is a named bundle of a naming template, a caption, a thumbnail, the six metadata titles and the Keep
|
| 4 |
+
Audio / Keep Subtitle lists. Every other setting (mode, file type, remove words, target channel) still comes
|
| 5 |
+
from the user's normal auto-rename settings. The caption never does: a format that was given none sends the new
|
| 6 |
+
file name in bold, whatever caption the user set for their own renames.
|
| 7 |
"""
|
| 8 |
from __future__ import annotations
|
| 9 |
|
|
|
|
| 15 |
NAME_MAX = 30
|
| 16 |
# format field -> the kind settings_ops.apply_setting() understands
|
| 17 |
FIELD_KINDS: Dict[str, str] = {
|
| 18 |
+
"template": "template", "caption": "caption",
|
| 19 |
"title": "mt_title", "author": "mt_author", "artist": "mt_artist",
|
| 20 |
"audio": "mt_audio", "subtitle": "mt_subtitle", "video": "mt_video",
|
| 21 |
"keep_audio": "keep_audio", "keep_subtitle": "keep_subtitle",
|
| 22 |
}
|
| 23 |
+
_EDITABLE = ("template", "caption", "meta", "keep_audio", "keep_subtitle")
|
| 24 |
|
| 25 |
|
| 26 |
def validate_name(text: Optional[str]) -> Tuple[bool, str]:
|
|
|
|
| 38 |
|
| 39 |
|
| 40 |
def apply_format(settings: Dict[str, Any], fmt: Dict[str, Any]) -> Dict[str, Any]:
|
| 41 |
+
"""The user's settings with the format's template, caption, metadata and keep lists laid over them.
|
| 42 |
|
| 43 |
Metadata is on whenever the format sets anything, whatever the global Metadata switch says: the format
|
| 44 |
carries its own metadata, and a format with none leaves the file alone (no ffmpeg pass).
|
| 45 |
"""
|
| 46 |
out = copy.deepcopy(settings)
|
| 47 |
out["template"] = fmt["template"]
|
| 48 |
+
out["caption"] = fmt.get("caption") or DEFAULT_SETTINGS["caption"] # never the user's own caption
|
| 49 |
out["meta"] = dict(fmt["meta"])
|
| 50 |
out["keep_audio"] = list(fmt["keep_audio"])
|
| 51 |
out["keep_subtitle"] = list(fmt["keep_subtitle"])
|
|
|
|
| 69 |
if fmt is None:
|
| 70 |
raise LookupError("that format no longer exists")
|
| 71 |
out = copy.deepcopy(DEFAULT_SETTINGS)
|
| 72 |
+
out.update(
|
| 73 |
+
template=fmt["template"], caption=fmt["caption"], keep_audio=list(fmt["keep_audio"]),
|
| 74 |
+
keep_subtitle=list(fmt["keep_subtitle"]), metadata_on=True,
|
| 75 |
+
)
|
| 76 |
out["meta"] = dict(fmt["meta"])
|
| 77 |
return out
|
| 78 |
|
bot/main.py
CHANGED
|
@@ -71,9 +71,9 @@ async def amain() -> int:
|
|
| 71 |
channel_log = errorlog.install(app._log_send, errorlog.secrets_from(cfg)) if cfg.log_channel else None # errors -> LOG_CHANNEL
|
| 72 |
log.info("@%s is online (DC %s, %d connection(s) per transfer, fast transfer %s)",
|
| 73 |
me.username, client.session.dc_id, cfg.connections, "on" if cfg.fast_transfer else "off")
|
| 74 |
-
speed = sysinfo.speed_summary(cfg.fast_transfer, cfg.connections)
|
| 75 |
log.info("speed: %s", speed)
|
| 76 |
-
for _short, detail, fine in sysinfo.speed_checks(cfg.fast_transfer, cfg.connections):
|
| 77 |
if not fine:
|
| 78 |
log.warning("speed: %s", detail)
|
| 79 |
if cfg.log_channel:
|
|
|
|
| 71 |
channel_log = errorlog.install(app._log_send, errorlog.secrets_from(cfg)) if cfg.log_channel else None # errors -> LOG_CHANNEL
|
| 72 |
log.info("@%s is online (DC %s, %d connection(s) per transfer, fast transfer %s)",
|
| 73 |
me.username, client.session.dc_id, cfg.connections, "on" if cfg.fast_transfer else "off")
|
| 74 |
+
speed = sysinfo.speed_summary(cfg.fast_transfer, cfg.connections, cfg.stream_transfer)
|
| 75 |
log.info("speed: %s", speed)
|
| 76 |
+
for _short, detail, fine in sysinfo.speed_checks(cfg.fast_transfer, cfg.connections, cfg.stream_transfer):
|
| 77 |
if not fine:
|
| 78 |
log.warning("speed: %s", detail)
|
| 79 |
if cfg.log_channel:
|
bot/panels.py
CHANGED
|
@@ -8,6 +8,7 @@ from __future__ import annotations
|
|
| 8 |
|
| 9 |
from typing import Any, Dict, Iterable, List, Sequence, Tuple
|
| 10 |
|
|
|
|
| 11 |
from .media import LANGUAGES, canonical_lang
|
| 12 |
from .parser import CAPTION_VARS, TEMPLATE_VARS
|
| 13 |
from .utils import clip, code_each, esc
|
|
@@ -207,7 +208,7 @@ def help_panel() -> Panel:
|
|
| 207 |
text = (
|
| 208 |
"<b>How to use</b>\n\n"
|
| 209 |
"1. Send a file — I rename it with your template and send it back.\n"
|
| 210 |
-
"2. /formats — save up to 12 named formats (template, thumbnail, metadata, tracks) and share them; "
|
| 211 |
"/rename — pick one, then send a file (send /rename again for the next one).\n"
|
| 212 |
"3. /template and /caption — set the naming and caption templates.\n"
|
| 213 |
"4. /mode — read details from the file name or the caption.\n"
|
|
@@ -267,7 +268,7 @@ def _format_buttons(formats: Sequence[Dict[str, Any]], prefix: str) -> List[Row]
|
|
| 267 |
def formats_menu(formats: Sequence[Dict[str, Any]], limit: int) -> Panel:
|
| 268 |
text = (
|
| 269 |
f"<b>🗂 FORMATS</b> ({len(formats)}/{limit})\n\n"
|
| 270 |
-
"A format saves its own template, thumbnail, metadata titles and Keep Audio / Keep Subtitle lists "
|
| 271 |
"under a name, like <i>One Piece</i> or <i>Dragon Ball</i>.\n"
|
| 272 |
"Tap one to view or edit it, then use /rename to apply it to your files."
|
| 273 |
)
|
|
@@ -281,11 +282,17 @@ def formats_menu(formats: Sequence[Dict[str, Any]], limit: int) -> Panel:
|
|
| 281 |
return text, rows + nav("home")
|
| 282 |
|
| 283 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 284 |
def _format_lines(fmt: Dict[str, Any], *, thumb_yes: str, thumb_no: str) -> List[str]:
|
| 285 |
"""What a format holds, one line per field."""
|
| 286 |
m = fmt["meta"]
|
| 287 |
lines = [
|
| 288 |
f"{DOT} <b>Template</b> ▸ {_code(fmt['template'], 60)}",
|
|
|
|
| 289 |
f"{DOT} <b>Thumbnail</b> ▸ " + (thumb_yes if fmt.get("has_thumb") else thumb_no),
|
| 290 |
]
|
| 291 |
for key, label in FORMAT_FIELD_LABELS:
|
|
@@ -300,14 +307,18 @@ def format_view(fmt: Dict[str, Any], *, share_inline: bool = False) -> Panel:
|
|
| 300 |
while inline mode is on for the bot (Telegram refuses the message otherwise), so without it the button explains how."""
|
| 301 |
lines = [f"<b>🗂 {esc(clip(fmt['name'], 30))}</b>", ""]
|
| 302 |
lines += _format_lines(fmt, thumb_yes="✅ saved", thumb_no="— (your default thumbnail is used)")
|
| 303 |
-
lines += ["", "Use it with /rename. Mode,
|
| 304 |
fid = fmt["id"]
|
| 305 |
thumb_row: Row = [("Thumbnail", f"fm:th:{fid}")]
|
| 306 |
if fmt.get("has_thumb"):
|
| 307 |
thumb_row.append(("🗑 Delete Thumb", f"fm:thd:{fid}"))
|
| 308 |
rows: List[Row] = [
|
| 309 |
-
[("Name", f"fm:in:{fid}:name"), ("Template", f"fm:in:{fid}:template")],
|
| 310 |
thumb_row,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 311 |
[("Title", f"fm:in:{fid}:title"), ("Author", f"fm:in:{fid}:author"), ("Artist", f"fm:in:{fid}:artist")],
|
| 312 |
[("Audio", f"fm:in:{fid}:audio"), ("Subtitle", f"fm:in:{fid}:subtitle"), ("Video", f"fm:in:{fid}:video")],
|
| 313 |
[("Keep Audio", f"fm:in:{fid}:keep_audio"), ("Keep Subtitle", f"fm:in:{fid}:keep_subtitle")],
|
|
@@ -350,7 +361,7 @@ def shared_added(fmt: Dict[str, Any]) -> Panel:
|
|
| 350 |
|
| 351 |
|
| 352 |
def format_delete_confirm(fmt: Dict[str, Any]) -> Panel:
|
| 353 |
-
text = f"<b>Delete the format “{esc(clip(fmt['name'], 30))}”?</b>\n\nIts template, thumbnail and metadata are removed. This can't be undone."
|
| 354 |
return text, [[("🗑 Yes, delete", f"fm:ok:{fmt['id']}"), ("◀ Keep it", f"fm:v:{fmt['id']}")]]
|
| 355 |
|
| 356 |
|
|
@@ -358,7 +369,7 @@ def rename_picker(formats: Sequence[Dict[str, Any]]) -> Panel:
|
|
| 358 |
if not formats:
|
| 359 |
text = (
|
| 360 |
"<b>🏷 RENAME</b>\n\nYou have no saved formats yet.\n"
|
| 361 |
-
"A format keeps a template, thumbnail and metadata under a name, like <i>One Piece</i>. Create one, then come back here."
|
| 362 |
)
|
| 363 |
return text, [[("➕ Create a format", "fm:new")], [("✖ Close", "x:close")]]
|
| 364 |
text = "<b>🏷 RENAME</b>\n\nChoose the format to rename with. I'll ask for your files next."
|
|
@@ -536,6 +547,12 @@ FORMAT_PROMPTS: Dict[str, str] = {
|
|
| 536 |
"name": "<b>Send the new name</b> for this format (30 characters max).",
|
| 537 |
"thumb": "<b>Send a photo</b> to use as this format's thumbnail.",
|
| 538 |
"template": PROMPTS["template"],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 539 |
"title": PROMPTS["mt_title"], "author": PROMPTS["mt_author"], "artist": PROMPTS["mt_artist"],
|
| 540 |
"audio": PROMPTS["mt_audio"], "subtitle": PROMPTS["mt_subtitle"], "video": PROMPTS["mt_video"],
|
| 541 |
"keep_audio": PROMPTS["keep_audio"], "keep_subtitle": PROMPTS["keep_subtitle"],
|
|
|
|
| 8 |
|
| 9 |
from typing import Any, Dict, Iterable, List, Sequence, Tuple
|
| 10 |
|
| 11 |
+
from .db import DEFAULT_SETTINGS
|
| 12 |
from .media import LANGUAGES, canonical_lang
|
| 13 |
from .parser import CAPTION_VARS, TEMPLATE_VARS
|
| 14 |
from .utils import clip, code_each, esc
|
|
|
|
| 208 |
text = (
|
| 209 |
"<b>How to use</b>\n\n"
|
| 210 |
"1. Send a file — I rename it with your template and send it back.\n"
|
| 211 |
+
"2. /formats — save up to 12 named formats (template, caption, thumbnail, metadata, tracks) and share them; "
|
| 212 |
"/rename — pick one, then send a file (send /rename again for the next one).\n"
|
| 213 |
"3. /template and /caption — set the naming and caption templates.\n"
|
| 214 |
"4. /mode — read details from the file name or the caption.\n"
|
|
|
|
| 268 |
def formats_menu(formats: Sequence[Dict[str, Any]], limit: int) -> Panel:
|
| 269 |
text = (
|
| 270 |
f"<b>🗂 FORMATS</b> ({len(formats)}/{limit})\n\n"
|
| 271 |
+
"A format saves its own template, caption, thumbnail, metadata titles and Keep Audio / Keep Subtitle lists "
|
| 272 |
"under a name, like <i>One Piece</i> or <i>Dragon Ball</i>.\n"
|
| 273 |
"Tap one to view or edit it, then use /rename to apply it to your files."
|
| 274 |
)
|
|
|
|
| 282 |
return text, rows + nav("home")
|
| 283 |
|
| 284 |
|
| 285 |
+
def _custom_caption(fmt: Dict[str, Any]) -> bool:
|
| 286 |
+
"""Has the format a caption of its own? Otherwise it sends the new file name in bold."""
|
| 287 |
+
return fmt["caption"] != DEFAULT_SETTINGS["caption"]
|
| 288 |
+
|
| 289 |
+
|
| 290 |
def _format_lines(fmt: Dict[str, Any], *, thumb_yes: str, thumb_no: str) -> List[str]:
|
| 291 |
"""What a format holds, one line per field."""
|
| 292 |
m = fmt["meta"]
|
| 293 |
lines = [
|
| 294 |
f"{DOT} <b>Template</b> ▸ {_code(fmt['template'], 60)}",
|
| 295 |
+
f"{DOT} <b>Caption</b> ▸ {_code(fmt['caption'], 60)}" + ("" if _custom_caption(fmt) else " · default"),
|
| 296 |
f"{DOT} <b>Thumbnail</b> ▸ " + (thumb_yes if fmt.get("has_thumb") else thumb_no),
|
| 297 |
]
|
| 298 |
for key, label in FORMAT_FIELD_LABELS:
|
|
|
|
| 307 |
while inline mode is on for the bot (Telegram refuses the message otherwise), so without it the button explains how."""
|
| 308 |
lines = [f"<b>🗂 {esc(clip(fmt['name'], 30))}</b>", ""]
|
| 309 |
lines += _format_lines(fmt, thumb_yes="✅ saved", thumb_no="— (your default thumbnail is used)")
|
| 310 |
+
lines += ["", "Use it with /rename. Mode, file type and the rest come from your normal settings."]
|
| 311 |
fid = fmt["id"]
|
| 312 |
thumb_row: Row = [("Thumbnail", f"fm:th:{fid}")]
|
| 313 |
if fmt.get("has_thumb"):
|
| 314 |
thumb_row.append(("🗑 Delete Thumb", f"fm:thd:{fid}"))
|
| 315 |
rows: List[Row] = [
|
| 316 |
+
[("Name", f"fm:in:{fid}:name"), ("Template", f"fm:in:{fid}:template"), ("Caption", f"fm:in:{fid}:caption")],
|
| 317 |
thumb_row,
|
| 318 |
+
]
|
| 319 |
+
if _custom_caption(fmt):
|
| 320 |
+
rows.append([("↩ Reset Caption", f"fm:cr:{fid}")])
|
| 321 |
+
rows += [
|
| 322 |
[("Title", f"fm:in:{fid}:title"), ("Author", f"fm:in:{fid}:author"), ("Artist", f"fm:in:{fid}:artist")],
|
| 323 |
[("Audio", f"fm:in:{fid}:audio"), ("Subtitle", f"fm:in:{fid}:subtitle"), ("Video", f"fm:in:{fid}:video")],
|
| 324 |
[("Keep Audio", f"fm:in:{fid}:keep_audio"), ("Keep Subtitle", f"fm:in:{fid}:keep_subtitle")],
|
|
|
|
| 361 |
|
| 362 |
|
| 363 |
def format_delete_confirm(fmt: Dict[str, Any]) -> Panel:
|
| 364 |
+
text = f"<b>Delete the format “{esc(clip(fmt['name'], 30))}”?</b>\n\nIts template, caption, thumbnail and metadata are removed. This can't be undone."
|
| 365 |
return text, [[("🗑 Yes, delete", f"fm:ok:{fmt['id']}"), ("◀ Keep it", f"fm:v:{fmt['id']}")]]
|
| 366 |
|
| 367 |
|
|
|
|
| 369 |
if not formats:
|
| 370 |
text = (
|
| 371 |
"<b>🏷 RENAME</b>\n\nYou have no saved formats yet.\n"
|
| 372 |
+
"A format keeps a template, caption, thumbnail and metadata under a name, like <i>One Piece</i>. Create one, then come back here."
|
| 373 |
)
|
| 374 |
return text, [[("➕ Create a format", "fm:new")], [("✖ Close", "x:close")]]
|
| 375 |
text = "<b>🏷 RENAME</b>\n\nChoose the format to rename with. I'll ask for your files next."
|
|
|
|
| 547 |
"name": "<b>Send the new name</b> for this format (30 characters max).",
|
| 548 |
"thumb": "<b>Send a photo</b> to use as this format's thumbnail.",
|
| 549 |
"template": PROMPTS["template"],
|
| 550 |
+
"caption": (
|
| 551 |
+
"<b>Send the caption for this format.</b>\n\n"
|
| 552 |
+
f"Variables: {CAPTION_VARIABLES}\n"
|
| 553 |
+
"Telegram HTML works (<code><b></code>, <code><i></code>, <code><code></code>).\n"
|
| 554 |
+
"Send <code>reset</code> to go back to the default: the new file name in bold."
|
| 555 |
+
),
|
| 556 |
"title": PROMPTS["mt_title"], "author": PROMPTS["mt_author"], "artist": PROMPTS["mt_artist"],
|
| 557 |
"audio": PROMPTS["mt_audio"], "subtitle": PROMPTS["mt_subtitle"], "video": PROMPTS["mt_video"],
|
| 558 |
"keep_audio": PROMPTS["keep_audio"], "keep_subtitle": PROMPTS["keep_subtitle"],
|
bot/pipeline.py
CHANGED
|
@@ -1,12 +1,13 @@
|
|
| 1 |
-
"""The rename pipeline: download -> (ffmpeg) -> upload."""
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
import logging
|
| 5 |
import mimetypes
|
| 6 |
import shutil
|
| 7 |
import time
|
|
|
|
| 8 |
from pathlib import Path
|
| 9 |
-
from typing import Any, Dict, Optional
|
| 10 |
|
| 11 |
from telethon import errors, types
|
| 12 |
|
|
@@ -65,6 +66,47 @@ def original_name_of(message: Any) -> str:
|
|
| 65 |
return f"{kind}_{message.id}{(f.ext if f is not None else '') or ''}"
|
| 66 |
|
| 67 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
class Renamer:
|
| 69 |
def __init__(self, client: Any, cfg: Config, db: Database):
|
| 70 |
self.client = client
|
|
@@ -111,6 +153,134 @@ class Renamer:
|
|
| 111 |
errorlog.error_context.reset(context)
|
| 112 |
shutil.rmtree(work, ignore_errors=True)
|
| 113 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
async def _run(self, job: Job, s: Dict[str, Any], work: Path, reporter: ProgressReporter, started: float) -> None:
|
| 115 |
client, cfg = self.client, self.cfg
|
| 116 |
msg = await self._fresh_message(job)
|
|
@@ -120,6 +290,8 @@ class Renamer:
|
|
| 120 |
size = int(msg.file.size or job.size or 0)
|
| 121 |
src = work / f"src{ext}"
|
| 122 |
|
|
|
|
|
|
|
| 123 |
self._check_space(work, size, will_remux=bool(s["metadata_on"]))
|
| 124 |
|
| 125 |
# ---- download ------------------------------------------------------------
|
|
@@ -177,22 +349,17 @@ class Renamer:
|
|
| 177 |
)
|
| 178 |
)
|
| 179 |
|
| 180 |
-
thumb = await self.
|
| 181 |
-
if thumb is None:
|
| 182 |
-
thumb = await self.db.get_thumb(job.user_id)
|
| 183 |
if thumb is None and ext.lower() in media.VIDEO_CONTAINERS and info is not None and info.of("video"):
|
| 184 |
thumb_path = work / "thumb.jpg"
|
| 185 |
if await media.make_video_thumbnail(current, thumb_path, info.duration, job.cancel):
|
| 186 |
thumb = thumb_path.read_bytes()
|
| 187 |
|
| 188 |
# ---- caption ---------------------------------------------------------------------
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
filename=split_ext(job.new_name)[0], original=orig_name, ext=ext.lstrip("."),
|
| 193 |
-
size=human_size(final_size), duration=human_duration(info.duration) if info and info.duration else "",
|
| 194 |
)
|
| 195 |
-
caption = render_caption(s["caption"], values)[:1000] or None
|
| 196 |
|
| 197 |
# ---- upload ---------------------------------------------------------------------------
|
| 198 |
reporter.set_stage("⬆️ Uploading", final_size)
|
|
@@ -204,31 +371,12 @@ class Renamer:
|
|
| 204 |
up_seconds = time.monotonic() - began
|
| 205 |
if job.cancel.is_set():
|
| 206 |
raise Cancelled()
|
| 207 |
-
target = s.get("target_channel") or job.chat_id
|
| 208 |
-
kwargs: Dict[str, Any] = dict(
|
| 209 |
-
caption=caption, attributes=attrs, mime_type=guess_mime(job.new_name, getattr(msg.file, "mime_type", None)),
|
| 210 |
-
force_document=(send_type == "document"), thumb=thumb, parse_mode="html",
|
| 211 |
-
)
|
| 212 |
-
if target == job.chat_id:
|
| 213 |
-
kwargs["reply_to"] = job.msg_id
|
| 214 |
-
try:
|
| 215 |
-
await client.send_file(target, input_file, **kwargs)
|
| 216 |
-
except (errors.EntityBoundsInvalidError, errors.MediaCaptionTooLongError, errors.MessageTooLongError):
|
| 217 |
-
# the caption template produced something Telegram rejects: send the file without a caption
|
| 218 |
-
kwargs.update(caption=None)
|
| 219 |
-
await client.send_file(target, input_file, **kwargs)
|
| 220 |
-
warnings.append("The caption was rejected by Telegram (check your caption template), so it was left out.")
|
| 221 |
-
|
| 222 |
-
await self.db.record_job(job.user_id, final_size)
|
| 223 |
-
job.state = "done"
|
| 224 |
-
took = time.monotonic() - started
|
| 225 |
-
note = "".join(f"\n⚠️ {esc(w)}" for w in warnings)
|
| 226 |
-
where = "" if target == job.chat_id else "\n📣 Sent to your channel."
|
| 227 |
rates = "".join( # how fast each way went; a transfer under a second says nothing
|
| 228 |
f" · {arrow} {human_size(amount / seconds)}/s"
|
| 229 |
for arrow, amount, seconds in (("⬇", size, down_seconds), ("⬆", final_size, up_seconds))
|
| 230 |
if seconds >= 1 and amount > 0
|
| 231 |
)
|
| 232 |
-
await
|
| 233 |
-
|
|
|
|
| 234 |
)
|
|
|
|
| 1 |
+
"""The rename pipeline: download -> (ffmpeg) -> upload, or for a plain rename of a big file a copy straight from the download into the upload."""
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
import logging
|
| 5 |
import mimetypes
|
| 6 |
import shutil
|
| 7 |
import time
|
| 8 |
+
from dataclasses import dataclass
|
| 9 |
from pathlib import Path
|
| 10 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 11 |
|
| 12 |
from telethon import errors, types
|
| 13 |
|
|
|
|
| 66 |
return f"{kind}_{message.id}{(f.ext if f is not None else '') or ''}"
|
| 67 |
|
| 68 |
|
| 69 |
+
def make_caption(template: str, *, new_name: str, orig_name: str, ext: str, size: int, duration: float) -> Optional[str]:
|
| 70 |
+
"""The caption of a renamed file: the user's (or the format's) template with its variables filled in."""
|
| 71 |
+
parsed = parse_info(split_ext(orig_name)[0])
|
| 72 |
+
values = build_values(parsed, split_ext(orig_name)[0], ext)
|
| 73 |
+
values.update(
|
| 74 |
+
filename=split_ext(new_name)[0], original=orig_name, ext=ext.lstrip("."),
|
| 75 |
+
size=human_size(size), duration=human_duration(duration) if duration else "",
|
| 76 |
+
)
|
| 77 |
+
return render_caption(template, values)[:1000] or None
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def source_attributes(message: Any) -> Tuple[Optional[Any], Optional[Any]]:
|
| 81 |
+
"""The video and the audio details Telegram keeps with a file: ``(DocumentAttributeVideo, DocumentAttributeAudio)``, ``None`` for a missing one."""
|
| 82 |
+
attributes = getattr(getattr(message, "document", None), "attributes", None) or []
|
| 83 |
+
video = next((a for a in attributes if isinstance(a, types.DocumentAttributeVideo) and not a.round_message), None)
|
| 84 |
+
audio = next((a for a in attributes if isinstance(a, types.DocumentAttributeAudio) and not a.voice), None)
|
| 85 |
+
return video, audio
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
async def embedded_thumb(client: Any, msg: Any, allow_stripped: bool = False) -> Optional[bytes]:
|
| 89 |
+
"""The preview Telegram keeps with a file. The tiny blurred one (a "stripped" size) only counts when ``allow_stripped``."""
|
| 90 |
+
thumbs = getattr(getattr(msg, "document", None), "thumbs", None) or []
|
| 91 |
+
if not any(not isinstance(t, types.PhotoStrippedSize) for t in thumbs) and not (allow_stripped and thumbs):
|
| 92 |
+
return None
|
| 93 |
+
try:
|
| 94 |
+
return await client.download_media(msg, file=bytes, thumb=-1) or None
|
| 95 |
+
except Exception as exc: # noqa: BLE001
|
| 96 |
+
log.debug("could not download the embedded thumbnail: %s", exc)
|
| 97 |
+
return None
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
@dataclass
|
| 101 |
+
class StreamPlan:
|
| 102 |
+
"""What a streamed rename sends along with the file, worked out without ever reading the file."""
|
| 103 |
+
|
| 104 |
+
attrs: List[Any]
|
| 105 |
+
as_document: bool
|
| 106 |
+
thumb: Optional[bytes]
|
| 107 |
+
duration: float
|
| 108 |
+
|
| 109 |
+
|
| 110 |
class Renamer:
|
| 111 |
def __init__(self, client: Any, cfg: Config, db: Database):
|
| 112 |
self.client = client
|
|
|
|
| 153 |
errorlog.error_context.reset(context)
|
| 154 |
shutil.rmtree(work, ignore_errors=True)
|
| 155 |
|
| 156 |
+
async def _custom_thumb(self, job: Job) -> Optional[bytes]:
|
| 157 |
+
"""The thumbnail the user chose: the one of the format being used, else their default one."""
|
| 158 |
+
thumb = await self.db.get_format_thumb(job.user_id, job.fmt["id"]) if job.fmt else None
|
| 159 |
+
if thumb is None:
|
| 160 |
+
thumb = await self.db.get_thumb(job.user_id)
|
| 161 |
+
return thumb
|
| 162 |
+
|
| 163 |
+
async def _stream_plan(
|
| 164 |
+
self, job: Job, s: Dict[str, Any], msg: Any, ext: str, send_type: str, size: int
|
| 165 |
+
) -> Optional[StreamPlan]:
|
| 166 |
+
"""What a streamed rename would send, or ``None`` when the file has to be looked at first (it is then saved and uploaded as usual).
|
| 167 |
+
|
| 168 |
+
Streaming never saves the file, so nothing can read it: the video and audio details come from the ones Telegram keeps
|
| 169 |
+
with the original, the thumbnail from the user's setting or the original's own preview. A rename that changes the
|
| 170 |
+
file itself (metadata and tracks) cannot be streamed either.
|
| 171 |
+
"""
|
| 172 |
+
if not (self.cfg.fast_transfer and self.cfg.stream_transfer):
|
| 173 |
+
return None
|
| 174 |
+
video, audio = source_attributes(msg)
|
| 175 |
+
source = video if send_type == "video" else audio if send_type == "audio" else (video or audio)
|
| 176 |
+
duration = float(getattr(source, "duration", 0) or 0)
|
| 177 |
+
reason = None
|
| 178 |
+
if getattr(msg, "document", None) is None:
|
| 179 |
+
reason = "it is not a document"
|
| 180 |
+
elif size <= transfer.BIG_FILE_THRESHOLD:
|
| 181 |
+
reason = "it is small, so there is little to gain"
|
| 182 |
+
elif s["metadata_on"]:
|
| 183 |
+
reason = "metadata and tracks change the file"
|
| 184 |
+
elif send_type == "video" and video is None:
|
| 185 |
+
reason = "Telegram has no video details to reuse"
|
| 186 |
+
elif send_type == "audio" and audio is None:
|
| 187 |
+
reason = "Telegram has no audio details to reuse"
|
| 188 |
+
elif "{duration}" in (s["caption"] or "") and not duration:
|
| 189 |
+
reason = "the caption wants the duration and only the file can tell"
|
| 190 |
+
thumb = None
|
| 191 |
+
if reason is None:
|
| 192 |
+
thumb = await self._custom_thumb(job)
|
| 193 |
+
if thumb is None and ext.lower() in media.VIDEO_CONTAINERS: # the usual way makes one from a frame of the file
|
| 194 |
+
thumb = await embedded_thumb(self.client, msg)
|
| 195 |
+
if thumb is None:
|
| 196 |
+
reason = "there is no thumbnail to reuse"
|
| 197 |
+
if reason is not None:
|
| 198 |
+
log.info("%s is not streamed: %s", job.new_name, reason)
|
| 199 |
+
return None
|
| 200 |
+
attrs: List[Any] = [types.DocumentAttributeFilename(job.new_name)]
|
| 201 |
+
if send_type == "video":
|
| 202 |
+
attrs.append(types.DocumentAttributeVideo(duration=video.duration or 0, w=video.w or 1, h=video.h or 1, supports_streaming=True))
|
| 203 |
+
elif send_type == "audio":
|
| 204 |
+
attrs.append(
|
| 205 |
+
types.DocumentAttributeAudio(
|
| 206 |
+
duration=int(audio.duration or 0), title=audio.title or None, performer=audio.performer or None
|
| 207 |
+
)
|
| 208 |
+
)
|
| 209 |
+
return StreamPlan(attrs=attrs, as_document=(send_type == "document"), thumb=thumb, duration=duration)
|
| 210 |
+
|
| 211 |
+
async def _stream(
|
| 212 |
+
self, job: Job, s: Dict[str, Any], msg: Any, orig_name: str, ext: str, send_type: str, size: int,
|
| 213 |
+
reporter: ProgressReporter, started: float,
|
| 214 |
+
) -> bool:
|
| 215 |
+
"""Rename by copying the file from the download straight into the upload: nothing is saved, so the whole takes about as long as
|
| 216 |
+
the slower of the two. ``True`` when the file was sent; ``False`` when it cannot be streamed or streaming failed (the usual way follows)."""
|
| 217 |
+
plan = await self._stream_plan(job, s, msg, ext, send_type, size)
|
| 218 |
+
if plan is None:
|
| 219 |
+
return False
|
| 220 |
+
reporter.set_stage("⬇️⬆️ Streaming", size)
|
| 221 |
+
began = time.monotonic()
|
| 222 |
+
try:
|
| 223 |
+
input_file = await transfer.relay(
|
| 224 |
+
self.client, msg.document, job.new_name, connections=self.cfg.connections, progress=reporter, cancel=job.cancel
|
| 225 |
+
)
|
| 226 |
+
except Cancelled:
|
| 227 |
+
raise
|
| 228 |
+
except Exception as exc: # noqa: BLE001
|
| 229 |
+
self._stream_failed(job, exc)
|
| 230 |
+
return False
|
| 231 |
+
seconds = time.monotonic() - began
|
| 232 |
+
if job.cancel.is_set():
|
| 233 |
+
raise Cancelled()
|
| 234 |
+
rates = f" · ⬇⬆ {human_size(size / seconds)}/s streamed" if seconds >= 1 else ""
|
| 235 |
+
caption = make_caption(s["caption"], new_name=job.new_name, orig_name=orig_name, ext=ext, size=size, duration=plan.duration)
|
| 236 |
+
try:
|
| 237 |
+
await self._deliver(
|
| 238 |
+
job, s, msg, input_file, caption=caption, attrs=plan.attrs, as_document=plan.as_document, thumb=plan.thumb,
|
| 239 |
+
final_size=size, warnings=list(job.notes), rates=rates, started=started, reporter=reporter,
|
| 240 |
+
)
|
| 241 |
+
except Exception as exc: # noqa: BLE001
|
| 242 |
+
if not transfer.is_file_part_error(exc): # only Telegram not accepting the parts is a streaming problem
|
| 243 |
+
raise
|
| 244 |
+
self._stream_failed(job, exc)
|
| 245 |
+
return False
|
| 246 |
+
transfer.stats.streamed += 1
|
| 247 |
+
return True
|
| 248 |
+
|
| 249 |
+
def _stream_failed(self, job: Job, exc: BaseException) -> None:
|
| 250 |
+
log.error("streaming %s failed (%s: %s); doing it the usual way", job.new_name, type(exc).__name__, exc, exc_info=exc)
|
| 251 |
+
transfer.stats.stream_failed(exc)
|
| 252 |
+
job.notes.append("Streaming didn't work for this file, so it was saved first and then uploaded.")
|
| 253 |
+
|
| 254 |
+
async def _deliver(
|
| 255 |
+
self, job: Job, s: Dict[str, Any], msg: Any, input_file: Any, *, caption: Optional[str], attrs: List[Any],
|
| 256 |
+
as_document: bool, thumb: Optional[bytes], final_size: int, warnings: List[str], rates: str, started: float,
|
| 257 |
+
reporter: ProgressReporter,
|
| 258 |
+
) -> None:
|
| 259 |
+
"""Send the uploaded file to its chat, then count it and say it is done."""
|
| 260 |
+
target = s.get("target_channel") or job.chat_id
|
| 261 |
+
kwargs: Dict[str, Any] = dict(
|
| 262 |
+
caption=caption, attributes=attrs, mime_type=guess_mime(job.new_name, getattr(msg.file, "mime_type", None)),
|
| 263 |
+
force_document=as_document, thumb=thumb, parse_mode="html",
|
| 264 |
+
)
|
| 265 |
+
if target == job.chat_id:
|
| 266 |
+
kwargs["reply_to"] = job.msg_id
|
| 267 |
+
try:
|
| 268 |
+
await self.client.send_file(target, input_file, **kwargs)
|
| 269 |
+
except (errors.EntityBoundsInvalidError, errors.MediaCaptionTooLongError, errors.MessageTooLongError):
|
| 270 |
+
# the caption template produced something Telegram rejects: send the file without a caption
|
| 271 |
+
kwargs.update(caption=None)
|
| 272 |
+
await self.client.send_file(target, input_file, **kwargs)
|
| 273 |
+
warnings.append("The caption was rejected by Telegram (check your caption template), so it was left out.")
|
| 274 |
+
|
| 275 |
+
await self.db.record_job(job.user_id, final_size)
|
| 276 |
+
job.state = "done"
|
| 277 |
+
took = time.monotonic() - started
|
| 278 |
+
note = "".join(f"\n⚠️ {esc(w)}" for w in warnings)
|
| 279 |
+
where = "" if target == job.chat_id else "\n📣 Sent to your channel."
|
| 280 |
+
await reporter.finish(
|
| 281 |
+
f"✅ <b>Done</b> in {human_duration(took)}\n<code>{esc(clip(job.new_name, 120))}</code>\n{human_size(final_size)}{rates}{where}{note}"
|
| 282 |
+
)
|
| 283 |
+
|
| 284 |
async def _run(self, job: Job, s: Dict[str, Any], work: Path, reporter: ProgressReporter, started: float) -> None:
|
| 285 |
client, cfg = self.client, self.cfg
|
| 286 |
msg = await self._fresh_message(job)
|
|
|
|
| 290 |
size = int(msg.file.size or job.size or 0)
|
| 291 |
src = work / f"src{ext}"
|
| 292 |
|
| 293 |
+
if await self._stream(job, s, msg, orig_name, ext, send_type, size, reporter, started):
|
| 294 |
+
return
|
| 295 |
self._check_space(work, size, will_remux=bool(s["metadata_on"]))
|
| 296 |
|
| 297 |
# ---- download ------------------------------------------------------------
|
|
|
|
| 349 |
)
|
| 350 |
)
|
| 351 |
|
| 352 |
+
thumb = await self._custom_thumb(job)
|
|
|
|
|
|
|
| 353 |
if thumb is None and ext.lower() in media.VIDEO_CONTAINERS and info is not None and info.of("video"):
|
| 354 |
thumb_path = work / "thumb.jpg"
|
| 355 |
if await media.make_video_thumbnail(current, thumb_path, info.duration, job.cancel):
|
| 356 |
thumb = thumb_path.read_bytes()
|
| 357 |
|
| 358 |
# ---- caption ---------------------------------------------------------------------
|
| 359 |
+
caption = make_caption(
|
| 360 |
+
s["caption"], new_name=job.new_name, orig_name=orig_name, ext=ext, size=final_size,
|
| 361 |
+
duration=(info.duration if info else 0) or 0,
|
|
|
|
|
|
|
| 362 |
)
|
|
|
|
| 363 |
|
| 364 |
# ---- upload ---------------------------------------------------------------------------
|
| 365 |
reporter.set_stage("⬆️ Uploading", final_size)
|
|
|
|
| 371 |
up_seconds = time.monotonic() - began
|
| 372 |
if job.cancel.is_set():
|
| 373 |
raise Cancelled()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 374 |
rates = "".join( # how fast each way went; a transfer under a second says nothing
|
| 375 |
f" · {arrow} {human_size(amount / seconds)}/s"
|
| 376 |
for arrow, amount, seconds in (("⬇", size, down_seconds), ("⬆", final_size, up_seconds))
|
| 377 |
if seconds >= 1 and amount > 0
|
| 378 |
)
|
| 379 |
+
await self._deliver(
|
| 380 |
+
job, s, msg, input_file, caption=caption, attrs=attrs, as_document=(send_type == "document"), thumb=thumb,
|
| 381 |
+
final_size=final_size, warnings=warnings, rates=rates, started=started, reporter=reporter,
|
| 382 |
)
|
bot/sysinfo.py
CHANGED
|
@@ -140,8 +140,8 @@ def loop_backend() -> str:
|
|
| 140 |
return "uvloop" if type(loop).__module__.split(".")[0] == "uvloop" else "asyncio"
|
| 141 |
|
| 142 |
|
| 143 |
-
def speed_checks(parallel: bool, connections: int) -> List[Tuple[str, str, bool]]:
|
| 144 |
-
"""``(short name, detail, fine)`` for the
|
| 145 |
crypto, loop = crypto_backend(), loop_backend()
|
| 146 |
if crypto == "cryptg":
|
| 147 |
encryption = ("cryptg", "cryptg", True)
|
|
@@ -151,28 +151,43 @@ def speed_checks(parallel: bool, connections: int) -> List[Tuple[str, str, bool]
|
|
| 151 |
encryption = ("slow Python crypto", "pure Python, slow: cryptg is missing", False)
|
| 152 |
event_loop = ("uvloop", "uvloop", True) if loop == "uvloop" else ("no uvloop", f"{loop} loop: uvloop is not running", False)
|
| 153 |
transfers = (f"parallel ×{connections}", f"on, {connections} connections", True) if parallel else ("parallel off", "off (FAST_TRANSFER=0)", False)
|
| 154 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
|
| 156 |
|
| 157 |
-
def speed_summary(parallel: bool, connections: int) -> str:
|
| 158 |
-
"""One line for the start-up notice: ``cryptg ✅ · uvloop ✅ · parallel ×8 ✅``."""
|
| 159 |
-
return " · ".join(f"{short} {'✅' if fine else '⚠️'}" for short, _detail, fine in speed_checks(parallel, connections))
|
| 160 |
|
| 161 |
|
| 162 |
-
def format_speed(parallel: bool, connections: int, stats: Any) -> str:
|
| 163 |
"""The Speed section of /status: what is switched on, and how the transfers since the start went."""
|
| 164 |
-
labels = ("🔐 Encryption", "🔁 Event loop", "🚀 Parallel transfer")
|
| 165 |
lines = ["⚡ <b>Speed</b>"]
|
| 166 |
-
for label, (_short, detail, fine) in zip(labels, speed_checks(parallel, connections)):
|
| 167 |
lines.append(f"{label}: {esc(detail)} {'✅' if fine else '⚠️'}")
|
| 168 |
small = f"{BIG_FILE_THRESHOLD // 2 ** 20} MB"
|
| 169 |
lines.append(f"⬇ <b>Downloads:</b> {stats.fast_down} parallel · {stats.standard_down} standard")
|
| 170 |
lines.append(f"⬆ <b>Uploads:</b> {stats.fast_up} parallel · {stats.small_up} up to {small} · {stats.standard_up} standard")
|
|
|
|
|
|
|
| 171 |
if stats.fallbacks:
|
| 172 |
lines.append(
|
| 173 |
f"⚠️ The parallel transfer failed {stats.fallbacks}× and the standard, slower one took over. "
|
| 174 |
f"Last: <code>{esc(clip(stats.last_fallback, 160))}</code>"
|
| 175 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
return "\n".join(lines)
|
| 177 |
|
| 178 |
|
|
|
|
| 140 |
return "uvloop" if type(loop).__module__.split(".")[0] == "uvloop" else "asyncio"
|
| 141 |
|
| 142 |
|
| 143 |
+
def speed_checks(parallel: bool, connections: int, stream: Optional[bool] = None) -> List[Tuple[str, str, bool]]:
|
| 144 |
+
"""``(short name, detail, fine)`` for the things transfer speed depends on; ``stream`` (when given) adds the streaming rename."""
|
| 145 |
crypto, loop = crypto_backend(), loop_backend()
|
| 146 |
if crypto == "cryptg":
|
| 147 |
encryption = ("cryptg", "cryptg", True)
|
|
|
|
| 151 |
encryption = ("slow Python crypto", "pure Python, slow: cryptg is missing", False)
|
| 152 |
event_loop = ("uvloop", "uvloop", True) if loop == "uvloop" else ("no uvloop", f"{loop} loop: uvloop is not running", False)
|
| 153 |
transfers = (f"parallel ×{connections}", f"on, {connections} connections", True) if parallel else ("parallel off", "off (FAST_TRANSFER=0)", False)
|
| 154 |
+
checks = [encryption, event_loop, transfers]
|
| 155 |
+
if stream is not None:
|
| 156 |
+
if not parallel:
|
| 157 |
+
checks.append(("no streaming", "off: it needs the parallel transfer", False))
|
| 158 |
+
elif stream:
|
| 159 |
+
checks.append(("streaming", "on: big files are uploaded while they download", True))
|
| 160 |
+
else:
|
| 161 |
+
checks.append(("no streaming", "off (STREAM_RENAME=0)", False))
|
| 162 |
+
return checks
|
| 163 |
|
| 164 |
|
| 165 |
+
def speed_summary(parallel: bool, connections: int, stream: Optional[bool] = None) -> str:
|
| 166 |
+
"""One line for the start-up notice: ``cryptg ✅ · uvloop ✅ · parallel ×8 ✅ · streaming ✅``."""
|
| 167 |
+
return " · ".join(f"{short} {'✅' if fine else '⚠️'}" for short, _detail, fine in speed_checks(parallel, connections, stream))
|
| 168 |
|
| 169 |
|
| 170 |
+
def format_speed(parallel: bool, connections: int, stats: Any, stream: Optional[bool] = None) -> str:
|
| 171 |
"""The Speed section of /status: what is switched on, and how the transfers since the start went."""
|
| 172 |
+
labels = ("🔐 Encryption", "🔁 Event loop", "🚀 Parallel transfer", "⬇⬆ Streaming rename")
|
| 173 |
lines = ["⚡ <b>Speed</b>"]
|
| 174 |
+
for label, (_short, detail, fine) in zip(labels, speed_checks(parallel, connections, stream)):
|
| 175 |
lines.append(f"{label}: {esc(detail)} {'✅' if fine else '⚠️'}")
|
| 176 |
small = f"{BIG_FILE_THRESHOLD // 2 ** 20} MB"
|
| 177 |
lines.append(f"⬇ <b>Downloads:</b> {stats.fast_down} parallel · {stats.standard_down} standard")
|
| 178 |
lines.append(f"⬆ <b>Uploads:</b> {stats.fast_up} parallel · {stats.small_up} up to {small} · {stats.standard_up} standard")
|
| 179 |
+
if stream is not None:
|
| 180 |
+
lines.append(f"⬇⬆ <b>Streamed renames:</b> {stats.streamed}")
|
| 181 |
if stats.fallbacks:
|
| 182 |
lines.append(
|
| 183 |
f"⚠️ The parallel transfer failed {stats.fallbacks}× and the standard, slower one took over. "
|
| 184 |
f"Last: <code>{esc(clip(stats.last_fallback, 160))}</code>"
|
| 185 |
)
|
| 186 |
+
if stats.stream_fallbacks:
|
| 187 |
+
lines.append(
|
| 188 |
+
f"⚠️ Streaming failed {stats.stream_fallbacks}× and those files were saved first and then uploaded. "
|
| 189 |
+
f"Last: <code>{esc(clip(stats.last_stream_fallback, 160))}</code>"
|
| 190 |
+
)
|
| 191 |
return "\n".join(lines)
|
| 192 |
|
| 193 |
|
tests/test_fasttransfer.py
CHANGED
|
@@ -394,3 +394,219 @@ async def test_file_helper_without_positional_io(tmp_path):
|
|
| 394 |
r._positional = False
|
| 395 |
assert r.pread(10, 0) == b"helloworld"
|
| 396 |
r.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 394 |
r._positional = False
|
| 395 |
assert r.pread(10, 0) == b"helloworld"
|
| 396 |
r.close()
|
| 397 |
+
|
| 398 |
+
|
| 399 |
+
# ---------------------------------------------------------------------------
|
| 400 |
+
# streamed copy: the download goes straight into the upload, nothing is saved
|
| 401 |
+
# ---------------------------------------------------------------------------
|
| 402 |
+
|
| 403 |
+
MIB = 1024 * 1024
|
| 404 |
+
|
| 405 |
+
|
| 406 |
+
@pytest.mark.parametrize("size", [1, ft.UPLOAD_PART - 1, ft.UPLOAD_PART, ft.UPLOAD_PART + 1, MIB - 1, MIB, MIB + 1, 3 * MIB // 2 + 7, 5 * MIB + 12345])
|
| 407 |
+
async def test_relay_copies_a_file_part_for_part(size):
|
| 408 |
+
blob = blob_of(size, seed=11)
|
| 409 |
+
store = {}
|
| 410 |
+
down = [FakeTransport(blob) for _ in range(3)]
|
| 411 |
+
up = [FakeTransport(store=store) for _ in range(3)]
|
| 412 |
+
seen = []
|
| 413 |
+
n = await ft.relay_parts(down, up, "loc", size, 4321, progress=lambda c, t: seen.append((c, t)))
|
| 414 |
+
expected = -(-size // ft.UPLOAD_PART)
|
| 415 |
+
assert n == expected and sorted(store) == list(range(expected))
|
| 416 |
+
assert all(fid == 4321 and total == expected for fid, total, _d in store.values())
|
| 417 |
+
sizes = [len(store[i][2]) for i in range(expected)]
|
| 418 |
+
assert all(s == ft.UPLOAD_PART for s in sizes[:-1]) # every part but the last is full size, whatever the download pieces were
|
| 419 |
+
assert b"".join(store[i][2] for i in range(expected)) == blob
|
| 420 |
+
assert seen[-1] == (size, size) and all(t == size for _c, t in seen)
|
| 421 |
+
assert [c for c, _t in seen] == sorted(c for c, _t in seen) # progress only ever moves forward
|
| 422 |
+
|
| 423 |
+
|
| 424 |
+
async def test_relay_fetches_every_part_once_and_both_sides_share_the_work():
|
| 425 |
+
size = 12 * MIB
|
| 426 |
+
blob = blob_of(size)
|
| 427 |
+
store = {}
|
| 428 |
+
down = [FakeTransport(blob) for _ in range(4)]
|
| 429 |
+
up = [FakeTransport(store=store) for _ in range(4)]
|
| 430 |
+
await ft.relay_parts(down, up, "loc", size, 1)
|
| 431 |
+
assert sorted(offset for t in down for offset, _limit in t.requests) == [i * MIB for i in range(12)]
|
| 432 |
+
assert sum(t.calls for t in up) == 24 # two upload parts for every downloaded one
|
| 433 |
+
assert sum(1 for t in down if t.calls) > 1 and sum(1 for t in up if t.calls) > 1
|
| 434 |
+
|
| 435 |
+
|
| 436 |
+
async def test_relay_with_far_more_connections_than_parts_still_finishes():
|
| 437 |
+
blob = blob_of(MIB + 100) # two downloaded parts, three upload parts
|
| 438 |
+
store = {}
|
| 439 |
+
down = [FakeTransport(blob) for _ in range(8)]
|
| 440 |
+
up = [FakeTransport(store=store) for _ in range(8)]
|
| 441 |
+
n = await asyncio.wait_for(ft.relay_parts(down, up, "loc", len(blob), 5), 5)
|
| 442 |
+
assert n == 3 and b"".join(store[i][2] for i in sorted(store)) == blob
|
| 443 |
+
|
| 444 |
+
|
| 445 |
+
async def test_relay_does_not_run_ahead_of_a_slow_upload():
|
| 446 |
+
parts, fetched, completed, ahead = 40, 0, 0, 0
|
| 447 |
+
blob = blob_of(parts * MIB)
|
| 448 |
+
|
| 449 |
+
class Down(FakeTransport):
|
| 450 |
+
async def get_file(self, location, offset, limit):
|
| 451 |
+
nonlocal fetched, ahead
|
| 452 |
+
data = await super().get_file(location, offset, limit)
|
| 453 |
+
fetched += 1
|
| 454 |
+
ahead = max(ahead, fetched - completed) # downloaded parts that are not sent yet
|
| 455 |
+
return data
|
| 456 |
+
|
| 457 |
+
class Up(FakeTransport):
|
| 458 |
+
async def save_part(self, file_id, part, total_parts, data):
|
| 459 |
+
nonlocal completed
|
| 460 |
+
await asyncio.sleep(0.005)
|
| 461 |
+
await super().save_part(file_id, part, total_parts, data)
|
| 462 |
+
if part % 2 == 1: # the second half of a downloaded part: that part is sent
|
| 463 |
+
completed += 1
|
| 464 |
+
|
| 465 |
+
store = {}
|
| 466 |
+
down, up = [Down(blob) for _ in range(4)], [Up(store=store) for _ in range(2)]
|
| 467 |
+
await ft.relay_parts(down, up, "loc", parts * MIB, 1)
|
| 468 |
+
held_back = max(ft.RELAY_BUFFER_MIN, 2 * len(up)) # the buffer, plus a part in each hand
|
| 469 |
+
assert ahead <= held_back + len(down) + len(up) and ahead < parts // 2 # memory stays small however big the file is
|
| 470 |
+
assert b"".join(store[i][2] for i in sorted(store)) == blob
|
| 471 |
+
|
| 472 |
+
|
| 473 |
+
async def test_relay_retries_failures_on_both_sides():
|
| 474 |
+
size = 3 * MIB + 7
|
| 475 |
+
blob = blob_of(size)
|
| 476 |
+
store = {}
|
| 477 |
+
down = [FakeTransport(blob, fail_first=2), FakeTransport(blob, fail_first=3, short_once=True)]
|
| 478 |
+
up = [FakeTransport(store=store, fail_first=2), FakeTransport(store=store)]
|
| 479 |
+
await ft.relay_parts(down, up, "loc", size, 1)
|
| 480 |
+
assert b"".join(store[i][2] for i in sorted(store)) == blob
|
| 481 |
+
|
| 482 |
+
|
| 483 |
+
async def test_relay_gives_up_when_a_connection_keeps_failing():
|
| 484 |
+
blob = blob_of(2 * MIB)
|
| 485 |
+
broken = FakeTransport(store={}, fail_first=10_000)
|
| 486 |
+
with pytest.raises(ConnectionError):
|
| 487 |
+
await asyncio.wait_for(ft.relay_parts([FakeTransport(blob)], [broken], "loc", len(blob), 1), 10)
|
| 488 |
+
assert broken.calls == ft.MAX_RETRIES + 1
|
| 489 |
+
|
| 490 |
+
|
| 491 |
+
@pytest.mark.parametrize("broken_side", ["down", "up"])
|
| 492 |
+
async def test_relay_stops_both_sides_when_one_fails_for_good(broken_side):
|
| 493 |
+
class FileReferenceExpiredError(Exception): # retrying cannot help
|
| 494 |
+
pass
|
| 495 |
+
|
| 496 |
+
class LateFatalUp(FakeTransport):
|
| 497 |
+
async def save_part(self, file_id, part, total_parts, data):
|
| 498 |
+
await asyncio.sleep(0.05) # long enough for the downloads to fill the buffer and wait
|
| 499 |
+
raise FileReferenceExpiredError("expired")
|
| 500 |
+
|
| 501 |
+
blob = blob_of(40 * MIB)
|
| 502 |
+
if broken_side == "down":
|
| 503 |
+
down, up = [FakeTransport(blob, fatal=FileReferenceExpiredError("expired"))], [FakeTransport(store={}) for _ in range(2)]
|
| 504 |
+
else:
|
| 505 |
+
down, up = [FakeTransport(blob) for _ in range(3)], [LateFatalUp() for _ in range(2)]
|
| 506 |
+
with pytest.raises(FileReferenceExpiredError): # and does not hang on a full or an empty buffer
|
| 507 |
+
await asyncio.wait_for(ft.relay_parts(down, up, "loc", len(blob), 1), 5)
|
| 508 |
+
assert sum(t.calls for t in down) < 40 # the download did not carry on to the end
|
| 509 |
+
|
| 510 |
+
|
| 511 |
+
async def test_relay_cancel_stops_both_sides():
|
| 512 |
+
cancel = asyncio.Event()
|
| 513 |
+
|
| 514 |
+
class SlowDown(FakeTransport):
|
| 515 |
+
async def get_file(self, location, offset, limit):
|
| 516 |
+
await asyncio.sleep(0.005)
|
| 517 |
+
if offset >= 3 * MIB:
|
| 518 |
+
cancel.set()
|
| 519 |
+
return b"\0" * limit
|
| 520 |
+
|
| 521 |
+
up = [FakeTransport(store={}) for _ in range(3)]
|
| 522 |
+
with pytest.raises(Cancelled):
|
| 523 |
+
await asyncio.wait_for(ft.relay_parts([SlowDown() for _ in range(3)], up, "loc", 64 * MIB, 1, cancel=cancel), 5)
|
| 524 |
+
assert sum(t.calls for t in up) < 2 * 64
|
| 525 |
+
cancel.set() # cancelled before it starts
|
| 526 |
+
untouched = FakeTransport(store={})
|
| 527 |
+
with pytest.raises(Cancelled):
|
| 528 |
+
await ft.relay_parts([FakeTransport(b"x" * MIB)], [untouched], "loc", MIB, 1, cancel=cancel)
|
| 529 |
+
assert untouched.calls == 0
|
| 530 |
+
|
| 531 |
+
|
| 532 |
+
async def test_relay_refuses_what_it_cannot_do():
|
| 533 |
+
ok = [FakeTransport(b"x" * 10)]
|
| 534 |
+
with pytest.raises(ValueError): # an empty file has no parts
|
| 535 |
+
await ft.relay_parts(ok, ok, "loc", 0, 1)
|
| 536 |
+
with pytest.raises(ValueError): # no connection to fetch with, or none to send with
|
| 537 |
+
await ft.relay_parts([], ok, "loc", 10, 1)
|
| 538 |
+
with pytest.raises(ValueError):
|
| 539 |
+
await ft.relay_parts(ok, [], "loc", 10, 1)
|
| 540 |
+
with pytest.raises(ValueError): # Telegram wants equal parts: a download part must be cut into whole ones
|
| 541 |
+
await ft.relay_parts(ok, ok, "loc", 10, 1, part_size=1000, send_size=512)
|
| 542 |
+
|
| 543 |
+
|
| 544 |
+
class RelayClient:
|
| 545 |
+
class session:
|
| 546 |
+
dc_id = 2
|
| 547 |
+
|
| 548 |
+
|
| 549 |
+
async def test_relay_fetches_from_the_files_dc_sends_to_ours_and_returns_an_input_file_big(monkeypatch):
|
| 550 |
+
from telethon.tl.types import InputFileBig
|
| 551 |
+
|
| 552 |
+
size = ft.BIG_FILE_THRESHOLD + 12345
|
| 553 |
+
blob = blob_of(size, seed=5)
|
| 554 |
+
store, opened = {}, []
|
| 555 |
+
|
| 556 |
+
async def fake_open(client, dc_id, connections):
|
| 557 |
+
group = [FakeTransport(blob, store=store, jitter=False) for _ in range(connections)]
|
| 558 |
+
opened.append((dc_id, group))
|
| 559 |
+
return group
|
| 560 |
+
|
| 561 |
+
monkeypatch.setattr(ft, "open_transports", fake_open)
|
| 562 |
+
seen = []
|
| 563 |
+
result = await ft.relay(RelayClient(), a_document(size, dc_id=4), "Big Name.mkv", connections=3, progress=lambda c, t: seen.append(c))
|
| 564 |
+
assert sorted(dc for dc, _group in opened) == [2, 4] and all(len(g) == 3 for _dc, g in opened)
|
| 565 |
+
assert isinstance(result, InputFileBig) and result.name == "Big Name.mkv"
|
| 566 |
+
assert result.parts == len(store) == -(-size // ft.UPLOAD_PART) and result.id == next(iter(store.values()))[0]
|
| 567 |
+
assert b"".join(store[i][2] for i in sorted(store)) == blob
|
| 568 |
+
assert all(t.closed for _dc, group in opened for t in group) and seen[-1] == size
|
| 569 |
+
assert ft.stats == ft.TransferStats() # counting what was streamed is up to the caller, once the file is sent
|
| 570 |
+
|
| 571 |
+
|
| 572 |
+
async def test_relay_closes_its_connections_when_it_fails_and_when_it_cannot_open_them(monkeypatch):
|
| 573 |
+
opened = []
|
| 574 |
+
|
| 575 |
+
async def failing_open(client, dc_id, connections):
|
| 576 |
+
group = [FakeTransport(b"", fail_first=10_000) for _ in range(connections)]
|
| 577 |
+
opened.append(group)
|
| 578 |
+
return group
|
| 579 |
+
|
| 580 |
+
monkeypatch.setattr(ft, "open_transports", failing_open)
|
| 581 |
+
with pytest.raises(ConnectionError):
|
| 582 |
+
await ft.relay(RelayClient(), a_document(ft.BIG_FILE_THRESHOLD + 1, dc_id=4), "x.mkv", connections=2)
|
| 583 |
+
assert len(opened) == 2 and all(t.closed for group in opened for t in group)
|
| 584 |
+
|
| 585 |
+
opened.clear()
|
| 586 |
+
|
| 587 |
+
async def half_open(client, dc_id, connections):
|
| 588 |
+
if dc_id == 4:
|
| 589 |
+
raise OSError("no route to DC 4")
|
| 590 |
+
group = [FakeTransport() for _ in range(connections)]
|
| 591 |
+
opened.append(group)
|
| 592 |
+
return group
|
| 593 |
+
|
| 594 |
+
monkeypatch.setattr(ft, "open_transports", half_open)
|
| 595 |
+
with pytest.raises(OSError):
|
| 596 |
+
await ft.relay(RelayClient(), a_document(ft.BIG_FILE_THRESHOLD + 1, dc_id=4), "x.mkv", connections=2)
|
| 597 |
+
assert len(opened) == 1 and all(t.closed for t in opened[0]) # what did open is not left connected
|
| 598 |
+
|
| 599 |
+
|
| 600 |
+
def test_file_part_errors_are_told_apart_from_the_rest():
|
| 601 |
+
from telethon import errors
|
| 602 |
+
|
| 603 |
+
assert ft.is_file_part_error(errors.FilePartsInvalidError(request=None))
|
| 604 |
+
assert ft.is_file_part_error(errors.FilePartMissingError(request=None, capture=3))
|
| 605 |
+
assert not ft.is_file_part_error(errors.MediaCaptionTooLongError(request=None))
|
| 606 |
+
assert not ft.is_file_part_error(RuntimeError("FilePart"))
|
| 607 |
+
|
| 608 |
+
|
| 609 |
+
def test_a_failed_stream_is_remembered_apart_from_the_parallel_failures():
|
| 610 |
+
stats = ft.TransferStats()
|
| 611 |
+
stats.stream_failed(ValueError("boom"))
|
| 612 |
+
assert (stats.stream_fallbacks, stats.last_stream_fallback, stats.fallbacks, stats.last_fallback) == (1, "ValueError: boom", 0, "")
|
tests/test_formats.py
CHANGED
|
@@ -128,14 +128,14 @@ def test_validate_name():
|
|
| 128 |
|
| 129 |
|
| 130 |
def fmt_with(**kw):
|
| 131 |
-
base = {"id": 1, "name": "F", "has_thumb": False, "template": "T {title}",
|
| 132 |
"meta": dict(DEFAULT_FORMAT["meta"]), "keep_audio": [], "keep_subtitle": []}
|
| 133 |
base.update(kw)
|
| 134 |
return base
|
| 135 |
|
| 136 |
|
| 137 |
def test_apply_format_lays_the_format_over_the_settings_without_mutating_them():
|
| 138 |
-
settings = dict(DEFAULT_SETTINGS, mode="caption", caption="<
|
| 139 |
target_channel=-100123, metadata_on=False, keep_audio=["french"])
|
| 140 |
settings["meta"] = dict(DEFAULT_SETTINGS["meta"], title="GLOBAL TITLE")
|
| 141 |
fmt = fmt_with(template="One Piece - E{episode}", meta=dict(DEFAULT_FORMAT["meta"], author="@Me"), keep_subtitle=["english"])
|
|
@@ -144,10 +144,22 @@ def test_apply_format_lays_the_format_over_the_settings_without_mutating_them():
|
|
| 144 |
assert out["meta"]["author"] == "@Me" and out["meta"]["title"] == "" # the format's own metadata, not the global one
|
| 145 |
assert out["keep_audio"] == [] and out["keep_subtitle"] == ["english"]
|
| 146 |
assert out["metadata_on"] is True
|
|
|
|
| 147 |
# everything else is still the user's
|
| 148 |
-
assert (out["mode"], out["
|
| 149 |
-
"caption", "<b>{filename}</b>", "document", ["x"], -100123)
|
| 150 |
assert settings["template"] == DEFAULT_SETTINGS["template"] and settings["meta"]["title"] == "GLOBAL TITLE"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
|
| 152 |
|
| 153 |
def test_a_format_without_metadata_leaves_the_file_alone():
|
|
@@ -200,3 +212,65 @@ async def test_editing_a_deleted_format_raises_lookup_error(db):
|
|
| 200 |
await apply_setting(store, U, "template", "x {title}")
|
| 201 |
with pytest.raises(LookupError):
|
| 202 |
await store.get_settings(U)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
|
| 129 |
|
| 130 |
def fmt_with(**kw):
|
| 131 |
+
base = {"id": 1, "name": "F", "has_thumb": False, "template": "T {title}", "caption": DEFAULT_FORMAT["caption"],
|
| 132 |
"meta": dict(DEFAULT_FORMAT["meta"]), "keep_audio": [], "keep_subtitle": []}
|
| 133 |
base.update(kw)
|
| 134 |
return base
|
| 135 |
|
| 136 |
|
| 137 |
def test_apply_format_lays_the_format_over_the_settings_without_mutating_them():
|
| 138 |
+
settings = dict(DEFAULT_SETTINGS, mode="caption", caption="<i>{original}</i>", file_type="document", remove_words=["x"],
|
| 139 |
target_channel=-100123, metadata_on=False, keep_audio=["french"])
|
| 140 |
settings["meta"] = dict(DEFAULT_SETTINGS["meta"], title="GLOBAL TITLE")
|
| 141 |
fmt = fmt_with(template="One Piece - E{episode}", meta=dict(DEFAULT_FORMAT["meta"], author="@Me"), keep_subtitle=["english"])
|
|
|
|
| 144 |
assert out["meta"]["author"] == "@Me" and out["meta"]["title"] == "" # the format's own metadata, not the global one
|
| 145 |
assert out["keep_audio"] == [] and out["keep_subtitle"] == ["english"]
|
| 146 |
assert out["metadata_on"] is True
|
| 147 |
+
assert out["caption"] == "<b>{filename}</b>" # the format's own, not the user's <i>{original}</i>
|
| 148 |
# everything else is still the user's
|
| 149 |
+
assert (out["mode"], out["file_type"], out["remove_words"], out["target_channel"]) == ("caption", "document", ["x"], -100123)
|
|
|
|
| 150 |
assert settings["template"] == DEFAULT_SETTINGS["template"] and settings["meta"]["title"] == "GLOBAL TITLE"
|
| 151 |
+
assert settings["caption"] == "<i>{original}</i>"
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def test_a_format_brings_its_own_caption_and_never_borrows_the_users():
|
| 155 |
+
mine = dict(DEFAULT_SETTINGS, caption="<i>{original}</i> by me")
|
| 156 |
+
assert apply_format(mine, fmt_with(caption="<b>{title}</b> · {size}"))["caption"] == "<b>{title}</b> · {size}"
|
| 157 |
+
assert apply_format(mine, fmt_with())["caption"] == "<b>{filename}</b>" # none of its own: the new file name in bold
|
| 158 |
+
odd = fmt_with()
|
| 159 |
+
del odd["caption"] # a snapshot without the field
|
| 160 |
+
assert apply_format(mine, odd)["caption"] == "<b>{filename}</b>"
|
| 161 |
+
assert apply_format(mine, fmt_with(caption=""))["caption"] == "<b>{filename}</b>"
|
| 162 |
+
assert mine["caption"] == "<i>{original}</i> by me"
|
| 163 |
|
| 164 |
|
| 165 |
def test_a_format_without_metadata_leaves_the_file_alone():
|
|
|
|
| 212 |
await apply_setting(store, U, "template", "x {title}")
|
| 213 |
with pytest.raises(LookupError):
|
| 214 |
await store.get_settings(U)
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
# ---------------------------------------------------------------------------
|
| 218 |
+
# a format's own caption
|
| 219 |
+
# ---------------------------------------------------------------------------
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
async def test_a_format_starts_with_the_file_name_in_bold_and_keeps_a_caption_of_its_own(db):
|
| 223 |
+
fmt = await db.create_format(U, "One Piece")
|
| 224 |
+
assert fmt["caption"] == "<b>{filename}</b>" == DEFAULT_SETTINGS["caption"]
|
| 225 |
+
out = await db.update_format(U, fmt["id"], caption="<i>{filename}</i> · {size}")
|
| 226 |
+
assert out["caption"] == "<i>{filename}</i> · {size}"
|
| 227 |
+
await db.update_format(U, fmt["id"], template="X {title}", meta={"title": "T"}) # other edits leave it alone
|
| 228 |
+
other = Database(db.url)
|
| 229 |
+
assert (await other.get_format(U, fmt["id"]))["caption"] == "<i>{filename}</i> · {size}"
|
| 230 |
+
await other.close()
|
| 231 |
+
assert (await db.list_formats(U))[0]["caption"] == "<i>{filename}</i> · {size}"
|
| 232 |
+
assert await db.get_settings(U) == DEFAULT_SETTINGS # the user's own caption is a different thing
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
async def test_formats_saved_before_captions_existed_get_the_default_one(db):
|
| 236 |
+
import json
|
| 237 |
+
|
| 238 |
+
from bot.db import Format
|
| 239 |
+
|
| 240 |
+
async with db._sessions() as session:
|
| 241 |
+
session.add(Format(user_id=U, name="Old", data=json.dumps({"template": "Old - {title}", "keep_audio": ["english"]})))
|
| 242 |
+
session.add(Format(user_id=U, name="Broken", data=json.dumps({"caption": 7})))
|
| 243 |
+
session.add(Format(user_id=U, name="Blank", data=json.dumps({"caption": " "})))
|
| 244 |
+
await session.commit()
|
| 245 |
+
old, broken, blank = await db.list_formats(U)
|
| 246 |
+
assert old["template"] == "Old - {title}" and old["keep_audio"] == ["english"] and old["caption"] == "<b>{filename}</b>"
|
| 247 |
+
assert broken["caption"] == "<b>{filename}</b>" and blank["caption"] == "<b>{filename}</b>"
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
async def test_a_copy_of_a_format_has_its_caption(db):
|
| 251 |
+
fmt = await db.create_format(U, "One Piece")
|
| 252 |
+
await db.update_format(U, fmt["id"], caption="<code>{filename}</code>")
|
| 253 |
+
copy = await db.copy_format(V, U, fmt["id"])
|
| 254 |
+
assert copy["caption"] == "<code>{filename}</code>"
|
| 255 |
+
await db.update_format(V, copy["id"], caption="<b>{title}</b>")
|
| 256 |
+
assert (await db.get_format(U, fmt["id"]))["caption"] == "<code>{filename}</code>"
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
async def test_settings_ops_edit_a_formats_caption_with_the_checks_of_the_global_one(db):
|
| 260 |
+
fmt = await db.create_format(U, "One Piece")
|
| 261 |
+
store = FormatSettings(db, fmt["id"])
|
| 262 |
+
ok, msg = await apply_setting(store, U, "caption", "<b>{filename}</b> · {size} · {duration}")
|
| 263 |
+
assert ok and "Caption template saved" in msg and "Unknown" not in msg
|
| 264 |
+
assert (await db.get_format(U, fmt["id"]))["caption"] == "<b>{filename}</b> · {size} · {duration}"
|
| 265 |
+
ok, msg = await apply_setting(store, U, "caption", "<i>{nonsense}</i>")
|
| 266 |
+
assert ok and "Unknown variables" in msg and "{nonsense}" in msg # kept as plain text, like for the global one
|
| 267 |
+
ok, msg = await apply_setting(store, U, "caption", "x" * 801)
|
| 268 |
+
assert not ok and "too long" in msg and (await db.get_format(U, fmt["id"]))["caption"] == "<i>{nonsense}</i>"
|
| 269 |
+
ok, msg = await apply_setting(store, U, "caption", "")
|
| 270 |
+
assert not ok and "can't be empty" in msg
|
| 271 |
+
for word in ("reset", "off", "none", "clear"):
|
| 272 |
+
await apply_setting(store, U, "caption", "<u>{filename}</u>")
|
| 273 |
+
ok, _ = await apply_setting(store, U, "caption", word)
|
| 274 |
+
assert ok and (await db.get_format(U, fmt["id"]))["caption"] == "<b>{filename}</b>", word # the file name in bold again
|
| 275 |
+
assert (await store.get_settings(U))["caption"] == "<b>{filename}</b>"
|
| 276 |
+
assert await db.get_settings(U) == DEFAULT_SETTINGS
|
tests/test_formats_app.py
CHANGED
|
@@ -90,17 +90,18 @@ async def test_the_format_screen_shows_every_field_and_has_a_button_for_each(ctx
|
|
| 90 |
fmt = await make_format(ctx, meta={"title": "One Piece", "video": "HD"}, keep_audio=["japanese", "2"])
|
| 91 |
cb = await click(ctx, OWNER, f"fm:v:{fmt['id']}")
|
| 92 |
text, buttons = cb.edits[-1]
|
| 93 |
-
for label in ("Template", "Thumbnail", "Title", "Author", "Artist", "Audio", "Subtitle", "Video", "Keep Audio", "Keep Subtitle"):
|
| 94 |
assert label in text, label
|
| 95 |
assert "One Piece - E{episode}" in text and "Japanese, 2" in text and "your default thumbnail" in text
|
| 96 |
assert "<b>Title</b> ▸ <code>One Piece</code>" in text and "<b>Video</b> ▸ <code>HD</code>" in text # values land on the right line
|
| 97 |
assert "<b>Author</b> ▸ —" in text
|
| 98 |
data = data_of(buttons)
|
| 99 |
i = fmt["id"]
|
| 100 |
-
for field in ("name", "template", "title", "author", "artist", "audio", "subtitle", "video", "keep_audio", "keep_subtitle"):
|
| 101 |
assert f"fm:in:{i}:{field}" in data, field
|
| 102 |
assert {f"fm:th:{i}", f"fm:del:{i}", "m:formats", "x:close"} <= set(data)
|
| 103 |
assert f"fm:thd:{i}" not in data # nothing to delete yet
|
|
|
|
| 104 |
|
| 105 |
|
| 106 |
async def test_editing_each_field_goes_through_the_same_checks_as_the_settings(ctx):
|
|
@@ -108,6 +109,7 @@ async def test_editing_each_field_goes_through_the_same_checks_as_the_settings(c
|
|
| 108 |
i = fmt["id"]
|
| 109 |
edits = {
|
| 110 |
"template": ("[S{season}E{episode}] {title}", lambda f: f["template"] == "[S{season}E{episode}] {title}"),
|
|
|
|
| 111 |
"title": ("One Piece", lambda f: f["meta"]["title"] == "One Piece"),
|
| 112 |
"author": ("@Me", lambda f: f["meta"]["author"] == "@Me"),
|
| 113 |
"artist": ("@Me", lambda f: f["meta"]["artist"] == "@Me"),
|
|
@@ -128,7 +130,8 @@ async def test_editing_each_field_goes_through_the_same_checks_as_the_settings(c
|
|
| 128 |
chat, text, kw = ctx.client.edited[-1]
|
| 129 |
assert kw["message"] == 700 + n and "🗂 One Piece" in text, field # the format screen is back in the same message
|
| 130 |
# none of this touched the user's own settings
|
| 131 |
-
|
|
|
|
| 132 |
|
| 133 |
|
| 134 |
async def test_invalid_values_keep_the_prompt_open_and_cancel_returns_to_the_format(ctx):
|
|
@@ -151,6 +154,42 @@ async def test_clearing_a_field_with_off(ctx):
|
|
| 151 |
assert got["meta"]["title"] == "" and got["keep_audio"] == []
|
| 152 |
|
| 153 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
async def test_renaming_a_format(ctx):
|
| 155 |
fmt = await make_format(ctx, "One Piece")
|
| 156 |
await make_format(ctx, "Dragon Ball")
|
|
@@ -418,7 +457,7 @@ def test_the_format_screens_fit_in_a_caption_even_with_the_longest_values():
|
|
| 418 |
|
| 419 |
longest = ["klingon-language-tag-x"] * 12
|
| 420 |
fmt = {
|
| 421 |
-
"id": 12, "name": "N" * 30, "has_thumb": True, "template": "T" * 200,
|
| 422 |
"meta": {k: "M" * 200 for k in ("title", "author", "artist", "audio", "subtitle", "video")},
|
| 423 |
"keep_audio": longest, "keep_subtitle": longest,
|
| 424 |
}
|
|
|
|
| 90 |
fmt = await make_format(ctx, meta={"title": "One Piece", "video": "HD"}, keep_audio=["japanese", "2"])
|
| 91 |
cb = await click(ctx, OWNER, f"fm:v:{fmt['id']}")
|
| 92 |
text, buttons = cb.edits[-1]
|
| 93 |
+
for label in ("Template", "Caption", "Thumbnail", "Title", "Author", "Artist", "Audio", "Subtitle", "Video", "Keep Audio", "Keep Subtitle"):
|
| 94 |
assert label in text, label
|
| 95 |
assert "One Piece - E{episode}" in text and "Japanese, 2" in text and "your default thumbnail" in text
|
| 96 |
assert "<b>Title</b> ▸ <code>One Piece</code>" in text and "<b>Video</b> ▸ <code>HD</code>" in text # values land on the right line
|
| 97 |
assert "<b>Author</b> ▸ —" in text
|
| 98 |
data = data_of(buttons)
|
| 99 |
i = fmt["id"]
|
| 100 |
+
for field in ("name", "template", "caption", "title", "author", "artist", "audio", "subtitle", "video", "keep_audio", "keep_subtitle"):
|
| 101 |
assert f"fm:in:{i}:{field}" in data, field
|
| 102 |
assert {f"fm:th:{i}", f"fm:del:{i}", "m:formats", "x:close"} <= set(data)
|
| 103 |
assert f"fm:thd:{i}" not in data # nothing to delete yet
|
| 104 |
+
assert f"fm:cr:{i}" not in data and "<b>Caption</b> ▸ <code><b>{filename}</b></code> · default" in text # nor to reset
|
| 105 |
|
| 106 |
|
| 107 |
async def test_editing_each_field_goes_through_the_same_checks_as_the_settings(ctx):
|
|
|
|
| 109 |
i = fmt["id"]
|
| 110 |
edits = {
|
| 111 |
"template": ("[S{season}E{episode}] {title}", lambda f: f["template"] == "[S{season}E{episode}] {title}"),
|
| 112 |
+
"caption": ("<i>{filename}</i> · {size}", lambda f: f["caption"] == "<i>{filename}</i> · {size}"),
|
| 113 |
"title": ("One Piece", lambda f: f["meta"]["title"] == "One Piece"),
|
| 114 |
"author": ("@Me", lambda f: f["meta"]["author"] == "@Me"),
|
| 115 |
"artist": ("@Me", lambda f: f["meta"]["artist"] == "@Me"),
|
|
|
|
| 130 |
chat, text, kw = ctx.client.edited[-1]
|
| 131 |
assert kw["message"] == 700 + n and "🗂 One Piece" in text, field # the format screen is back in the same message
|
| 132 |
# none of this touched the user's own settings
|
| 133 |
+
mine = await ctx.db.get_settings(OWNER)
|
| 134 |
+
assert mine["meta"]["title"] == "" and mine["keep_audio"] == [] and mine["caption"] == "<b>{filename}</b>"
|
| 135 |
|
| 136 |
|
| 137 |
async def test_invalid_values_keep_the_prompt_open_and_cancel_returns_to_the_format(ctx):
|
|
|
|
| 154 |
assert got["meta"]["title"] == "" and got["keep_audio"] == []
|
| 155 |
|
| 156 |
|
| 157 |
+
async def test_a_formats_caption_is_its_own_and_can_be_reset(ctx):
|
| 158 |
+
await ctx.db.update_settings(OWNER, caption="<i>MY OWN {filename}</i>")
|
| 159 |
+
fmt = await make_format(ctx)
|
| 160 |
+
i = fmt["id"]
|
| 161 |
+
cb = await click(ctx, OWNER, f"fm:in:{i}:caption", msg_id=710)
|
| 162 |
+
assert cb.edits[-1][0] == panels.FORMAT_PROMPTS["caption"] and data_of(cb.edits[-1][1]) == [f"fm:cx:{i}"]
|
| 163 |
+
assert "reset" in cb.edits[-1][0]
|
| 164 |
+
|
| 165 |
+
ev = await say(ctx, OWNER, "x" * 801) # the same checks as the global caption: too long keeps the prompt open
|
| 166 |
+
assert "too long" in ev.texts[0] and "Try again" in ev.texts[0] and OWNER in ctx.app.pending
|
| 167 |
+
assert (await ctx.db.get_format(OWNER, i))["caption"] == "<b>{filename}</b>"
|
| 168 |
+
ev = await say(ctx, OWNER, "<b>{filename}</b> · {size} · {nonsense}")
|
| 169 |
+
assert ev.deleted and OWNER not in ctx.app.pending
|
| 170 |
+
chat, text, kw = ctx.client.edited[-1]
|
| 171 |
+
assert kw["message"] == 710 and "Caption template saved" in text and "Unknown variables" in text and "{nonsense}" in text
|
| 172 |
+
assert "<b>Caption</b> ▸ <code><b>{filename}</b> · {size} · {nonsense}</code>" in text and "· default" not in text
|
| 173 |
+
assert f"fm:cr:{i}" in data_of(kw["buttons"]) # a custom caption can be reset with one tap
|
| 174 |
+
|
| 175 |
+
cb = await click(ctx, OWNER, f"fm:cr:{i}")
|
| 176 |
+
assert cb.toasts == ["Caption reset."] and (await ctx.db.get_format(OWNER, i))["caption"] == "<b>{filename}</b>"
|
| 177 |
+
assert "· default" in cb.edits[-1][0] and f"fm:cr:{i}" not in data_of(cb.edits[-1][1])
|
| 178 |
+
assert (await ctx.db.get_settings(OWNER))["caption"] == "<i>MY OWN {filename}</i>" # the user's own caption was never touched
|
| 179 |
+
|
| 180 |
+
await ctx.db.update_format(OWNER, i, caption="<u>{filename}</u>") # sending reset in the prompt does the same
|
| 181 |
+
await click(ctx, OWNER, f"fm:in:{i}:caption")
|
| 182 |
+
await say(ctx, OWNER, "reset")
|
| 183 |
+
assert (await ctx.db.get_format(OWNER, i))["caption"] == "<b>{filename}</b>" and OWNER not in ctx.app.pending
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
async def test_the_reset_caption_button_of_a_deleted_format_says_so(ctx):
|
| 187 |
+
fmt = await make_format(ctx, caption="<u>{filename}</u>")
|
| 188 |
+
await ctx.db.delete_format(OWNER, fmt["id"])
|
| 189 |
+
cb = await click(ctx, OWNER, f"fm:cr:{fmt['id']}")
|
| 190 |
+
assert cb.alerts == ["That format no longer exists."] and "FORMATS" in cb.edits[-1][0]
|
| 191 |
+
|
| 192 |
+
|
| 193 |
async def test_renaming_a_format(ctx):
|
| 194 |
fmt = await make_format(ctx, "One Piece")
|
| 195 |
await make_format(ctx, "Dragon Ball")
|
|
|
|
| 457 |
|
| 458 |
longest = ["klingon-language-tag-x"] * 12
|
| 459 |
fmt = {
|
| 460 |
+
"id": 12, "name": "N" * 30, "has_thumb": True, "template": "T" * 200, "caption": "C" * 800,
|
| 461 |
"meta": {k: "M" * 200 for k in ("title", "author", "artist", "audio", "subtitle", "video")},
|
| 462 |
"keep_audio": longest, "keep_subtitle": longest,
|
| 463 |
}
|
tests/test_panels.py
CHANGED
|
@@ -102,7 +102,7 @@ def test_code_each_makes_one_escaped_block_per_item():
|
|
| 102 |
|
| 103 |
def sample_format(**over):
|
| 104 |
fmt = {"id": 12, "name": "One Piece", "has_thumb": False, "template": "[S{season}E{episode}] {title}",
|
| 105 |
-
"meta": {k: "" for k in ("title", "author", "artist", "audio", "subtitle", "video")}, "keep_audio": [], "keep_subtitle": []}
|
| 106 |
fmt.update(over)
|
| 107 |
return fmt
|
| 108 |
|
|
@@ -224,3 +224,15 @@ def test_to_buttons_builds_callback_buttons():
|
|
| 224 |
|
| 225 |
def test_known_languages_lists_names():
|
| 226 |
assert "english" in panels.known_languages()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
|
| 103 |
def sample_format(**over):
|
| 104 |
fmt = {"id": 12, "name": "One Piece", "has_thumb": False, "template": "[S{season}E{episode}] {title}",
|
| 105 |
+
"caption": "<b>{filename}</b>", "meta": {k: "" for k in ("title", "author", "artist", "audio", "subtitle", "video")}, "keep_audio": [], "keep_subtitle": []}
|
| 106 |
fmt.update(over)
|
| 107 |
return fmt
|
| 108 |
|
|
|
|
| 224 |
|
| 225 |
def test_known_languages_lists_names():
|
| 226 |
assert "english" in panels.known_languages()
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def test_the_format_screen_shows_its_caption_and_offers_to_reset_a_custom_one():
|
| 230 |
+
plain = panels.format_view(sample_format())
|
| 231 |
+
assert "<b>Caption</b> ▸ <code><b>{filename}</b></code> · default" in plain[0]
|
| 232 |
+
assert "fm:in:12:caption" in [d for row in plain[1] for _l, d in row] and "fm:cr:12" not in [d for row in plain[1] for _l, d in row]
|
| 233 |
+
|
| 234 |
+
custom = panels.format_view(sample_format(caption="<i>{filename}</i> · {size}"))
|
| 235 |
+
assert "<b>Caption</b> ▸ <code><i>{filename}</i> · {size}</code>" in custom[0] and "default" not in custom[0].split("Thumbnail")[0]
|
| 236 |
+
assert ("↩ Reset Caption", "fm:cr:12") in [button for row in custom[1] for button in row]
|
| 237 |
+
assert panels.without_inline(custom[1]) == panels.format_view(sample_format(caption="<i>{filename}</i> · {size}"), share_inline=False)[1]
|
| 238 |
+
assert "caption" in panels.FORMAT_PROMPTS["caption"].lower() and "<code>reset</code>" in panels.FORMAT_PROMPTS["caption"]
|
tests/test_pipeline.py
CHANGED
|
@@ -371,7 +371,43 @@ async def test_everything_else_still_comes_from_the_users_settings(env):
|
|
| 371 |
job.new_name = "One Piece - E5.mkv"
|
| 372 |
await renamer.process(job)
|
| 373 |
(target, _f, kw), = client.sent
|
| 374 |
-
assert target == -100777 and kw["force_document"] is True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 375 |
|
| 376 |
|
| 377 |
@needs_ffmpeg
|
|
|
|
| 371 |
job.new_name = "One Piece - E5.mkv"
|
| 372 |
await renamer.process(job)
|
| 373 |
(target, _f, kw), = client.sent
|
| 374 |
+
assert target == -100777 and kw["force_document"] is True # the file type and the channel are still the user's
|
| 375 |
+
assert kw["caption"] == "<b>One Piece - E5</b>" # the caption is not: this format has none, so it is the new name in bold
|
| 376 |
+
|
| 377 |
+
|
| 378 |
+
@needs_ffmpeg
|
| 379 |
+
async def test_a_format_sends_its_own_caption(env):
|
| 380 |
+
from bot.utils import human_size
|
| 381 |
+
|
| 382 |
+
await env.db.update_settings(42, caption="<i>THE USER'S CAPTION {filename}</i>")
|
| 383 |
+
fmt = await make_fmt(env, caption="<b>{filename}</b> · {original} · {size}")
|
| 384 |
+
client, renamer, job = make(env)
|
| 385 |
+
job.fmt = fmt
|
| 386 |
+
job.new_name = "One Piece - E5.mkv"
|
| 387 |
+
await renamer.process(job)
|
| 388 |
+
(_t, _f, kw), = client.sent
|
| 389 |
+
size = (env.media_dir / "sample.mkv").stat().st_size
|
| 390 |
+
assert kw["caption"] == f"<b>One Piece - E5</b> · {job.file_name} · {human_size(size)}" and kw["parse_mode"] == "html"
|
| 391 |
+
|
| 392 |
+
|
| 393 |
+
@needs_ffmpeg
|
| 394 |
+
async def test_a_format_without_a_caption_sends_the_new_name_in_bold_not_the_users_caption(env):
|
| 395 |
+
await env.db.update_settings(42, caption="<i>THE USER'S CAPTION {filename}</i>")
|
| 396 |
+
fmt = await make_fmt(env)
|
| 397 |
+
client, renamer, job = make(env)
|
| 398 |
+
job.fmt = fmt
|
| 399 |
+
job.new_name = "One Piece - E5.mkv"
|
| 400 |
+
await renamer.process(job)
|
| 401 |
+
assert client.sent[0][2]["caption"] == "<b>One Piece - E5</b>"
|
| 402 |
+
|
| 403 |
+
|
| 404 |
+
@needs_ffmpeg
|
| 405 |
+
async def test_a_rename_without_a_format_still_uses_the_users_caption(env):
|
| 406 |
+
await env.db.update_settings(42, caption="<i>{filename}</i> ({quality})")
|
| 407 |
+
await env.db.create_format(42, "Unused", template="X") # having formats changes nothing for the normal renames
|
| 408 |
+
client, renamer, job = make(env)
|
| 409 |
+
await renamer.process(job)
|
| 410 |
+
assert client.sent[0][2]["caption"] == "<i>[S01E05] Tokyo Revengers [1080p]</i> (1080p)"
|
| 411 |
|
| 412 |
|
| 413 |
@needs_ffmpeg
|
tests/test_share_app.py
CHANGED
|
@@ -54,8 +54,8 @@ async def test_the_format_screen_has_a_share_button_that_opens_the_chat_picker(c
|
|
| 54 |
|
| 55 |
async def test_the_share_button_is_a_valid_telegram_button():
|
| 56 |
"""The format screen's rows go through Telethon's own conversion and serialise (no network needed)."""
|
| 57 |
-
fmt = {"id": 12, "name": "One Piece", "has_thumb": False, "template": "{title}", "
|
| 58 |
-
"keep_audio": [], "keep_subtitle": []}
|
| 59 |
_text, rows = panels.format_view(fmt, share_inline=True)
|
| 60 |
client = TelegramClient(StringSession(), 1, "0123456789abcdef0123456789abcdef")
|
| 61 |
markup = client.build_reply_markup(panels.to_buttons(rows))
|
|
@@ -210,6 +210,21 @@ async def test_opening_a_shared_format_shows_it_and_offers_to_add_it(ctx):
|
|
| 210 |
assert await ctx.db.count_formats(ADMIN_CFG) == 0 # nothing is added until they say so
|
| 211 |
|
| 212 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
async def test_a_plain_start_still_opens_the_menu(ctx):
|
| 214 |
for text in ("/start", "/start hello", "/start fmt", "/start x_fmt_abc"):
|
| 215 |
ev = await say(ctx, ADMIN_CFG, text)
|
|
|
|
| 54 |
|
| 55 |
async def test_the_share_button_is_a_valid_telegram_button():
|
| 56 |
"""The format screen's rows go through Telethon's own conversion and serialise (no network needed)."""
|
| 57 |
+
fmt = {"id": 12, "name": "One Piece", "has_thumb": False, "template": "{title}", "caption": "<b>{filename}</b>",
|
| 58 |
+
"meta": {k: "" for k in ("title", "author", "artist", "audio", "subtitle", "video")}, "keep_audio": [], "keep_subtitle": []}
|
| 59 |
_text, rows = panels.format_view(fmt, share_inline=True)
|
| 60 |
client = TelegramClient(StringSession(), 1, "0123456789abcdef0123456789abcdef")
|
| 61 |
markup = client.build_reply_markup(panels.to_buttons(rows))
|
|
|
|
| 210 |
assert await ctx.db.count_formats(ADMIN_CFG) == 0 # nothing is added until they say so
|
| 211 |
|
| 212 |
|
| 213 |
+
async def test_a_shared_format_shows_its_caption_and_the_copy_keeps_it(ctx):
|
| 214 |
+
fmt, token = await share(ctx, caption="<i>{filename}</i> · {size}")
|
| 215 |
+
ev = await say(ctx, ADMIN_CFG, f"/start fmt_{token}")
|
| 216 |
+
assert "<b>Caption</b> ▸ <code><i>{filename}</i> · {size}</code>" in ev.texts[0] and "· default" not in ev.texts[0]
|
| 217 |
+
await click(ctx, ADMIN_CFG, f"fs:add:{token}")
|
| 218 |
+
(mine,) = await ctx.db.list_formats(ADMIN_CFG)
|
| 219 |
+
assert mine["caption"] == "<i>{filename}</i> · {size}" # theirs from now on
|
| 220 |
+
await ctx.db.update_format(ADMIN_CFG, mine["id"], caption="<b>{title}</b>")
|
| 221 |
+
assert (await ctx.db.get_format(OWNER, fmt["id"]))["caption"] == "<i>{filename}</i> · {size}"
|
| 222 |
+
|
| 223 |
+
_plain, plain_token = await share(ctx, "Dragon Ball") # without a caption of its own: the file name in bold
|
| 224 |
+
ev = await say(ctx, ADMIN_CFG, f"/start fmt_{plain_token}")
|
| 225 |
+
assert "<b>Caption</b> ▸ <code><b>{filename}</b></code> · default" in ev.texts[0]
|
| 226 |
+
|
| 227 |
+
|
| 228 |
async def test_a_plain_start_still_opens_the_menu(ctx):
|
| 229 |
for text in ("/start", "/start hello", "/start fmt", "/start x_fmt_abc"):
|
| 230 |
ev = await say(ctx, ADMIN_CFG, text)
|
tests/test_stream_rename.py
ADDED
|
@@ -0,0 +1,442 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Streaming renames: a big file goes straight from the download into the upload, and anything that needs the file takes the usual way."""
|
| 2 |
+
import shutil
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
from types import SimpleNamespace
|
| 5 |
+
|
| 6 |
+
import pytest
|
| 7 |
+
from telethon import errors, types
|
| 8 |
+
|
| 9 |
+
from bot import errorlog
|
| 10 |
+
from bot import fasttransfer as ft
|
| 11 |
+
from bot import pipeline
|
| 12 |
+
from bot.config import load_config
|
| 13 |
+
from bot.db import Database
|
| 14 |
+
from bot.errors import Cancelled
|
| 15 |
+
from bot.media import probe
|
| 16 |
+
from bot.pipeline import Renamer, embedded_thumb, make_caption, source_attributes
|
| 17 |
+
from bot.queue import Job
|
| 18 |
+
from bot.utils import human_duration, human_size
|
| 19 |
+
|
| 20 |
+
from .conftest import needs_ffmpeg
|
| 21 |
+
|
| 22 |
+
MIB = 1024 * 1024
|
| 23 |
+
BIG = 40 * MIB # more than the 10 MB Telegram's big-file upload starts at; nothing this size is really moved: the engine is a stub here
|
| 24 |
+
NAME = "[SubsPlease] Tokyo Revengers - 05 (1080p) [ABCD1234].mkv"
|
| 25 |
+
NEW_NAME = "[S01E05] Tokyo Revengers [1080p].mkv"
|
| 26 |
+
PREVIEW = b"\xff\xd8preview-of-the-original"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def video_attr(duration=1400.5, w=1920, h=1080, **kw):
|
| 30 |
+
return types.DocumentAttributeVideo(duration=duration, w=w, h=h, supports_streaming=True, **kw)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def audio_attr(duration=215.4, title="Song", performer="Me", **kw):
|
| 34 |
+
return types.DocumentAttributeAudio(duration=duration, title=title, performer=performer, **kw)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def preview_sizes():
|
| 38 |
+
return [types.PhotoStrippedSize(type="i", bytes=b"\x01\x02"), types.PhotoSize(type="m", w=320, h=180, size=900)]
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class FakeMessage:
|
| 42 |
+
"""A message with a document, the way Telethon shows it to the pipeline."""
|
| 43 |
+
|
| 44 |
+
def __init__(self, name=NAME, size=BIG, *, attributes=(), thumbs=None, mime="video/x-matroska", msg_id=7, document=True):
|
| 45 |
+
self.id = msg_id
|
| 46 |
+
self.file = SimpleNamespace(name=name, size=size, ext=Path(name or "").suffix, mime_type=mime)
|
| 47 |
+
self.document = SimpleNamespace(attributes=list(attributes), thumbs=thumbs, size=size, mime_type=mime) if document else None
|
| 48 |
+
video, audio = source_attributes(self)
|
| 49 |
+
self.video = self.document if video is not None else None # like Telethon's own properties
|
| 50 |
+
self.audio = self.document if audio is not None else None
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class FakeClient:
|
| 54 |
+
def __init__(self, message, source):
|
| 55 |
+
self.message, self.source = message, source
|
| 56 |
+
self.sent, self.edits = [], []
|
| 57 |
+
self.relays, self.downloads, self.uploads, self.preview_downloads = [], [], [], 0
|
| 58 |
+
self.relay_error = None
|
| 59 |
+
self.send_errors = []
|
| 60 |
+
|
| 61 |
+
async def get_messages(self, chat_id, ids):
|
| 62 |
+
return self.message
|
| 63 |
+
|
| 64 |
+
async def download_media(self, message, file=None, thumb=None, progress_callback=None):
|
| 65 |
+
assert thumb is not None, "the whole file is fetched by transfer.download, which is stubbed"
|
| 66 |
+
self.preview_downloads += 1
|
| 67 |
+
return PREVIEW
|
| 68 |
+
|
| 69 |
+
async def send_file(self, target, input_file, **kwargs):
|
| 70 |
+
if self.send_errors:
|
| 71 |
+
raise self.send_errors.pop(0)
|
| 72 |
+
self.sent.append((target, input_file, kwargs))
|
| 73 |
+
|
| 74 |
+
async def edit_message(self, message, text, buttons=None, parse_mode=None):
|
| 75 |
+
self.edits.append((text, buttons))
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
@pytest.fixture(autouse=True)
|
| 79 |
+
def fresh_stats(monkeypatch):
|
| 80 |
+
monkeypatch.setattr(ft, "stats", ft.TransferStats())
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
@pytest.fixture
|
| 84 |
+
async def env(tmp_path, media_dir, monkeypatch):
|
| 85 |
+
def new_cfg(**environ):
|
| 86 |
+
return load_config({
|
| 87 |
+
"API_ID": "1", "API_HASH": "h", "BOT_TOKEN": "1:t", "OWNER_ID": "42", "TEMP_DIR": str(tmp_path / "work"),
|
| 88 |
+
"PROGRESS_INTERVAL": "2", **environ, # fast transfer and streaming are on unless a test turns them off
|
| 89 |
+
})
|
| 90 |
+
|
| 91 |
+
db = Database(f"sqlite+aiosqlite:///{tmp_path}/s.db")
|
| 92 |
+
await db.init()
|
| 93 |
+
ns = SimpleNamespace(db=db, media_dir=media_dir, tmp=tmp_path, new_cfg=new_cfg, cfg=new_cfg())
|
| 94 |
+
|
| 95 |
+
async def relay(client, document, file_name, *, connections, progress=None, cancel=None):
|
| 96 |
+
client.relays.append((document, file_name, connections))
|
| 97 |
+
if client.relay_error is not None:
|
| 98 |
+
raise client.relay_error
|
| 99 |
+
progress(document.size // 2, document.size)
|
| 100 |
+
progress(document.size, document.size)
|
| 101 |
+
return types.InputFileBig(id=99, parts=-(-document.size // ft.UPLOAD_PART), name=file_name)
|
| 102 |
+
|
| 103 |
+
async def download(client, message, dest, *, fast, connections, progress=None, cancel=None, max_bytes=None):
|
| 104 |
+
client.downloads.append(Path(dest).name)
|
| 105 |
+
shutil.copy(client.source, dest)
|
| 106 |
+
|
| 107 |
+
async def upload(client, path, file_name, *, fast, connections, progress=None, cancel=None):
|
| 108 |
+
client.uploads.append(Path(path).read_bytes())
|
| 109 |
+
return types.InputFileBig(id=1, parts=1, name=file_name)
|
| 110 |
+
|
| 111 |
+
monkeypatch.setattr(ft, "relay", relay)
|
| 112 |
+
monkeypatch.setattr(ft, "download", download)
|
| 113 |
+
monkeypatch.setattr(ft, "upload", upload)
|
| 114 |
+
yield ns
|
| 115 |
+
await db.close()
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
@pytest.fixture
|
| 119 |
+
async def log_channel():
|
| 120 |
+
"""What reaches the log channel while a test runs."""
|
| 121 |
+
reports = []
|
| 122 |
+
|
| 123 |
+
async def send(text):
|
| 124 |
+
reports.append(text)
|
| 125 |
+
|
| 126 |
+
handler = errorlog.install(send)
|
| 127 |
+
yield SimpleNamespace(reports=reports, flush=handler.aclose)
|
| 128 |
+
handler.detach()
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
async def rename(env, message=None, *, cfg=None, new_name=NEW_NAME, fmt=None, source="sample.mkv"):
|
| 132 |
+
"""Run one job; returns ``(client, job)``."""
|
| 133 |
+
message = message or FakeMessage(attributes=[video_attr()], thumbs=preview_sizes())
|
| 134 |
+
client = FakeClient(message, env.media_dir / source)
|
| 135 |
+
job = Job(id=1, user_id=42, chat_id=42, msg_id=7, file_name=message.file.name or "x", size=message.file.size, new_name=new_name,
|
| 136 |
+
status_msg=SimpleNamespace(id=99), fmt=fmt)
|
| 137 |
+
await Renamer(client, cfg or env.cfg, env.db).process(job)
|
| 138 |
+
return client, job
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def attributes_by_type(kwargs):
|
| 142 |
+
return {type(a): a for a in kwargs["attributes"]}
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
# ---------------------------------------------------------------------------
|
| 146 |
+
# the streamed way
|
| 147 |
+
# ---------------------------------------------------------------------------
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
async def test_a_big_plain_rename_is_streamed_with_the_details_telegram_already_has(env):
|
| 151 |
+
message = FakeMessage(attributes=[video_attr()], thumbs=preview_sizes())
|
| 152 |
+
client, job = await rename(env, message)
|
| 153 |
+
|
| 154 |
+
assert client.relays == [(message.document, NEW_NAME, env.cfg.connections)]
|
| 155 |
+
assert client.downloads == [] and client.uploads == [] # nothing was saved, and nothing uploaded from a file
|
| 156 |
+
(target, input_file, kw), = client.sent
|
| 157 |
+
assert target == 42 and kw["reply_to"] == 7 and input_file.name == NEW_NAME
|
| 158 |
+
by_type = attributes_by_type(kw)
|
| 159 |
+
assert by_type[types.DocumentAttributeFilename].file_name == NEW_NAME
|
| 160 |
+
video = by_type[types.DocumentAttributeVideo]
|
| 161 |
+
assert (video.duration, video.w, video.h, video.supports_streaming) == (1400.5, 1920, 1080, True)
|
| 162 |
+
assert kw["force_document"] is False and kw["mime_type"] == "video/x-matroska" and kw["parse_mode"] == "html"
|
| 163 |
+
assert kw["thumb"] == PREVIEW and client.preview_downloads == 1 # the original's own preview, as no frame can be cut from a file
|
| 164 |
+
assert kw["caption"] == "<b>[S01E05] Tokyo Revengers [1080p]</b>"
|
| 165 |
+
assert job.state == "done" and (await env.db.totals())[0] == 1
|
| 166 |
+
assert "Streaming" in client.edits[0][0] # the progress message says what is going on
|
| 167 |
+
done, buttons = client.edits[-1]
|
| 168 |
+
assert "Done" in done and human_size(BIG) in done and buttons is None
|
| 169 |
+
assert ft.stats.streamed == 1 and ft.stats.stream_fallbacks == 0
|
| 170 |
+
assert not (Path(env.cfg.temp_dir) / "job1").exists()
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
async def test_the_users_thumbnail_wins_and_the_original_preview_is_then_not_even_fetched(env):
|
| 174 |
+
await env.db.set_thumb(42, b"\xff\xd8mine")
|
| 175 |
+
client, _job = await rename(env)
|
| 176 |
+
assert client.sent[0][2]["thumb"] == b"\xff\xd8mine" and client.preview_downloads == 0 and len(client.relays) == 1
|
| 177 |
+
|
| 178 |
+
fmt = await env.db.create_format(42, "One Piece")
|
| 179 |
+
await env.db.set_format_thumb(42, fmt["id"], b"\xff\xd8format")
|
| 180 |
+
client, _job = await rename(env, fmt=await env.db.get_format(42, fmt["id"]))
|
| 181 |
+
assert client.sent[0][2]["thumb"] == b"\xff\xd8format" and len(client.relays) == 1
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
async def test_a_format_streams_with_its_own_caption(env):
|
| 185 |
+
await env.db.update_settings(42, caption="<i>THE USER'S {filename}</i>")
|
| 186 |
+
fmt = await env.db.create_format(42, "One Piece", template="One Piece - E{episode}")
|
| 187 |
+
await env.db.update_format(42, fmt["id"], caption="<b>{filename}</b> · {size} · {duration}")
|
| 188 |
+
client, _job = await rename(env, new_name="One Piece - E5.mkv", fmt=await env.db.get_format(42, fmt["id"]))
|
| 189 |
+
(_t, _f, kw), = client.sent
|
| 190 |
+
assert len(client.relays) == 1
|
| 191 |
+
assert kw["caption"] == f"<b>One Piece - E5</b> · {human_size(BIG)} · {human_duration(1400.5)}" # the duration is the one Telegram has
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
async def test_a_file_that_gets_no_thumbnail_anyway_is_streamed_without_one(env):
|
| 195 |
+
message = FakeMessage("Archive.zip", BIG, mime="application/zip")
|
| 196 |
+
client, _job = await rename(env, message, new_name="Archive 2.zip")
|
| 197 |
+
(_t, _f, kw), = client.sent
|
| 198 |
+
assert len(client.relays) == 1 and kw["thumb"] is None and client.preview_downloads == 0
|
| 199 |
+
assert kw["force_document"] is True and kw["mime_type"] == "application/zip"
|
| 200 |
+
assert [type(a) for a in kw["attributes"]] == [types.DocumentAttributeFilename]
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
async def test_an_original_sent_as_a_file_is_streamed_as_a_file(env):
|
| 204 |
+
await env.db.set_thumb(42, b"\xff\xd8mine")
|
| 205 |
+
client, _job = await rename(env, FakeMessage()) # an .mkv without any video details: Telegram sent it as a file
|
| 206 |
+
(_t, _f, kw), = client.sent
|
| 207 |
+
assert len(client.relays) == 1 and kw["force_document"] is True and kw["thumb"] == b"\xff\xd8mine"
|
| 208 |
+
assert types.DocumentAttributeVideo not in attributes_by_type(kw)
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
async def test_audio_is_streamed_with_the_title_and_artist_telegram_has(env):
|
| 212 |
+
message = FakeMessage("old name.mp3", BIG, attributes=[audio_attr()], mime="audio/mpeg")
|
| 213 |
+
client, _job = await rename(env, message, new_name="New Song.mp3")
|
| 214 |
+
(_t, _f, kw), = client.sent
|
| 215 |
+
audio = attributes_by_type(kw)[types.DocumentAttributeAudio]
|
| 216 |
+
assert (audio.duration, audio.title, audio.performer) == (215, "Song", "Me") and not audio.voice
|
| 217 |
+
assert kw["force_document"] is False and kw["mime_type"] == "audio/mpeg" and kw["thumb"] is None and len(client.relays) == 1
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
async def test_a_forced_type_is_followed_when_telegram_has_the_details_for_it(env):
|
| 221 |
+
await env.db.update_settings(42, file_type="document")
|
| 222 |
+
client, _job = await rename(env) # sent as a video, but the user wants documents
|
| 223 |
+
(_t, _f, kw), = client.sent
|
| 224 |
+
assert len(client.relays) == 1 and kw["force_document"] is True and types.DocumentAttributeVideo not in attributes_by_type(kw)
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
async def test_the_caption_may_use_the_duration_when_telegram_has_it(env):
|
| 228 |
+
await env.db.update_settings(42, caption="<b>{filename}</b> · {duration}")
|
| 229 |
+
client, _job = await rename(env)
|
| 230 |
+
assert client.sent[0][2]["caption"] == f"<b>[S01E05] Tokyo Revengers [1080p]</b> · {human_duration(1400.5)}" and len(client.relays) == 1
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
async def test_a_streamed_file_goes_to_the_target_channel_like_any_other(env):
|
| 234 |
+
await env.db.update_settings(42, target_channel=-100123)
|
| 235 |
+
client, _job = await rename(env)
|
| 236 |
+
(target, _f, kw), = client.sent
|
| 237 |
+
assert target == -100123 and "reply_to" not in kw and "channel" in client.edits[-1][0]
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
async def test_a_rejected_caption_is_dropped_for_a_streamed_file_too(env):
|
| 241 |
+
message = FakeMessage(attributes=[video_attr()], thumbs=preview_sizes())
|
| 242 |
+
client = FakeClient(message, env.media_dir / "sample.mkv")
|
| 243 |
+
client.send_errors = [errors.MediaCaptionTooLongError(request=None)]
|
| 244 |
+
job = Job(id=1, user_id=42, chat_id=42, msg_id=7, file_name=NAME, size=BIG, new_name=NEW_NAME, status_msg=SimpleNamespace(id=99))
|
| 245 |
+
await Renamer(client, env.cfg, env.db).process(job)
|
| 246 |
+
(_t, _f, kw), = client.sent
|
| 247 |
+
assert kw["caption"] is None and "caption was rejected" in client.edits[-1][0] and job.state == "done" and len(client.relays) == 1
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
async def test_the_done_message_gives_the_speed_of_the_whole_stream(env, monkeypatch):
|
| 251 |
+
ticks = iter(range(0, 1000, 2)) # every look at the clock is two seconds later than the one before
|
| 252 |
+
monkeypatch.setattr(pipeline, "time", SimpleNamespace(monotonic=lambda: next(ticks)))
|
| 253 |
+
client, _job = await rename(env)
|
| 254 |
+
assert f"{human_size(BIG)} · ⬇⬆ {human_size(BIG / 2)}/s streamed" in client.edits[-1][0]
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
async def test_a_stream_that_took_under_a_second_shows_no_speed(env):
|
| 258 |
+
client, _job = await rename(env)
|
| 259 |
+
assert "Done" in client.edits[-1][0] and "/s" not in client.edits[-1][0]
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
# ---------------------------------------------------------------------------
|
| 263 |
+
# when the file has to be looked at: the usual way (download, ffmpeg, upload)
|
| 264 |
+
# ---------------------------------------------------------------------------
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
def usual_way(client, job):
|
| 268 |
+
"""The file was saved and uploaded the usual way, and sent once."""
|
| 269 |
+
assert client.relays == [] and len(client.downloads) == 1 and len(client.uploads) == 1
|
| 270 |
+
assert len(client.sent) == 1 and job.state == "done"
|
| 271 |
+
assert ft.stats.streamed == 0 and ft.stats.stream_fallbacks == 0 # not streaming is not a failure
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
@needs_ffmpeg
|
| 275 |
+
async def test_small_files_are_not_streamed(env):
|
| 276 |
+
message = FakeMessage(size=ft.BIG_FILE_THRESHOLD, attributes=[video_attr()], thumbs=preview_sizes())
|
| 277 |
+
client, job = await rename(env, message)
|
| 278 |
+
usual_way(client, job)
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
@needs_ffmpeg
|
| 282 |
+
async def test_metadata_and_tracks_change_the_file_so_it_is_not_streamed(env):
|
| 283 |
+
await env.db.update_settings(42, metadata_on=True, meta={"title": "Tokyo Revengers"})
|
| 284 |
+
client, job = await rename(env)
|
| 285 |
+
usual_way(client, job)
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
@needs_ffmpeg
|
| 289 |
+
async def test_streaming_can_be_switched_off_and_needs_the_parallel_transfer(env):
|
| 290 |
+
for environ in ({"STREAM_RENAME": "0"}, {"FAST_TRANSFER": "0"}):
|
| 291 |
+
client, job = await rename(env, cfg=env.new_cfg(**environ))
|
| 292 |
+
usual_way(client, job)
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
@needs_ffmpeg
|
| 296 |
+
async def test_a_video_without_a_thumbnail_to_reuse_is_saved_so_that_one_can_be_cut_from_it(env):
|
| 297 |
+
for thumbs in (None, [types.PhotoStrippedSize(type="i", bytes=b"\x01\x02")]): # a blurred stripped preview is no thumbnail
|
| 298 |
+
message = FakeMessage(attributes=[video_attr()], thumbs=thumbs)
|
| 299 |
+
client, job = await rename(env, message)
|
| 300 |
+
usual_way(client, job)
|
| 301 |
+
assert client.sent[0][2]["thumb"][:2] == b"\xff\xd8" and client.sent[0][2]["thumb"] != PREVIEW # a frame of the video
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
@needs_ffmpeg
|
| 305 |
+
async def test_a_forced_video_or_audio_type_needs_the_details_only_the_file_can_give(env):
|
| 306 |
+
await env.db.update_settings(42, file_type="video")
|
| 307 |
+
client, job = await rename(env, FakeMessage(thumbs=preview_sizes())) # an .mkv sent as a file: no video details
|
| 308 |
+
usual_way(client, job)
|
| 309 |
+
assert types.DocumentAttributeVideo in attributes_by_type(client.sent[0][2]) # probed from the file instead
|
| 310 |
+
|
| 311 |
+
await env.db.update_settings(42, file_type="audio")
|
| 312 |
+
client, job = await rename(env, FakeMessage(attributes=[video_attr()], thumbs=preview_sizes()))
|
| 313 |
+
usual_way(client, job)
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
@needs_ffmpeg
|
| 317 |
+
async def test_a_caption_that_wants_the_duration_needs_the_file_when_telegram_does_not_know_it(env):
|
| 318 |
+
await env.db.update_settings(42, caption="<b>{filename}</b> · {duration}")
|
| 319 |
+
await env.db.set_thumb(42, b"\xff\xd8mine")
|
| 320 |
+
client, job = await rename(env, FakeMessage()) # sent as a file: no duration
|
| 321 |
+
usual_way(client, job)
|
| 322 |
+
duration = (await probe(env.media_dir / "sample.mkv")).duration
|
| 323 |
+
assert client.sent[0][2]["caption"] == f"<b>[S01E05] Tokyo Revengers [1080p]</b> · {human_duration(duration)}" # read from the file
|
| 324 |
+
|
| 325 |
+
|
| 326 |
+
@needs_ffmpeg
|
| 327 |
+
async def test_a_message_without_a_document_is_never_streamed(env):
|
| 328 |
+
client, job = await rename(env, FakeMessage(document=False))
|
| 329 |
+
usual_way(client, job)
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
# ---------------------------------------------------------------------------
|
| 333 |
+
# when streaming goes wrong: the usual way takes over
|
| 334 |
+
# ---------------------------------------------------------------------------
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
@needs_ffmpeg
|
| 338 |
+
async def test_a_failed_stream_falls_back_to_the_usual_way_and_says_so(env, log_channel):
|
| 339 |
+
client = FakeClient(FakeMessage(attributes=[video_attr()], thumbs=preview_sizes()), env.media_dir / "sample.mkv")
|
| 340 |
+
client.relay_error = RuntimeError("DC unreachable")
|
| 341 |
+
job = Job(id=1, user_id=42, chat_id=42, msg_id=7, file_name=NAME, size=BIG, new_name=NEW_NAME, status_msg=SimpleNamespace(id=99))
|
| 342 |
+
await Renamer(client, env.cfg, env.db).process(job)
|
| 343 |
+
await log_channel.flush()
|
| 344 |
+
|
| 345 |
+
assert len(client.relays) == 1 and len(client.downloads) == 1 and client.uploads == [(env.media_dir / "sample.mkv").read_bytes()]
|
| 346 |
+
(_t, _f, kw), = client.sent
|
| 347 |
+
assert kw["force_document"] is False and kw["caption"] == "<b>[S01E05] Tokyo Revengers [1080p]</b>"
|
| 348 |
+
assert job.state == "done" and (await env.db.totals())[0] == 1 # counted once
|
| 349 |
+
done = client.edits[-1][0]
|
| 350 |
+
assert "Done" in done and "Streaming didn't work for this file" in done
|
| 351 |
+
assert (ft.stats.streamed, ft.stats.stream_fallbacks, ft.stats.last_stream_fallback) == (0, 1, "RuntimeError: DC unreachable")
|
| 352 |
+
assert ft.stats.fallbacks == 0 # the parallel transfer itself did not fail
|
| 353 |
+
(report,) = log_channel.reports # the log channel is told, with the traceback
|
| 354 |
+
assert "streaming" in report and "RuntimeError: DC unreachable" in report and "Traceback" in report
|
| 355 |
+
assert not (Path(env.cfg.temp_dir) / "job1").exists()
|
| 356 |
+
|
| 357 |
+
|
| 358 |
+
@needs_ffmpeg
|
| 359 |
+
async def test_telegram_refusing_the_streamed_parts_also_falls_back(env):
|
| 360 |
+
client = FakeClient(FakeMessage(attributes=[video_attr()], thumbs=preview_sizes()), env.media_dir / "sample.mkv")
|
| 361 |
+
client.send_errors = [errors.FilePartMissingError(request=None, capture=3)]
|
| 362 |
+
job = Job(id=1, user_id=42, chat_id=42, msg_id=7, file_name=NAME, size=BIG, new_name=NEW_NAME, status_msg=SimpleNamespace(id=99))
|
| 363 |
+
await Renamer(client, env.cfg, env.db).process(job)
|
| 364 |
+
assert len(client.relays) == 1 and len(client.downloads) == 1 and len(client.uploads) == 1 and len(client.sent) == 1
|
| 365 |
+
assert job.state == "done" and "Streaming didn't work" in client.edits[-1][0] and (await env.db.totals())[0] == 1
|
| 366 |
+
assert (ft.stats.streamed, ft.stats.stream_fallbacks) == (0, 1) and "FilePartMissingError" in ft.stats.last_stream_fallback
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
async def test_any_other_refusal_after_streaming_fails_the_job_without_a_second_try(env):
|
| 370 |
+
client = FakeClient(FakeMessage(attributes=[video_attr()], thumbs=preview_sizes()), env.media_dir / "sample.mkv")
|
| 371 |
+
client.send_errors = [errors.ChatWriteForbiddenError(request=None)]
|
| 372 |
+
job = Job(id=1, user_id=42, chat_id=42, msg_id=7, file_name=NAME, size=BIG, new_name=NEW_NAME, status_msg=SimpleNamespace(id=99))
|
| 373 |
+
await Renamer(client, env.cfg, env.db).process(job)
|
| 374 |
+
assert job.state == "failed" and client.sent == [] and client.downloads == [] and len(client.relays) == 1 # no download/upload all over again
|
| 375 |
+
assert "Failed" in client.edits[-1][0] and ft.stats.stream_fallbacks == 0 and (await env.db.totals())[0] == 0
|
| 376 |
+
|
| 377 |
+
|
| 378 |
+
async def test_cancelling_a_stream_cancels_the_job_and_does_not_fall_back(env):
|
| 379 |
+
client = FakeClient(FakeMessage(attributes=[video_attr()], thumbs=preview_sizes()), env.media_dir / "sample.mkv")
|
| 380 |
+
client.relay_error = Cancelled()
|
| 381 |
+
job = Job(id=1, user_id=42, chat_id=42, msg_id=7, file_name=NAME, size=BIG, new_name=NEW_NAME, status_msg=SimpleNamespace(id=99))
|
| 382 |
+
await Renamer(client, env.cfg, env.db).process(job)
|
| 383 |
+
assert job.state == "cancelled" and client.sent == [] and client.downloads == [] and "Cancelled" in client.edits[-1][0]
|
| 384 |
+
assert ft.stats.stream_fallbacks == 0 and ft.stats.streamed == 0
|
| 385 |
+
|
| 386 |
+
|
| 387 |
+
async def test_a_cancel_that_arrives_as_the_stream_ends_sends_nothing(env, monkeypatch):
|
| 388 |
+
client = FakeClient(FakeMessage(attributes=[video_attr()], thumbs=preview_sizes()), env.media_dir / "sample.mkv")
|
| 389 |
+
job = Job(id=1, user_id=42, chat_id=42, msg_id=7, file_name=NAME, size=BIG, new_name=NEW_NAME, status_msg=SimpleNamespace(id=99))
|
| 390 |
+
real_relay = ft.relay
|
| 391 |
+
|
| 392 |
+
async def relay_then_cancel(*a, **kw):
|
| 393 |
+
result = await real_relay(*a, **kw)
|
| 394 |
+
job.cancel.set()
|
| 395 |
+
return result
|
| 396 |
+
|
| 397 |
+
monkeypatch.setattr(ft, "relay", relay_then_cancel)
|
| 398 |
+
await Renamer(client, env.cfg, env.db).process(job)
|
| 399 |
+
assert job.state == "cancelled" and client.sent == []
|
| 400 |
+
|
| 401 |
+
|
| 402 |
+
# ---------------------------------------------------------------------------
|
| 403 |
+
# helpers
|
| 404 |
+
# ---------------------------------------------------------------------------
|
| 405 |
+
|
| 406 |
+
|
| 407 |
+
def test_source_attributes_ignore_round_videos_and_voice_notes():
|
| 408 |
+
plain, round_video = video_attr(), video_attr(round_message=True)
|
| 409 |
+
song, voice = audio_attr(), audio_attr(voice=True)
|
| 410 |
+
assert source_attributes(FakeMessage(attributes=[plain, song])) == (plain, song)
|
| 411 |
+
assert source_attributes(FakeMessage(attributes=[round_video, voice])) == (None, None)
|
| 412 |
+
assert source_attributes(FakeMessage(attributes=[types.DocumentAttributeFilename("a.mkv")])) == (None, None)
|
| 413 |
+
assert source_attributes(FakeMessage(document=False)) == (None, None) and source_attributes(object()) == (None, None)
|
| 414 |
+
|
| 415 |
+
|
| 416 |
+
def test_make_caption_fills_in_what_it_is_given():
|
| 417 |
+
text = make_caption("<b>{filename}</b> · {original} · {size} · {duration} · {ext} · {nonsense}", new_name="New - E5.mkv",
|
| 418 |
+
orig_name="[Grp] Show - 05 [720p].mkv", ext=".mkv", size=3 * MIB, duration=3725)
|
| 419 |
+
assert text == f"<b>New - E5</b> · [Grp] Show - 05 [720p].mkv · {human_size(3 * MIB)} · {human_duration(3725)} · mkv · {{nonsense}}"
|
| 420 |
+
assert make_caption("{duration}", new_name="a.mkv", orig_name="a.mkv", ext=".mkv", size=1, duration=0) is None # empty: no caption
|
| 421 |
+
assert len(make_caption("x" * 5000, new_name="a.mkv", orig_name="a.mkv", ext=".mkv", size=1, duration=0)) == 1000
|
| 422 |
+
|
| 423 |
+
|
| 424 |
+
async def test_the_embedded_preview_is_only_fetched_when_it_is_worth_having():
|
| 425 |
+
class Client:
|
| 426 |
+
def __init__(self, data=PREVIEW, error=None):
|
| 427 |
+
self.data, self.error, self.calls = data, error, 0
|
| 428 |
+
|
| 429 |
+
async def download_media(self, msg, file=None, thumb=None):
|
| 430 |
+
self.calls += 1
|
| 431 |
+
if self.error:
|
| 432 |
+
raise self.error
|
| 433 |
+
return self.data
|
| 434 |
+
|
| 435 |
+
stripped = [types.PhotoStrippedSize(type="i", bytes=b"\x01\x02")]
|
| 436 |
+
client = Client()
|
| 437 |
+
assert await embedded_thumb(client, FakeMessage(thumbs=preview_sizes())) == PREVIEW
|
| 438 |
+
assert await embedded_thumb(client, FakeMessage(thumbs=stripped)) is None and client.calls == 1 # blurred: not asked for
|
| 439 |
+
assert await embedded_thumb(client, FakeMessage(thumbs=stripped), allow_stripped=True) == PREVIEW
|
| 440 |
+
assert await embedded_thumb(client, FakeMessage(thumbs=None)) is None and await embedded_thumb(client, object()) is None
|
| 441 |
+
assert await embedded_thumb(Client(error=OSError("gone")), FakeMessage(thumbs=preview_sizes())) is None
|
| 442 |
+
assert await embedded_thumb(Client(data=b""), FakeMessage(thumbs=preview_sizes())) is None
|
tests/test_sysinfo.py
CHANGED
|
@@ -166,3 +166,26 @@ def test_format_status_adds_the_speed_section_only_when_given_one():
|
|
| 166 |
assert "Speed" not in plain and plain.endswith("Telethon 1.45.0")
|
| 167 |
with_speed = sysinfo.format_status(10, 1, "1.45.0", "⚡ <b>Speed</b>\nline")
|
| 168 |
assert with_speed.startswith(plain) and with_speed.endswith("\n\n⚡ <b>Speed</b>\nline")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
assert "Speed" not in plain and plain.endswith("Telethon 1.45.0")
|
| 167 |
with_speed = sysinfo.format_status(10, 1, "1.45.0", "⚡ <b>Speed</b>\nline")
|
| 168 |
assert with_speed.startswith(plain) and with_speed.endswith("\n\n⚡ <b>Speed</b>\nline")
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def test_the_speed_summary_and_section_say_whether_streaming_is_on(monkeypatch):
|
| 172 |
+
from bot.fasttransfer import TransferStats
|
| 173 |
+
|
| 174 |
+
monkeypatch.setattr(sysinfo, "crypto_backend", lambda: "cryptg")
|
| 175 |
+
monkeypatch.setattr(sysinfo, "loop_backend", lambda: "uvloop")
|
| 176 |
+
assert sysinfo.speed_summary(True, 8, True) == "cryptg ✅ · uvloop ✅ · parallel ×8 ✅ · streaming ✅"
|
| 177 |
+
assert sysinfo.speed_summary(True, 8, False).endswith("parallel ×8 ✅ · no streaming ⚠️")
|
| 178 |
+
assert sysinfo.speed_summary(False, 8, True).endswith("parallel off ⚠️ · no streaming ⚠️") # streaming needs the parallel transfer
|
| 179 |
+
assert sysinfo.speed_summary(True, 8) == "cryptg ✅ · uvloop ✅ · parallel ×8 ✅" # nothing added when it is not asked for
|
| 180 |
+
|
| 181 |
+
stats = TransferStats(streamed=7)
|
| 182 |
+
text = sysinfo.format_speed(True, 8, stats, True)
|
| 183 |
+
assert "⬇⬆ Streaming rename: on: big files are uploaded while they download ✅" in text and "<b>Streamed renames:</b> 7" in text
|
| 184 |
+
assert "Streaming failed" not in text and "⚠️" not in text
|
| 185 |
+
assert "Streaming rename" not in sysinfo.format_speed(True, 8, stats) and "Streamed renames" not in sysinfo.format_speed(True, 8, stats)
|
| 186 |
+
|
| 187 |
+
stats.stream_failed(ValueError("<x>"))
|
| 188 |
+
text = sysinfo.format_speed(True, 8, stats, False)
|
| 189 |
+
assert "off (STREAM_RENAME=0) ⚠️" in text
|
| 190 |
+
assert "⚠️ Streaming failed 1× and those files were saved first and then uploaded. Last: <code>ValueError: <x></code>" in text
|
| 191 |
+
assert "The parallel transfer failed" not in text # a streaming failure is not a parallel-transfer failure
|