github-actions[bot] commited on
Commit
529b973
·
1 Parent(s): a00abaf

Automated sync from GitHub

Browse files
.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
- """The preview Telegram keeps with a file. The tiny blurred one (a "stripped" size) only counts when ``allow_stripped``."""
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, caption, file type, ...) comes from the user's settings
 
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
- if isinstance(stored.get("template"), str) and stored["template"]:
146
- merged["template"] = stored["template"]
 
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 Audio /
4
- Keep Subtitle lists. Every other setting (mode, caption template, file type, remove words, target channel)
5
- still comes from the user's normal auto-rename settings.
 
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(template=fmt["template"], keep_audio=list(fmt["keep_audio"]), keep_subtitle=list(fmt["keep_subtitle"]), metadata_on=True)
 
 
 
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, caption, file type and the rest come from your normal settings."]
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>&lt;b&gt;</code>, <code>&lt;i&gt;</code>, <code>&lt;code&gt;</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.db.get_format_thumb(job.user_id, job.fmt["id"]) if job.fmt else None
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
- parsed = parse_info(split_ext(orig_name)[0])
190
- values = build_values(parsed, split_ext(orig_name)[0], ext)
191
- values.update(
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 reporter.finish(
233
- 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}"
 
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 three things transfer speed depends on."""
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
- return [encryption, event_loop, transfers]
 
 
 
 
 
 
 
 
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="<b>{filename}</b>", 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,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["caption"], out["file_type"], out["remove_words"], out["target_channel"]) == (
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
- assert (await ctx.db.get_settings(OWNER))["meta"]["title"] == "" and (await ctx.db.get_settings(OWNER))["keep_audio"] == []
 
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>&lt;b&gt;{filename}&lt;/b&gt;</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>&lt;b&gt;{filename}&lt;/b&gt; · {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>&lt;b&gt;{filename}&lt;/b&gt;</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>&lt;i&gt;{filename}&lt;/i&gt; · {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 and kw["caption"] == "<b>One Piece - E5</b> by me"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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}", "meta": {k: "" for k in ("title", "author", "artist", "audio", "subtitle", "video")},
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>&lt;i&gt;{filename}&lt;/i&gt; · {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>&lt;b&gt;{filename}&lt;/b&gt;</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: &lt;x&gt;</code>" in text
191
+ assert "The parallel transfer failed" not in text # a streaming failure is not a parallel-transfer failure