github-actions[bot] commited on
Commit
5f400f8
·
1 Parent(s): 49b8170

Automated sync from GitHub

Browse files
.gitattributes CHANGED
@@ -1,8 +1,8 @@
1
- *.png filter=lfs diff=lfs merge=lfs -text
2
- *.ttf filter=lfs diff=lfs merge=lfs -text
3
  *.otf filter=lfs diff=lfs merge=lfs -text
4
  *.webp filter=lfs diff=lfs merge=lfs -text
5
  *.mp4 filter=lfs diff=lfs merge=lfs -text
6
  *.mp3 filter=lfs diff=lfs merge=lfs -text
7
  *.jpg filter=lfs diff=lfs merge=lfs -text
8
  *.jpeg filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
1
  *.otf filter=lfs diff=lfs merge=lfs -text
2
  *.webp filter=lfs diff=lfs merge=lfs -text
3
  *.mp4 filter=lfs diff=lfs merge=lfs -text
4
  *.mp3 filter=lfs diff=lfs merge=lfs -text
5
  *.jpg filter=lfs diff=lfs merge=lfs -text
6
  *.jpeg filter=lfs diff=lfs merge=lfs -text
7
+ *.png filter=lfs diff=lfs merge=lfs -text
8
+ *.ttf filter=lfs diff=lfs merge=lfs -text
Dockerfile CHANGED
@@ -1,10 +1,22 @@
1
  FROM python:3.12-slim
2
 
3
- # ffmpeg/ffprobe: metadata + track keep/reorder (stream copy, no re-encoding)
4
  RUN apt-get update \
5
- && apt-get install -y --no-install-recommends ffmpeg ca-certificates \
6
  && rm -rf /var/lib/apt/lists/*
7
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  # Hugging Face Spaces run Docker containers as user 1000
9
  RUN useradd -m -u 1000 user
10
  USER user
 
1
  FROM python:3.12-slim
2
 
3
+ # ffmpeg/ffprobe: metadata + track keep/reorder (stream copy, no re-encoding); curl: fetches the Speedtest CLI below
4
  RUN apt-get update \
5
+ && apt-get install -y --no-install-recommends ffmpeg ca-certificates curl \
6
  && rm -rf /var/lib/apt/lists/*
7
 
8
+ # Ookla's official Speedtest CLI for /speed. Optional: if the download fails the build goes on and /speed
9
+ # uses the speedtest-cli Python package (also Ookla) instead.
10
+ RUN set -eux; \
11
+ case "$(dpkg --print-architecture)" in amd64) arch=x86_64 ;; arm64) arch=aarch64 ;; *) arch="" ;; esac; \
12
+ if [ -n "$arch" ]; then \
13
+ curl -fsSL --retry 3 --max-time 120 -o /tmp/speedtest.tgz "https://install.speedtest.net/app/cli/ookla-speedtest-1.2.0-linux-${arch}.tgz" \
14
+ && tar -xzf /tmp/speedtest.tgz -C /tmp speedtest \
15
+ && install -m 0755 /tmp/speedtest /usr/local/bin/ookla-speedtest \
16
+ || echo "Ookla Speedtest CLI not installed: /speed will use the speedtest-cli package"; \
17
+ rm -f /tmp/speedtest.tgz /tmp/speedtest; \
18
+ fi
19
+
20
  # Hugging Face Spaces run Docker containers as user 1000
21
  RUN useradd -m -u 1000 user
22
  USER user
bot/app.py CHANGED
@@ -2,36 +2,47 @@
2
  from __future__ import annotations
3
 
4
  import asyncio
 
5
  import html
6
  import io
 
7
  import logging
8
  import random
9
  import re
10
  import shutil
11
  import time
12
  from dataclasses import dataclass
 
13
  from pathlib import Path
14
  from typing import Any, Awaitable, Callable, Dict, List, Optional, Set
15
 
16
  import telethon
17
  from telethon import errors, events, functions, types
 
18
 
19
  from . import fasttransfer as transfer
20
- from . import media, panels, sysinfo
21
  from .config import Config
22
- from .db import Database
23
- from .parser import build_manual_name, compute_new_name, missing_variables
24
- from .pipeline import Renamer, original_name_of, source_ext
 
 
25
  from .queue import Job, JobQueue
 
26
  from .settings_ops import META_COMMANDS, apply_setting, parse_channel, preview_template
27
- from .utils import clip, esc, human_size
 
28
 
29
  log = logging.getLogger(__name__)
30
 
31
  COMMANDS = [
32
  ("start", "Open the main menu"),
33
  ("help", "How to use the bot"),
34
- ("rename", "Reply to a file with a new name"),
 
 
 
35
  ("mode", "Choose Filename or Caption mode"),
36
  ("template", "Set the filename template"),
37
  ("caption", "Set the caption template"),
@@ -40,22 +51,42 @@ COMMANDS = [
40
  ("keepaudio", "Keep audio tracks by language or number"),
41
  ("keepsubtitle", "Keep subtitle tracks by language or number"),
42
  ("tracks", "List the tracks of a file (reply to it)"),
 
 
 
 
43
  ("thumb", "View your thumbnail"),
44
  ("delthumb", "Delete your thumbnail"),
45
  ("queue", "Show your queued files"),
46
  ("cancel", "Cancel your current job"),
 
47
  ("status", "Show basic bot uptime, ping, and version information."),
48
  ("stats", "Show detailed system stats such as users, CPU, RAM, and disk usage."),
49
  ]
50
- OWNER_COMMANDS = {"addadmin", "deladmin", "admins"}
51
  BACK = {
52
  "template": "rename", "remove": "rename", "caption": "cap",
53
  "mt_title": "meta", "mt_author": "meta", "mt_artist": "meta",
54
  "mt_audio": "meta", "mt_subtitle": "meta", "mt_video": "meta",
55
  "keep_audio": "tool", "keep_subtitle": "tool",
56
  }
57
- PANEL_NAMES = {"home", "rename", "cap", "meta", "tool", "ftype", "help", "admin"}
58
  QUEUE_SETTLE = 1.5 # seconds an idle worker waits so files sent together are queued in message order
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  TRACKS_PROBE_BYTES = 12 * 1024 * 1024
60
  TRACKS_FULL_LIMIT = 700 * 1024 * 1024
61
 
@@ -68,12 +99,27 @@ class Pending:
68
  back: str
69
  is_photo: bool
70
  expires: float
 
 
 
 
 
 
 
 
 
 
 
 
71
 
72
 
73
  def cmd_pattern(name: str) -> "re.Pattern[str]":
74
  return re.compile(rf"(?is)^/{name}(?:@\w+)?(?:\s+(.*))?$")
75
 
76
 
 
 
 
77
  def visible_len(html_text: str) -> int:
78
  return len(html.unescape(re.sub(r"<[^>]+>", "", html_text)))
79
 
@@ -89,6 +135,51 @@ def is_renameable(message: Any) -> bool:
89
  )
90
 
91
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  class App:
93
  PENDING_TTL = 300
94
 
@@ -98,6 +189,12 @@ class App:
98
  self.db = db
99
  self.admins: Set[int] = set(cfg.admin_ids)
100
  self.pending: Dict[int, Pending] = {}
 
 
 
 
 
 
101
  self.renamer = Renamer(client, cfg, db, self._log_send)
102
  self.queue = JobQueue(self.renamer.process, workers=cfg.max_jobs, on_cancelled=self._on_job_cancelled, settle=QUEUE_SETTLE)
103
  self._warned: Set[int] = set()
@@ -123,6 +220,8 @@ class App:
123
  self.queue.start()
124
 
125
  async def shutdown(self) -> None:
 
 
126
  await self.queue.stop()
127
 
128
  async def _set_commands(self) -> None:
@@ -195,6 +294,7 @@ class App:
195
  )
196
  add(self._guard(self.on_media), events.NewMessage(incoming=True, func=lambda e: e.is_private and is_renameable(e.message)))
197
  add(self._guard(self.on_photo), events.NewMessage(incoming=True, func=lambda e: e.is_private and e.message.photo is not None))
 
198
  add(self._guard(self.on_text), events.NewMessage(incoming=True, func=self._wants_text))
199
  add(self._guard(self.on_callback), events.CallbackQuery())
200
  add(self._on_stranger, events.NewMessage(incoming=True, func=lambda e: e.is_private and not self.is_allowed(e.sender_id)))
@@ -208,14 +308,17 @@ class App:
208
 
209
  def _command_table(self) -> Dict[str, Callable[[Any], Awaitable[None]]]:
210
  table: Dict[str, Callable[[Any], Awaitable[None]]] = {
211
- "start": self.cmd_start, "help": self.cmd_help, "rename": self.cmd_rename, "mode": self.cmd_mode,
 
212
  "template": self.cmd_template, "caption": self.cmd_caption, "filetype": self.cmd_filetype,
213
  "metadata": self.cmd_metadata, "keepaudio": self._cmd_keep("keep_audio", "Keep Audio", "/keepaudio English Japanese"),
214
  "keepsubtitle": self._cmd_keep("keep_subtitle", "Keep Subtitle", "/keepsubtitle English"),
215
- "tracks": self.cmd_tracks, "thumb": self.cmd_thumb, "delthumb": self.cmd_delthumb,
 
 
216
  "removewords": self.cmd_remove, "setchannel": self.cmd_setchannel, "queue": self.cmd_queue,
217
- "cancel": self.cmd_cancel, "status": self.cmd_status, "stats": self.cmd_stats,
218
- "addadmin": self.cmd_addadmin, "deladmin": self.cmd_deladmin, "admins": self.cmd_admins,
219
  }
220
  for command, kind in META_COMMANDS.items():
221
  table[command] = self._cmd_meta_field(command, kind)
@@ -246,6 +349,8 @@ class App:
246
  return panels.filetype(s)
247
  if name == "help":
248
  return panels.help_panel()
 
 
249
  if name == "admin":
250
  return panels.admin(
251
  self.cfg.owner_id, sorted(self.admins), self.queue.running, self.queue.waiting, self.cfg.log_channel
@@ -268,19 +373,70 @@ class App:
268
  except errors.MessageNotModifiedError:
269
  pass
270
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
271
  # --------------------------------------------------------------- commands
272
  async def cmd_start(self, event: Any) -> None:
273
  self.pending.pop(event.sender_id, None)
274
  text, rows = await self.panel(event.sender_id, "home")
275
- if self.cfg.start_image:
276
- try:
277
- await self.client.send_file(
278
- event.chat_id, self.cfg.start_image, caption=text, buttons=panels.to_buttons(rows), parse_mode="html"
279
- )
280
- return
281
- except Exception as exc: # noqa: BLE001
282
- log.warning("could not send START_IMAGE (%s); sending text only", exc)
283
- await self._reply(event, text, rows)
284
 
285
  async def cmd_help(self, event: Any) -> None:
286
  text, _ = panels.help_panel()
@@ -439,6 +595,16 @@ class App:
439
  await self._reply(event, "You have no thumbnail.")
440
 
441
  async def on_photo(self, event: Any) -> None:
 
 
 
 
 
 
 
 
 
 
442
  raw = await event.download_media(bytes)
443
  if not raw:
444
  await self._reply(event, "❌ I couldn't download that photo.")
@@ -448,22 +614,208 @@ class App:
448
  except Exception: # noqa: BLE001
449
  await self._reply(event, "❌ I couldn't read that image.")
450
  return
 
 
 
451
  await self.db.set_thumb(event.sender_id, thumb)
452
  await self._reply(event, "✅ Thumbnail saved. It will be used for the files I send back.", [[("🗑 Delete", "th:rm"), ("✖ Close", "x:close")]])
453
 
 
 
 
 
 
 
 
454
  # ------------------------------------------------------------ rename flow
 
 
 
 
 
455
  async def cmd_rename(self, event: Any) -> None:
456
- arg = self._args(event)
457
- reply = await event.get_reply_message()
458
- if reply is None or not is_renameable(reply) or not arg:
459
- await self._reply(event, "Reply to a file with <code>/rename New Name</code>.\nThe extension always stays the same.")
460
- return
461
- await self.enqueue(event, reply, manual=arg)
462
 
463
  async def on_media(self, event: Any) -> None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
464
  await self.enqueue(event, event.message)
465
 
466
- async def enqueue(self, event: Any, msg: Any, manual: Optional[str] = None) -> None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
467
  uid = event.sender_id
468
  size = int(msg.file.size or 0) if msg.file is not None else 0
469
  if size > self.cfg.max_file_bytes:
@@ -472,29 +824,28 @@ class App:
472
  s = await self.db.get_settings(uid)
473
  orig = original_name_of(msg)
474
  ext = source_ext(msg, orig)
 
 
 
 
 
475
  notes: List[str] = []
476
- if manual:
477
- new_name = build_manual_name(manual, ext)
478
- else:
479
- new_name, info = compute_new_name(
480
- template=s["template"], mode=s["mode"], original_name=orig, original_ext=ext,
481
- caption=msg.raw_text or "", remove_words=s["remove_words"],
482
  )
483
- gaps = missing_variables(s["template"], info)
484
- if gaps:
485
- where_from = "caption" if s["mode"] == "caption" else "file name"
486
- notes.append(
487
- "Couldn't find " + ", ".join("{%s}" % g for g in gaps) + f" in the {where_from}, so it is left empty. "
488
- "Cancel and use /rename, or change the template."
489
- )
490
  job = Job(
491
  id=self.queue.new_id(), user_id=uid, chat_id=event.chat_id, msg_id=msg.id,
492
- file_name=orig, size=size, new_name=new_name, manual=bool(manual), notes=notes, order=event.id,
493
  )
494
  ahead = self.queue.running + self.queue.waiting
495
  where = "starting soon" if ahead == 0 else f"{ahead} ahead of it"
 
496
  job.status_msg = await event.reply(
497
- f"📥 <b>Queued</b> ({where})\n<code>{esc(clip(new_name, 120))}</code>" + "".join(f"\n⚠️ {esc(n)}" for n in notes),
498
  buttons=panels.to_buttons([[("✖ Cancel", f"job:x:{job.id}")]]),
499
  parse_mode="html",
500
  )
@@ -528,6 +879,18 @@ class App:
528
  if not arg and self.pending.pop(uid, None) is not None:
529
  await self._reply(event, "Cancelled — nothing was changed.")
530
  return
 
 
 
 
 
 
 
 
 
 
 
 
531
  owner_scope = None if self.is_owner(uid) else uid
532
  if arg.isdigit():
533
  ok = self.queue.cancel(int(arg), user_id=owner_scope)
@@ -536,42 +899,492 @@ class App:
536
  count = self.queue.cancel_all() if (arg == "all" and self.is_owner(uid)) else self.queue.cancel_user(uid)
537
  await self._reply(event, f"🛑 Cancelling {count} job(s)…" if count else "You have no active jobs.")
538
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
539
  # ------------------------------------------------------------------ tracks
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
540
  async def cmd_tracks(self, event: Any) -> None:
541
  reply = await event.get_reply_message()
542
  if reply is None or not is_renameable(reply):
543
  await self._reply(event, "Reply to a video or audio file with /tracks to list its audio and subtitle tracks.")
544
  return
545
  status = await self._reply(event, "🔎 Reading the tracks…")
546
- name = original_name_of(reply)
547
- ext = source_ext(reply, name)
548
- tmp_dir = Path(self.cfg.temp_dir) / f"tracks{event.chat_id}_{event.id}"
549
- tmp_dir.mkdir(parents=True, exist_ok=True)
550
- try:
551
- path = tmp_dir / f"probe{ext}"
552
- size = int(reply.file.size or 0)
553
- info = None
554
- await transfer.download(
555
- self.client, reply, path, fast=self.cfg.fast_transfer, connections=2, max_bytes=TRACKS_PROBE_BYTES
556
- )
557
  try:
558
  info = await media.probe(path)
559
  except media.MediaError:
560
- info = None
561
- if (info is None or not info.streams) and 0 < size <= TRACKS_FULL_LIMIT and size > TRACKS_PROBE_BYTES:
562
- await self.client.edit_message(status, "🔎 Reading the tracks (this one needs the whole file)…", parse_mode="html")
563
- await transfer.download(self.client, reply, path, fast=self.cfg.fast_transfer, connections=self.cfg.connections)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
564
  try:
565
- info = await media.probe(path)
566
  except media.MediaError:
567
- info = None
568
- if info is None or not info.streams:
569
- await self.client.edit_message(status, "❌ I couldn't read any tracks from that file.", parse_mode="html")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
570
  return
571
- text = f"<b>{esc(clip(name, 80))}</b>\n\n{media.format_tracks(info)}"
572
- await self.client.edit_message(status, text[:3900], parse_mode="html")
573
- finally:
574
- shutil.rmtree(tmp_dir, ignore_errors=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
575
 
576
  # ----------------------------------------------------------- status/stats
577
  async def cmd_status(self, event: Any) -> None:
@@ -630,6 +1443,12 @@ class App:
630
  if p is None or p.expires < time.time():
631
  self.pending.pop(uid, None)
632
  return
 
 
 
 
 
 
633
  ok, msg = await apply_setting(self.db, uid, p.kind, event.raw_text)
634
  if not ok:
635
  await self._reply(event, msg + "\n\nTry again, or tap Cancel.")
@@ -674,6 +1493,33 @@ class App:
674
  await self._cb_set(event, uid, parts[1], parts[2])
675
  return
676
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
677
  if head == "in":
678
  await self._cb_input(event, uid, parts)
679
  return
@@ -776,8 +1622,182 @@ class App:
776
  msg_obj = await event.get_message()
777
  self.pending[uid] = Pending(
778
  kind=kind, chat_id=event.chat_id, msg_id=event.message_id, back=BACK[kind],
779
- is_photo=bool(msg_obj is not None and msg_obj.photo is not None), expires=time.time() + self.PENDING_TTL,
 
780
  )
781
  text, rows = panels.input_prompt(kind, BACK[kind])
782
  await self._edit_panel(event, text, rows)
783
  await event.answer()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  from __future__ import annotations
3
 
4
  import asyncio
5
+ import base64
6
  import html
7
  import io
8
+ import json
9
  import logging
10
  import random
11
  import re
12
  import shutil
13
  import time
14
  from dataclasses import dataclass
15
+ from datetime import timezone
16
  from pathlib import Path
17
  from typing import Any, Awaitable, Callable, Dict, List, Optional, Set
18
 
19
  import telethon
20
  from telethon import errors, events, functions, types
21
+ from telethon.tl.custom import Button
22
 
23
  from . import fasttransfer as transfer
24
+ from . import media, mediainfo, panels, speed, sysinfo
25
  from .config import Config
26
+ from .db import MAX_FORMATS, Database
27
+ from .errors import FormatError
28
+ from .formats import FIELD_KINDS, 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
34
+ from .telegraph import Telegraph, TelegraphError
35
+ from .utils import clip, esc, human_size, human_uptime
36
 
37
  log = logging.getLogger(__name__)
38
 
39
  COMMANDS = [
40
  ("start", "Open the main menu"),
41
  ("help", "How to use the bot"),
42
+ ("rename", "Rename files with a saved format"),
43
+ ("formats", "Create and edit your saved formats"),
44
+ ("sequence", "Collect files to sort by episode"),
45
+ ("sort", "Sort the collected files by episode"),
46
  ("mode", "Choose Filename or Caption mode"),
47
  ("template", "Set the filename template"),
48
  ("caption", "Set the caption template"),
 
51
  ("keepaudio", "Keep audio tracks by language or number"),
52
  ("keepsubtitle", "Keep subtitle tracks by language or number"),
53
  ("tracks", "List the tracks of a file (reply to it)"),
54
+ ("check", "See what I can read from a file (reply to it)"),
55
+ ("mediainfo", "Full media details of a file on Telegraph (reply to it)"),
56
+ ("ethumb", "Extract the thumbnail of a file or video"),
57
+ ("resize", "Resize an image, for thumbnails"),
58
  ("thumb", "View your thumbnail"),
59
  ("delthumb", "Delete your thumbnail"),
60
  ("queue", "Show your queued files"),
61
  ("cancel", "Cancel your current job"),
62
+ ("speed", "Test the bot's internet speed (Ookla)"),
63
  ("status", "Show basic bot uptime, ping, and version information."),
64
  ("stats", "Show detailed system stats such as users, CPU, RAM, and disk usage."),
65
  ]
66
+ OWNER_COMMANDS = {"addadmin", "deladmin", "admins", "smedia"}
67
  BACK = {
68
  "template": "rename", "remove": "rename", "caption": "cap",
69
  "mt_title": "meta", "mt_author": "meta", "mt_artist": "meta",
70
  "mt_audio": "meta", "mt_subtitle": "meta", "mt_video": "meta",
71
  "keep_audio": "tool", "keep_subtitle": "tool",
72
  }
73
+ PANEL_NAMES = {"home", "rename", "cap", "meta", "tool", "ftype", "help", "admin", "formats"}
74
  QUEUE_SETTLE = 1.5 # seconds an idle worker waits so files sent together are queued in message order
75
+ ONE_FILE_WAIT = 600 # seconds the bot waits for the file after /rename (or /ethumb ...) before forgetting about it
76
+ ONE_SHOT_GRACE = 8 # seconds after a one-file command during which further files get a hint, not an auto-rename
77
+ ONE_SHOT_TAKES = { # what each one-file command consumes; further files of that kind sent right after it are skipped
78
+ "rename": {"doc"}, "ethumb": {"doc"}, "resize": {"doc", "photo"}, "smedia": {"doc", "photo", "gif"},
79
+ }
80
+ ETHUMB_FRAME_WIDTH = 1280
81
+ START_MEDIA_MAX_BYTES = 10 * 1024 * 1024 # biggest /start picture or GIF; a copy of it is kept in the database
82
+ START_IMAGE_BOX = 1280 # pictures are shrunk to fit this box, as Telegram does with photos
83
+ RESIZE_MAX_BYTES = 20 * 1024 * 1024
84
+ SEQUENCE_WAIT = 1800 # seconds /sequence keeps the collected files without a new one arriving
85
+ SEQUENCE_MAX = 200 # files one /sequence collects
86
+ SEQUENCE_STATUS_DELAY = 1.2 # seconds without a new file before "N files collected" is sent
87
+ SORT_GAP = 0.3 # seconds between the files /sort sends back
88
+ SORT_MAX_WAIT = 120 # a Telegram flood wait longer than this (seconds) stops /sort instead of being waited out
89
+ SORT_PROGRESS_EVERY = 10 # files between two updates of the "Sorting…" message
90
  TRACKS_PROBE_BYTES = 12 * 1024 * 1024
91
  TRACKS_FULL_LIMIT = 700 * 1024 * 1024
92
 
 
99
  back: str
100
  is_photo: bool
101
  expires: float
102
+ fid: int = 0 # the saved format a "fmt:*" prompt is about (0 = one that is being created)
103
+
104
+
105
+ @dataclass
106
+ class Mode:
107
+ """What a user's next files are for: a /rename format session, /sequence, /ethumb, /resize or /smedia."""
108
+
109
+ kind: str
110
+ ttl: float
111
+ expires: float
112
+ data: Any = None
113
+ dropped: str = "" # a note about what starting this mode threw away, for the reply that announces it
114
 
115
 
116
  def cmd_pattern(name: str) -> "re.Pattern[str]":
117
  return re.compile(rf"(?is)^/{name}(?:@\w+)?(?:\s+(.*))?$")
118
 
119
 
120
+ MEDIAINFO_CAPTION = cmd_pattern("mediainfo") # a file sent with this as its caption is for /mediainfo, not for renaming
121
+
122
+
123
  def visible_len(html_text: str) -> int:
124
  return len(html.unescape(re.sub(r"<[^>]+>", "", html_text)))
125
 
 
135
  )
136
 
137
 
138
+ def is_image(message: Any) -> bool:
139
+ """A photo, or a file that is a still picture (not a GIF animation)."""
140
+ if getattr(message, "photo", None) is not None:
141
+ return True
142
+ f = getattr(message, "file", None)
143
+ return bool(
144
+ getattr(message, "document", None) is not None and not getattr(message, "gif", None)
145
+ and f is not None and (f.mime_type or "").startswith("image/")
146
+ )
147
+
148
+
149
+ def sent_as(message: Any) -> str:
150
+ """How Telegram labels the file: a video, an audio file, a voice message ..., or a plain document."""
151
+ for attr, label in (
152
+ ("voice", "Voice message"), ("video_note", "Video message"), ("gif", "GIF"), ("sticker", "Sticker"),
153
+ ("video", "Video"), ("audio", "Audio"),
154
+ ):
155
+ if getattr(message, attr, None) is not None:
156
+ return label
157
+ return "Document"
158
+
159
+
160
+ def is_playable(message: Any, name: str) -> bool:
161
+ """A file that should hold audio or video streams, going by its MIME type and extension."""
162
+ mime = (getattr(message.file, "mime_type", "") or "").lower()
163
+ return mime.startswith(("video/", "audio/")) or source_ext(message, name).lower() in PROBE_EXTS
164
+
165
+
166
+ def telegram_facts(message: Any, caption: bool = True) -> Dict[str, str]:
167
+ """What Telegram knows about the file, for the "Telegram" part of the /mediainfo page."""
168
+ date = getattr(message, "date", None)
169
+ stamp = ""
170
+ if date is not None:
171
+ if date.tzinfo is None:
172
+ date = date.replace(tzinfo=timezone.utc)
173
+ stamp = date.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
174
+ return {
175
+ "mime": getattr(message.file, "mime_type", "") or "",
176
+ "sent_as": sent_as(message),
177
+ "sent_on": stamp,
178
+ "dc": str(getattr(message.document, "dc_id", "") or ""),
179
+ "caption": clip((message.raw_text or "").strip(), 500) if caption else "",
180
+ }
181
+
182
+
183
  class App:
184
  PENDING_TTL = 300
185
 
 
189
  self.db = db
190
  self.admins: Set[int] = set(cfg.admin_ids)
191
  self.pending: Dict[int, Pending] = {}
192
+ self.modes: Dict[int, Mode] = {}
193
+ self._recent: Dict[int, List[Any]] = {} # uid -> [command, finished_at, hint_sent] of the last one-file command
194
+ self._sorting: Dict[int, asyncio.Event] = {} # users whose /sort is sending files; set to stop it
195
+ self._speed_lock = asyncio.Lock() # one speed test at a time
196
+ self.telegraph = Telegraph(self._load_telegraph_token, self._save_telegraph_token)
197
+ self._tasks: Set["asyncio.Future[Any]"] = set()
198
  self.renamer = Renamer(client, cfg, db, self._log_send)
199
  self.queue = JobQueue(self.renamer.process, workers=cfg.max_jobs, on_cancelled=self._on_job_cancelled, settle=QUEUE_SETTLE)
200
  self._warned: Set[int] = set()
 
220
  self.queue.start()
221
 
222
  async def shutdown(self) -> None:
223
+ for task in list(self._tasks):
224
+ task.cancel()
225
  await self.queue.stop()
226
 
227
  async def _set_commands(self) -> None:
 
294
  )
295
  add(self._guard(self.on_media), events.NewMessage(incoming=True, func=lambda e: e.is_private and is_renameable(e.message)))
296
  add(self._guard(self.on_photo), events.NewMessage(incoming=True, func=lambda e: e.is_private and e.message.photo is not None))
297
+ add(self._guard(self.on_gif), events.NewMessage(incoming=True, func=lambda e: e.is_private and e.message.gif is not None))
298
  add(self._guard(self.on_text), events.NewMessage(incoming=True, func=self._wants_text))
299
  add(self._guard(self.on_callback), events.CallbackQuery())
300
  add(self._on_stranger, events.NewMessage(incoming=True, func=lambda e: e.is_private and not self.is_allowed(e.sender_id)))
 
308
 
309
  def _command_table(self) -> Dict[str, Callable[[Any], Awaitable[None]]]:
310
  table: Dict[str, Callable[[Any], Awaitable[None]]] = {
311
+ "start": self.cmd_start, "help": self.cmd_help, "rename": self.cmd_rename, "formats": self.cmd_formats,
312
+ "sequence": self.cmd_sequence, "sort": self.cmd_sort, "mode": self.cmd_mode,
313
  "template": self.cmd_template, "caption": self.cmd_caption, "filetype": self.cmd_filetype,
314
  "metadata": self.cmd_metadata, "keepaudio": self._cmd_keep("keep_audio", "Keep Audio", "/keepaudio English Japanese"),
315
  "keepsubtitle": self._cmd_keep("keep_subtitle", "Keep Subtitle", "/keepsubtitle English"),
316
+ "tracks": self.cmd_tracks, "check": self.cmd_check, "mediainfo": self.cmd_mediainfo,
317
+ "ethumb": self.cmd_ethumb, "resize": self.cmd_resize,
318
+ "thumb": self.cmd_thumb, "delthumb": self.cmd_delthumb,
319
  "removewords": self.cmd_remove, "setchannel": self.cmd_setchannel, "queue": self.cmd_queue,
320
+ "cancel": self.cmd_cancel, "speed": self.cmd_speed, "status": self.cmd_status, "stats": self.cmd_stats,
321
+ "addadmin": self.cmd_addadmin, "deladmin": self.cmd_deladmin, "admins": self.cmd_admins, "smedia": self.cmd_smedia,
322
  }
323
  for command, kind in META_COMMANDS.items():
324
  table[command] = self._cmd_meta_field(command, kind)
 
349
  return panels.filetype(s)
350
  if name == "help":
351
  return panels.help_panel()
352
+ if name == "formats":
353
+ return panels.formats_menu(await self.db.list_formats(uid), MAX_FORMATS)
354
  if name == "admin":
355
  return panels.admin(
356
  self.cfg.owner_id, sorted(self.admins), self.queue.running, self.queue.waiting, self.cfg.log_channel
 
373
  except errors.MessageNotModifiedError:
374
  pass
375
 
376
+ # ------------------------------------------------------------------ modes
377
+ def _mode(self, uid: int, *kinds: str) -> Optional[Mode]:
378
+ """The user's active mode (of one of ``kinds`` if given); an expired one is dropped."""
379
+ mode = self.modes.get(uid)
380
+ if mode is not None and mode.expires < time.time():
381
+ self._end_mode(uid)
382
+ return None
383
+ if mode is None or (kinds and mode.kind not in kinds):
384
+ return None
385
+ return mode
386
+
387
+ def _start_mode(self, uid: int, kind: str, ttl: float, data: Any = None) -> Mode:
388
+ previous = self._end_mode(uid)
389
+ mode = Mode(kind=kind, ttl=ttl, expires=time.time() + ttl, data=data)
390
+ if previous is not None and previous.kind == "sequence" and previous.expires >= time.time() and previous.data.items:
391
+ mode.dropped = f"⚠️ Your /sequence with {len(previous.data.items)} file(s) was discarded."
392
+ self.modes[uid] = mode
393
+ return mode
394
+
395
+ def _end_mode(self, uid: int) -> Optional[Mode]:
396
+ mode = self.modes.pop(uid, None)
397
+ if mode is not None and isinstance(mode.data, SequenceState):
398
+ mode.data.closed = True
399
+ return mode
400
+
401
+ def _spawn(self, coro: Awaitable[Any]) -> "asyncio.Future[Any]":
402
+ task = asyncio.ensure_future(coro)
403
+ self._tasks.add(task)
404
+ task.add_done_callback(self._tasks.discard)
405
+ return task
406
+
407
+ def _consume(self, uid: int, kind: str) -> Optional[Mode]:
408
+ """Take the user's one-file mode. Nothing awaits in here, so of several files that arrive together only one gets it."""
409
+ mode = self._mode(uid, kind)
410
+ if mode is None:
411
+ return None
412
+ del self.modes[uid]
413
+ self._recent[uid] = [kind, time.time(), False]
414
+ return mode
415
+
416
+ def _extra_file_hint(self, uid: int, via: str = "doc") -> Optional[str]:
417
+ """After a one-file command: ``""`` for a further file to skip silently, a hint for the first one, ``None`` once the grace is over.
418
+
419
+ ``via`` is how the file arrived (``doc``, ``photo`` or ``gif``); only files the command itself would have taken are skipped.
420
+ """
421
+ rec = self._recent.get(uid)
422
+ if rec is None:
423
+ return None
424
+ if time.time() - rec[1] > ONE_SHOT_GRACE:
425
+ del self._recent[uid]
426
+ return None
427
+ if via not in ONE_SHOT_TAKES.get(rec[0], {"doc"}):
428
+ return None
429
+ if rec[2]:
430
+ return ""
431
+ rec[2] = True
432
+ return f"ℹ️ /{rec[0]} handles one file at a time, so I skipped this one. Send /{rec[0]} again for the next file."
433
+
434
  # --------------------------------------------------------------- commands
435
  async def cmd_start(self, event: Any) -> None:
436
  self.pending.pop(event.sender_id, None)
437
  text, rows = await self.panel(event.sender_id, "home")
438
+ if not await self._send_start_media(event.chat_id, text, rows):
439
+ await self._reply(event, text, rows)
 
 
 
 
 
 
 
440
 
441
  async def cmd_help(self, event: Any) -> None:
442
  text, _ = panels.help_panel()
 
595
  await self._reply(event, "You have no thumbnail.")
596
 
597
  async def on_photo(self, event: Any) -> None:
598
+ uid = event.sender_id
599
+ p = self.pending.get(uid)
600
+ if not (p is not None and p.kind == "fmt:thumb" and p.expires >= time.time()):
601
+ if await self._image_mode(event):
602
+ return
603
+ hint = self._extra_file_hint(uid, "photo")
604
+ if hint is not None:
605
+ if hint:
606
+ await self._reply(event, hint)
607
+ return
608
  raw = await event.download_media(bytes)
609
  if not raw:
610
  await self._reply(event, "❌ I couldn't download that photo.")
 
614
  except Exception: # noqa: BLE001
615
  await self._reply(event, "❌ I couldn't read that image.")
616
  return
617
+ if p is not None and p.kind == "fmt:thumb" and p.expires >= time.time():
618
+ await self._format_thumb_saved(event, p, thumb)
619
+ return
620
  await self.db.set_thumb(event.sender_id, thumb)
621
  await self._reply(event, "✅ Thumbnail saved. It will be used for the files I send back.", [[("🗑 Delete", "th:rm"), ("✖ Close", "x:close")]])
622
 
623
+ async def on_gif(self, event: Any) -> None:
624
+ if await self._image_mode(event):
625
+ return
626
+ hint = self._extra_file_hint(event.sender_id, "gif")
627
+ if hint:
628
+ await self._reply(event, hint)
629
+
630
  # ------------------------------------------------------------ rename flow
631
+ async def cmd_formats(self, event: Any) -> None:
632
+ self.pending.pop(event.sender_id, None)
633
+ text, rows = await self.panel(event.sender_id, "formats")
634
+ await self._reply(event, text, rows)
635
+
636
  async def cmd_rename(self, event: Any) -> None:
637
+ uid = event.sender_id
638
+ self.pending.pop(uid, None)
639
+ text, rows = panels.rename_picker(await self.db.list_formats(uid))
640
+ await self._reply(event, text, rows)
 
 
641
 
642
  async def on_media(self, event: Any) -> None:
643
+ uid = event.sender_id
644
+ if MEDIAINFO_CAPTION.match(event.raw_text or ""):
645
+ return # cmd_mediainfo has it
646
+ mode = self._consume(uid, "rename")
647
+ if mode is not None:
648
+ await self._media_rename(event, mode)
649
+ return
650
+ mode = self._consume(uid, "ethumb")
651
+ if mode is not None:
652
+ await self._send_thumbnail(event, event.message)
653
+ return
654
+ if await self._image_mode(event):
655
+ return
656
+ mode = self._mode(uid, "sequence")
657
+ if mode is not None:
658
+ notice = self._collect(event, mode)
659
+ if notice:
660
+ await self._reply(event, notice)
661
+ return
662
+ hint = self._extra_file_hint(uid)
663
+ if hint is not None:
664
+ if hint:
665
+ await self._reply(event, hint)
666
+ return
667
  await self.enqueue(event, event.message)
668
 
669
+ async def _media_rename(self, event: Any, mode: Mode) -> None:
670
+ fmt = await self.db.get_format(event.sender_id, mode.data["fid"])
671
+ if fmt is None:
672
+ await self._reply(event, "⚠️ That format was deleted. Use /rename to pick another one, then send the file again.")
673
+ return
674
+ await self.enqueue(event, event.message, fmt=fmt)
675
+
676
+ # --------------------------------------------------------- /sequence, /sort
677
+ async def cmd_sequence(self, event: Any) -> None:
678
+ uid = event.sender_id
679
+ self.pending.pop(uid, None)
680
+ mode = self._mode(uid, "sequence")
681
+ if mode is not None and mode.data.items: # a second /sequence must not throw the collected files away
682
+ mode.expires = time.time() + mode.ttl
683
+ text, rows = panels.sequence_status(len(mode.data.items), SEQUENCE_MAX)
684
+ await self._reply(event, "You're already collecting files for a sequence.\n\n" + text, rows)
685
+ return
686
+ mode = self._start_mode(uid, "sequence", SEQUENCE_WAIT, SequenceState())
687
+ text, rows = panels.sequence_intro(SEQUENCE_MAX)
688
+ await self._reply(event, text + (f"\n\n{mode.dropped}" if mode.dropped else ""), rows)
689
+
690
+ def _collect(self, event: Any, mode: Mode) -> Optional[str]:
691
+ """Add a file to the user's sequence. Nothing awaits in here, so files that arrive together are all kept."""
692
+ state: SequenceState = mode.data
693
+ mode.expires = time.time() + mode.ttl
694
+ if len(state.items) >= SEQUENCE_MAX:
695
+ if state.full_noted:
696
+ return None
697
+ state.full_noted = True
698
+ return f"⚠️ That's the limit: {SEQUENCE_MAX} files. Send /sort now, or /cancel to start over."
699
+ msg = event.message
700
+ state.items.append(SeqItem(msg_id=msg.id, name=original_name_of(msg), caption=msg.raw_text or ""))
701
+ state.version += 1
702
+ if state.task is None or state.task.done():
703
+ state.task = self._spawn(self._sequence_status(event.chat_id, state))
704
+ return None
705
+
706
+ async def _sequence_status(self, chat_id: int, state: SequenceState) -> None:
707
+ """Send "N files collected" once the files stop arriving, so it is the newest message in the chat."""
708
+ try:
709
+ while not state.closed:
710
+ seen = state.version
711
+ await asyncio.sleep(SEQUENCE_STATUS_DELAY)
712
+ if state.closed:
713
+ return
714
+ if state.version != seen:
715
+ continue
716
+ text, rows = panels.sequence_status(len(state.items), SEQUENCE_MAX)
717
+ previous = state.status_id
718
+ sent = await self.client.send_message(chat_id, text, buttons=panels.to_buttons(rows), parse_mode="html")
719
+ if state.closed: # /sort or /cancel came while it was being sent
720
+ await self._delete_quietly(chat_id, sent.id)
721
+ return
722
+ state.status_id = sent.id
723
+ if previous is not None:
724
+ await self._delete_quietly(chat_id, previous)
725
+ if state.version == seen:
726
+ return
727
+ except asyncio.CancelledError:
728
+ raise
729
+ except Exception: # noqa: BLE001
730
+ log.exception("could not send the sequence status")
731
+
732
+ async def _delete_quietly(self, chat_id: int, *ids: int) -> None:
733
+ try:
734
+ await self.client.delete_messages(chat_id, list(ids))
735
+ except Exception as exc: # noqa: BLE001
736
+ log.debug("could not delete messages %s: %s", ids, exc)
737
+
738
+ async def cmd_sort(self, event: Any) -> None:
739
+ await self._run_sort(event.sender_id, event.chat_id)
740
+
741
+ @staticmethod
742
+ async def _pause(seconds: float) -> None:
743
+ await asyncio.sleep(seconds)
744
+
745
+ async def _send_back(self, chat_id: int, msg: Any) -> None:
746
+ """Send one collected file again (no re-upload), keeping its caption and formatting."""
747
+ for attempt in range(3):
748
+ try:
749
+ await self.client.send_file(chat_id, msg, caption=msg.raw_text or "", formatting_entities=msg.entities, parse_mode=None)
750
+ return
751
+ except errors.FloodWaitError as exc:
752
+ if attempt == 2 or exc.seconds > SORT_MAX_WAIT:
753
+ raise
754
+ await self._pause(exc.seconds + 1)
755
+
756
+ async def _run_sort(self, uid: int, chat_id: int) -> None:
757
+ say = lambda text, **kw: self.client.send_message(chat_id, text, parse_mode="html", link_preview=False, **kw) # noqa: E731
758
+ if uid in self._sorting:
759
+ await say("⏳ I'm still sorting your last batch. /cancel to stop it.")
760
+ return
761
+ mode = self._mode(uid, "sequence")
762
+ state: Optional[SequenceState] = mode.data if mode is not None else None
763
+ if state is None or not state.items:
764
+ await say("Nothing to sort yet. Send /sequence, then the files, then /sort.")
765
+ return
766
+ self._end_mode(uid)
767
+ cancel = self._sorting[uid] = asyncio.Event()
768
+ total = len(state.items)
769
+ status = None
770
+ try:
771
+ if state.status_id is not None:
772
+ await self._delete_quietly(chat_id, state.status_id)
773
+ s = await self.db.get_settings(uid)
774
+ plan = analyse(state.items, s["mode"])
775
+ status = await say(f"🔢 <b>Sorting {total} file{'s' if total != 1 else ''}…</b>")
776
+ fetched = list(await self.client.get_messages(chat_id, ids=[i.msg_id for i in plan.ordered]))
777
+ fetched += [None] * (len(plan.ordered) - len(fetched))
778
+
779
+ sent = 0
780
+ failed: List[SeqItem] = []
781
+ note = ""
782
+ stopped = False
783
+ for index, (item, msg) in enumerate(zip(plan.ordered, fetched)):
784
+ if cancel.is_set():
785
+ stopped = True
786
+ break
787
+ if msg is None or getattr(msg, "file", None) is None: # deleted since it was sent
788
+ failed.append(item)
789
+ continue
790
+ try:
791
+ await self._send_back(chat_id, msg)
792
+ except errors.FloodWaitError as exc:
793
+ note = f"⚠️ Telegram asked me to wait {human_uptime(exc.seconds)} before sending more, so I stopped here."
794
+ failed.extend(plan.ordered[index:])
795
+ break
796
+ except Exception as exc: # noqa: BLE001
797
+ log.warning("could not send %s back: %s", item.name, exc)
798
+ failed.append(item)
799
+ continue
800
+ sent += 1
801
+ if sent % SORT_PROGRESS_EVERY == 0 and sent < total:
802
+ try:
803
+ await self.client.edit_message(status, f"🔢 <b>Sorting…</b> {sent} of {total} sent", parse_mode="html")
804
+ except Exception: # noqa: BLE001
805
+ pass
806
+ if SORT_GAP:
807
+ await asyncio.sleep(SORT_GAP)
808
+ if status is not None:
809
+ await self._delete_quietly(chat_id, status.id)
810
+ if stopped:
811
+ await say(f"⏹ Stopped after {sent} of {total} file{'s' if total != 1 else ''}.")
812
+ return
813
+ for chunk in format_report(plan, total, sent, failed, note):
814
+ await say(chunk)
815
+ finally:
816
+ self._sorting.pop(uid, None)
817
+
818
+ async def enqueue(self, event: Any, msg: Any, fmt: Optional[Dict[str, Any]] = None) -> None:
819
  uid = event.sender_id
820
  size = int(msg.file.size or 0) if msg.file is not None else 0
821
  if size > self.cfg.max_file_bytes:
 
824
  s = await self.db.get_settings(uid)
825
  orig = original_name_of(msg)
826
  ext = source_ext(msg, orig)
827
+ template = fmt["template"] if fmt else s["template"]
828
+ new_name, info = compute_new_name(
829
+ template=template, mode=s["mode"], original_name=orig, original_ext=ext,
830
+ caption=msg.raw_text or "", remove_words=s["remove_words"],
831
+ )
832
  notes: List[str] = []
833
+ gaps = missing_variables(template, info)
834
+ if gaps:
835
+ where_from = "caption" if s["mode"] == "caption" else "file name"
836
+ notes.append(
837
+ "Couldn't find " + ", ".join("{%s}" % g for g in gaps) + f" in the {where_from}, so it is left empty. "
838
+ "Cancel and change the template if you don't want that."
839
  )
 
 
 
 
 
 
 
840
  job = Job(
841
  id=self.queue.new_id(), user_id=uid, chat_id=event.chat_id, msg_id=msg.id,
842
+ file_name=orig, size=size, new_name=new_name, fmt=fmt, notes=notes, order=event.id,
843
  )
844
  ahead = self.queue.running + self.queue.waiting
845
  where = "starting soon" if ahead == 0 else f"{ahead} ahead of it"
846
+ used = f"\n🗂 Format: <b>{esc(clip(fmt['name'], 30))}</b>" if fmt else ""
847
  job.status_msg = await event.reply(
848
+ f"📥 <b>Queued</b> ({where}){used}\n<code>{esc(clip(new_name, 120))}</code>" + "".join(f"\n⚠️ {esc(n)}" for n in notes),
849
  buttons=panels.to_buttons([[("✖ Cancel", f"job:x:{job.id}")]]),
850
  parse_mode="html",
851
  )
 
879
  if not arg and self.pending.pop(uid, None) is not None:
880
  await self._reply(event, "Cancelled — nothing was changed.")
881
  return
882
+ if not arg and uid in self._sorting:
883
+ self._sorting[uid].set()
884
+ await self._reply(event, "⏹ Stopping the sort…")
885
+ return
886
+ if not arg:
887
+ mode = self._end_mode(uid)
888
+ if mode is not None and mode.expires >= time.time():
889
+ if isinstance(mode.data, SequenceState) and mode.data.status_id is not None:
890
+ await self._delete_quietly(event.chat_id, mode.data.status_id) # its buttons are of no use any more
891
+ busy = " Send /cancel again to cancel your queued files." if self.queue.for_user(uid) else ""
892
+ await self._reply(event, self._mode_cancelled_text(mode) + busy)
893
+ return
894
  owner_scope = None if self.is_owner(uid) else uid
895
  if arg.isdigit():
896
  ok = self.queue.cancel(int(arg), user_id=owner_scope)
 
899
  count = self.queue.cancel_all() if (arg == "all" and self.is_owner(uid)) else self.queue.cancel_user(uid)
900
  await self._reply(event, f"🛑 Cancelling {count} job(s)…" if count else "You have no active jobs.")
901
 
902
+ @staticmethod
903
+ def _mode_cancelled_text(mode: Mode) -> str:
904
+ if mode.kind == "sequence":
905
+ count = len(mode.data.items)
906
+ return f"⏹ Sequence cancelled. {count} file{'s' if count != 1 else ''} discarded." if count else "⏹ Sequence cancelled."
907
+ return {
908
+ "rename": "⏹ Cancelled. Send /rename again to pick a format.",
909
+ "smedia": "⏹ Cancelled. Send /smedia again to change the start media.",
910
+ }.get(mode.kind, "Cancelled.")
911
+
912
+ # ------------------------------------------------------------------ check
913
+ async def cmd_check(self, event: Any) -> None:
914
+ reply = await event.get_reply_message()
915
+ if reply is None or not is_renameable(reply):
916
+ await self._reply(event, "Reply to a file with /check to see the details I can read from its name and caption.")
917
+ return
918
+ s = await self.db.get_settings(event.sender_id)
919
+ name = original_name_of(reply)
920
+ caption = reply.raw_text or ""
921
+ new_name, _info = compute_new_name(
922
+ template=s["template"], mode=s["mode"], original_name=name, original_ext=source_ext(reply, name),
923
+ caption=caption, remove_words=s["remove_words"],
924
+ )
925
+ text = panels.check_text(s["mode"], parse_info(split_ext(name)[0]), parse_caption(caption), new_name)
926
+ await event.reply(text, parse_mode="html", link_preview=False)
927
+
928
  # ------------------------------------------------------------------ tracks
929
+ async def _with_file_start(
930
+ self, event: Any, msg: Any, tag: str,
931
+ attempt: Callable[[Path], Awaitable[Any]], on_whole: Optional[Callable[[], Awaitable[None]]] = None,
932
+ ) -> Any:
933
+ """Run ``attempt(path)`` on the first part of the file; when it gives nothing (an index at the end of an mp4, say) on the whole file.
934
+
935
+ The files live in a temporary folder that is removed afterwards. ``on_whole`` runs before the big download starts.
936
+ """
937
+ name = original_name_of(msg)
938
+ size = int(msg.file.size or 0)
939
+ tmp_dir = Path(self.cfg.temp_dir) / f"{tag}{event.chat_id}_{event.id}"
940
+ tmp_dir.mkdir(parents=True, exist_ok=True)
941
+ try:
942
+ path = tmp_dir / f"probe{source_ext(msg, name)}"
943
+ await transfer.download(self.client, msg, path, fast=self.cfg.fast_transfer, connections=2, max_bytes=TRACKS_PROBE_BYTES)
944
+ result = await attempt(path)
945
+ if result is None and TRACKS_PROBE_BYTES < size <= TRACKS_FULL_LIMIT:
946
+ if on_whole is not None:
947
+ await on_whole()
948
+ await transfer.download(self.client, msg, path, fast=self.cfg.fast_transfer, connections=self.cfg.connections)
949
+ result = await attempt(path)
950
+ return result
951
+ finally:
952
+ shutil.rmtree(tmp_dir, ignore_errors=True)
953
+
954
  async def cmd_tracks(self, event: Any) -> None:
955
  reply = await event.get_reply_message()
956
  if reply is None or not is_renameable(reply):
957
  await self._reply(event, "Reply to a video or audio file with /tracks to list its audio and subtitle tracks.")
958
  return
959
  status = await self._reply(event, "🔎 Reading the tracks…")
960
+
961
+ async def read(path: Path) -> Optional[media.MediaInfo]:
 
 
 
 
 
 
 
 
 
962
  try:
963
  info = await media.probe(path)
964
  except media.MediaError:
965
+ return None
966
+ return info if info.streams else None
967
+
968
+ async def whole() -> None:
969
+ await self.client.edit_message(status, "🔎 Reading the tracks (this one needs the whole file)…", parse_mode="html")
970
+
971
+ info = await self._with_file_start(event, reply, "tracks", read, whole)
972
+ if info is None:
973
+ await self.client.edit_message(status, "❌ I couldn't read any tracks from that file.", parse_mode="html")
974
+ return
975
+ text = f"<b>{esc(clip(original_name_of(reply), 80))}</b>\n\n{media.format_tracks(info)}"
976
+ await self.client.edit_message(status, text[:3900], parse_mode="html")
977
+
978
+ # --------------------------------------------------------------- /mediainfo
979
+ async def _load_telegraph_token(self) -> Optional[str]:
980
+ return await self.db.kv_get("telegraph_token")
981
+
982
+ async def _save_telegraph_token(self, token: str) -> None:
983
+ if token:
984
+ await self.db.kv_set("telegraph_token", token)
985
+ else:
986
+ await self.db.kv_delete("telegraph_token")
987
+
988
+ async def cmd_mediainfo(self, event: Any) -> None:
989
+ target, own = await event.get_reply_message(), False
990
+ if target is None or getattr(target, "document", None) is None:
991
+ target, own = event.message, True # a file sent with /mediainfo as its caption
992
+ if getattr(target, "document", None) is None:
993
+ await self._reply(event, "Reply to a file or video with /mediainfo and I'll put everything MediaInfo can read from it on a Telegraph page.")
994
+ return
995
+ status = await self._reply(event, "🔎 Reading the file…")
996
+ try:
997
+ await self._mediainfo(event, target, own, status)
998
+ except Exception: # noqa: BLE001
999
+ log.exception("/mediainfo failed")
1000
+ try:
1001
+ await self.client.edit_message(status, "⚠️ Something went wrong while reading that file.", parse_mode="html")
1002
+ except Exception: # noqa: BLE001
1003
+ pass
1004
+
1005
+ async def _mediainfo(self, event: Any, msg: Any, own: bool, status: Any) -> None:
1006
+ name = original_name_of(msg)
1007
+ size = int(msg.file.size or 0)
1008
+ playable = is_playable(msg, name)
1009
+ extra = mediainfo.telegram_section(name, size, **telegram_facts(msg, caption=not own))
1010
+ last: List[mediainfo.Report] = []
1011
+
1012
+ async def read(path: Path) -> Optional[mediainfo.Report]:
1013
+ report = await mediainfo.read_file(path, name, size, extra=extra)
1014
+ last[:] = [report]
1015
+ return report if report.has_streams or not playable else None
1016
+
1017
+ async def whole() -> None:
1018
+ await self.client.edit_message(status, "🔎 Reading the file (this one needs the whole file)…", parse_mode="html")
1019
+
1020
+ try:
1021
+ report = await self._with_file_start(event, msg, "minfo", read, whole) or (last[0] if last else None)
1022
+ except Exception as exc: # noqa: BLE001
1023
+ log.warning("could not read %s for /mediainfo: %s", name, exc)
1024
+ report = None
1025
+ if report is None:
1026
+ await self.client.edit_message(status, "❌ I couldn't read that file.", parse_mode="html")
1027
+ return
1028
+ if playable and not report.has_streams:
1029
+ report.notes.append(
1030
+ "No tracks could be read from this file, and it is too big for me to download completely."
1031
+ if size > TRACKS_FULL_LIMIT else "No tracks could be read from this file. It may be damaged."
1032
+ )
1033
+ try:
1034
+ url = await self.telegraph.create_page(clip(f"MediaInfo – {name}", 256), mediainfo.to_nodes(report))
1035
+ except Exception as exc: # noqa: BLE001
1036
+ log.warning("Telegraph did not take the /mediainfo page: %s", exc)
1037
+ why = str(exc) if isinstance(exc, TelegraphError) else "Telegraph is not available"
1038
+ await self._mediainfo_file(event, status, name, report, why)
1039
+ return
1040
+ summary = report.summary()
1041
+ text = f"🔎 <b>MediaInfo</b> · {esc(clip(name, 80))}\n" + (f"<i>{esc(summary)}</i>\n" if summary else "") + f"\n{esc(url)}"
1042
+ await self.client.edit_message(status, text, buttons=[[Button.url("📄 Open MediaInfo", url)]], parse_mode="html", link_preview=False)
1043
+
1044
+ async def _mediainfo_file(self, event: Any, status: Any, name: str, report: mediainfo.Report, why: str) -> None:
1045
+ """Telegraph did not work: send the same report as a text file instead."""
1046
+ bio = io.BytesIO(mediainfo.render_text(report).encode("utf-8"))
1047
+ bio.name = re.sub(r'[\\/:*?"<>|]+', "_", clip(split_ext(name)[0], 60)) + " - MediaInfo.txt"
1048
+ caption = f"🔎 <b>MediaInfo</b> · {esc(clip(name, 80))}\n<i>{esc(why)}, so here it is as a text file.</i>"
1049
+ await self.client.send_file(event.chat_id, bio, caption=caption, parse_mode="html", force_document=True)
1050
+ await self._delete_quietly(event.chat_id, status.id)
1051
+
1052
+ # ----------------------------------------------------------- /ethumb, /resize
1053
+ async def cmd_ethumb(self, event: Any) -> None:
1054
+ uid = event.sender_id
1055
+ self.pending.pop(uid, None)
1056
+ reply = await event.get_reply_message()
1057
+ if reply is not None and is_renameable(reply): # sent as a reply to the file: no need to ask for it
1058
+ await self._send_thumbnail(event, reply)
1059
+ return
1060
+ mode = self._start_mode(uid, "ethumb", ONE_FILE_WAIT)
1061
+ text, rows = panels.ethumb_prompt(ONE_FILE_WAIT // 60)
1062
+ await self._reply(event, text + (f"\n\n{mode.dropped}" if mode.dropped else ""), rows)
1063
+
1064
+ async def _embedded_thumb(self, msg: Any, allow_stripped: bool = False) -> Optional[bytes]:
1065
+ """The preview Telegram keeps with a file. The tiny blurred one (a "stripped" size) only counts when ``allow_stripped``."""
1066
+ thumbs = getattr(getattr(msg, "document", None), "thumbs", None) or []
1067
+ if not any(not isinstance(t, types.PhotoStrippedSize) for t in thumbs) and not (allow_stripped and thumbs):
1068
+ return None
1069
+ try:
1070
+ return await self.client.download_media(msg, file=bytes, thumb=-1) or None
1071
+ except Exception as exc: # noqa: BLE001
1072
+ log.debug("could not download the embedded thumbnail: %s", exc)
1073
+ return None
1074
+
1075
+ async def _extract_thumbnail(self, event: Any, msg: Any) -> Optional[bytes]:
1076
+ data = await self._embedded_thumb(msg)
1077
+ if data:
1078
+ return data
1079
+ name = original_name_of(msg)
1080
+ playable = getattr(msg, "video", None) is not None or getattr(msg, "audio", None) is not None
1081
+ if playable or source_ext(msg, name).lower() in PROBE_EXTS:
1082
+ async def frame(path: Path) -> Optional[bytes]:
1083
+ out = path.with_suffix(".jpg")
1084
  try:
1085
+ duration = (await media.probe(path)).duration
1086
  except media.MediaError:
1087
+ duration = 0.0 # cut off before the index: ffmpeg may still find a picture
1088
+ ok = await media.extract_frame(path, out, media.frame_seeks(duration), width=ETHUMB_FRAME_WIDTH)
1089
+ return out.read_bytes() if ok else None
1090
+
1091
+ data = await self._with_file_start(event, msg, "ethumb", frame)
1092
+ if data:
1093
+ return data
1094
+ return await self._embedded_thumb(msg, allow_stripped=True)
1095
+
1096
+ async def _send_thumbnail(self, event: Any, msg: Any) -> None:
1097
+ status = await self._reply(event, "🖼 Extracting the thumbnail…")
1098
+ name = original_name_of(msg)
1099
+ try:
1100
+ data = await self._extract_thumbnail(event, msg)
1101
+ except Exception as exc: # noqa: BLE001
1102
+ log.warning("thumbnail extraction failed for %s: %s", name, exc)
1103
+ data = None
1104
+ if not data:
1105
+ await self.client.edit_message(status, "❌ I couldn't find a thumbnail in that file or grab a frame from it.", parse_mode="html")
1106
+ return
1107
+ bio = io.BytesIO(data)
1108
+ bio.name = "thumbnail.jpg"
1109
+ await self.client.send_file(event.chat_id, bio, caption=f"🖼 Thumbnail of <b>{esc(clip(name, 80))}</b>", parse_mode="html")
1110
+ await self._delete_quietly(event.chat_id, status.id)
1111
+
1112
+ async def cmd_resize(self, event: Any) -> None:
1113
+ uid = event.sender_id
1114
+ self.pending.pop(uid, None)
1115
+ arg = self._args(event)
1116
+ box = None
1117
+ if arg:
1118
+ box = media.parse_size(arg)
1119
+ if box is None:
1120
+ await self._reply(event, "❌ Send a size like <code>/resize 320</code> or <code>/resize 320x180</code> (16 to 4096 pixels).")
1121
  return
1122
+ reply = await event.get_reply_message()
1123
+ if reply is not None and is_image(reply): # sent as a reply to the image: do it now (thumbnail size unless told otherwise)
1124
+ await self._send_resized(event, reply, box or (media.THUMB_BOX, media.THUMB_BOX))
1125
+ return
1126
+ if box is None:
1127
+ text, rows = panels.resize_menu()
1128
+ await self._reply(event, text, rows)
1129
+ return
1130
+ await self._wait_for_image(event, box)
1131
+
1132
+ async def _wait_for_image(self, event: Any, box: Any) -> None:
1133
+ mode = self._start_mode(event.sender_id, "resize", ONE_FILE_WAIT, {"box": box})
1134
+ text, rows = panels.resize_ready(box, ONE_FILE_WAIT // 60)
1135
+ await self._reply(event, text + (f"\n\n{mode.dropped}" if mode.dropped else ""), rows)
1136
+
1137
+ async def _cb_resize(self, event: Any, uid: int, parts: List[str]) -> None:
1138
+ self.pending.pop(uid, None)
1139
+ action = parts[1] if len(parts) > 1 else ""
1140
+ if action == "s" and len(parts) == 3 and parts[2].isdigit() and int(parts[2]) in dict(panels.RESIZE_PRESETS):
1141
+ box = (int(parts[2]), int(parts[2]))
1142
+ mode = self._start_mode(uid, "resize", ONE_FILE_WAIT, {"box": box})
1143
+ text, rows = panels.resize_ready(box, ONE_FILE_WAIT // 60)
1144
+ await self._edit_panel(event, text + (f"\n\n{mode.dropped}" if mode.dropped else ""), rows)
1145
+ await event.answer(f"Size: {panels.describe_box(box)}")
1146
+ elif action == "custom":
1147
+ self.pending[uid] = Pending(
1148
+ kind="resize:size", chat_id=event.chat_id, msg_id=event.message_id, back="", is_photo=False,
1149
+ expires=time.time() + self.PENDING_TTL,
1150
+ )
1151
+ await self._edit_panel(event, panels.RESIZE_CUSTOM_PROMPT, [[("✖ Cancel", "rz:menu")]])
1152
+ await event.answer()
1153
+ elif action == "menu":
1154
+ if self._mode(uid, "resize") is not None:
1155
+ self._end_mode(uid)
1156
+ text, rows = panels.resize_menu()
1157
+ await self._edit_panel(event, text, rows)
1158
+ await event.answer()
1159
+ elif action == "stop":
1160
+ if self._mode(uid, "resize") is not None:
1161
+ self._end_mode(uid)
1162
+ await self._edit_panel(event, "⏹ Stopped. Send /resize again to resize an image.", [[("✖ Close", "x:close")]])
1163
+ await event.answer()
1164
+ else:
1165
+ await event.answer()
1166
+
1167
+ async def _on_resize_text(self, event: Any, p: Pending) -> None:
1168
+ uid = event.sender_id
1169
+ box = media.parse_size(event.raw_text or "")
1170
+ if box is None:
1171
+ await self._reply(
1172
+ event, "❌ I couldn't read that size. Send it like <code>320x180</code> or <code>320</code> (16 to 4096 pixels).\n\nTry again, or tap Cancel."
1173
+ )
1174
+ return
1175
+ self.pending.pop(uid, None)
1176
+ mode = self._start_mode(uid, "resize", ONE_FILE_WAIT, {"box": box})
1177
+ try:
1178
+ await event.delete()
1179
+ except Exception: # noqa: BLE001
1180
+ pass
1181
+ text, rows = panels.resize_ready(box, ONE_FILE_WAIT // 60)
1182
+ text += f"\n\n{mode.dropped}" if mode.dropped else ""
1183
+ try:
1184
+ await self.client.edit_message(p.chat_id, p.msg_id, text, buttons=panels.to_buttons(rows), parse_mode="html")
1185
+ except Exception: # noqa: BLE001
1186
+ await self._reply(event, text, rows)
1187
+
1188
+ async def _image_mode(self, event: Any) -> bool:
1189
+ """Give an incoming picture (or GIF) to the command that is waiting for one (/resize, /smedia). True when the message was taken care of."""
1190
+ uid = event.sender_id
1191
+ mode = self._mode(uid, "resize", "smedia")
1192
+ if mode is None:
1193
+ return False
1194
+ if mode.kind == "smedia":
1195
+ await self._take_start_media(event, mode)
1196
+ return True
1197
+ if not is_image(event.message):
1198
+ await self._reply(event, "❌ That isn't an image. Send a photo or an image file, or /cancel to stop.")
1199
+ return True
1200
+ self._consume(uid, "resize")
1201
+ await self._send_resized(event, event.message, mode.data["box"])
1202
+ return True
1203
+
1204
+ async def _send_resized(self, event: Any, msg: Any, box: Any) -> None:
1205
+ size = int(msg.file.size or 0) if getattr(msg, "file", None) is not None else 0
1206
+ if size > RESIZE_MAX_BYTES:
1207
+ await self._reply(event, f"❌ That image is {human_size(size)}; I can resize images up to {human_size(RESIZE_MAX_BYTES)}.")
1208
+ return
1209
+ raw = await self.client.download_media(msg, file=bytes)
1210
+ if not raw:
1211
+ await self._reply(event, "❌ I couldn't download that image.")
1212
+ return
1213
+ try:
1214
+ data, original, new = await asyncio.to_thread(media.resize_image, raw, box[0], box[1])
1215
+ except Exception: # noqa: BLE001
1216
+ await self._reply(event, "❌ I couldn't read that image.")
1217
+ return
1218
+ bio = io.BytesIO(data)
1219
+ bio.name = "resized.jpg"
1220
+ caption = f"🖼 <b>{new[0]}×{new[1]}</b> (was {original[0]}×{original[1]}) · {human_size(len(data))}"
1221
+ await self.client.send_file(event.chat_id, bio, caption=caption, parse_mode="html", force_document=max(new) > 1280)
1222
+
1223
+ # ------------------------------------------------------- /smedia, the /start picture
1224
+ async def _start_media_meta(self) -> Optional[Dict[str, Any]]:
1225
+ """What /start shows above the menu: ``{"kind": "image" | "gif", "file_id": ..., ...}``, or None."""
1226
+ raw = await self.db.kv_get("start_media")
1227
+ if not raw:
1228
+ return None
1229
+ try:
1230
+ meta = json.loads(raw)
1231
+ except ValueError:
1232
+ return None
1233
+ return meta if isinstance(meta, dict) and meta.get("kind") in ("image", "gif") else None
1234
+
1235
+ async def _store_start_media(self, meta: str, data: str) -> None:
1236
+ """Keep the /start media (its description and a base64 copy of the file); empty strings forget it."""
1237
+ for key, value in (("start_media", meta), ("start_media_data", data)):
1238
+ if value:
1239
+ await self.db.kv_set(key, value)
1240
+ else:
1241
+ await self.db.kv_delete(key)
1242
+
1243
+ async def _send_start_media(self, chat_id: int, text: str, rows: List[panels.Row]) -> bool:
1244
+ """Send the menu with the owner's picture or GIF above it. False when there is none or it can't be sent."""
1245
+ meta = await self._start_media_meta()
1246
+ if meta is None:
1247
+ return False
1248
+ kw: Dict[str, Any] = dict(caption=text, buttons=panels.to_buttons(rows), parse_mode="html")
1249
+ sent = None
1250
+ if meta.get("file_id"):
1251
+ try:
1252
+ sent = await self.client.send_file(chat_id, meta["file_id"], **kw)
1253
+ except Exception as exc: # noqa: BLE001 - a file reference that went stale: the stored copy is used instead
1254
+ log.warning("the start media could not be sent by its file id (%s); sending the stored copy", exc)
1255
+ if sent is None:
1256
+ try:
1257
+ data = base64.b64decode(await self.db.kv_get("start_media_data") or "", validate=True)
1258
+ if not data:
1259
+ return False
1260
+ sent = await self._upload_start_media(chat_id, meta, data, **kw)
1261
+ except Exception as exc: # noqa: BLE001
1262
+ log.warning("could not send the start media (%s); /start shows the menu only", exc)
1263
+ return False
1264
+ fresh = getattr(getattr(sent, "file", None), "id", None)
1265
+ if fresh and fresh != meta.get("file_id"): # the next /start can reuse what was just uploaded
1266
+ await self.db.kv_set("start_media", json.dumps({**meta, "file_id": fresh}))
1267
+ return True
1268
+
1269
+ async def _upload_start_media(self, chat_id: int, meta: Dict[str, Any], data: bytes, **kw: Any) -> Any:
1270
+ bio = io.BytesIO(data)
1271
+ if meta["kind"] == "gif":
1272
+ bio.name = "start.mp4"
1273
+ kw.update(
1274
+ mime_type=meta.get("mime") or "video/mp4",
1275
+ attributes=[
1276
+ types.DocumentAttributeVideo(
1277
+ duration=float(meta.get("duration") or 0), w=int(meta.get("width") or 0), h=int(meta.get("height") or 0),
1278
+ supports_streaming=True,
1279
+ ),
1280
+ types.DocumentAttributeAnimated(),
1281
+ ],
1282
+ )
1283
+ else:
1284
+ bio.name = "start.jpg"
1285
+ return await self.client.send_file(chat_id, bio, **kw)
1286
+
1287
+ async def cmd_smedia(self, event: Any) -> None:
1288
+ self.pending.pop(event.sender_id, None)
1289
+ meta = await self._start_media_meta()
1290
+ text, rows = panels.smedia_menu(meta["kind"] if meta else "")
1291
+ await self._reply(event, text, rows)
1292
+
1293
+ async def _cb_smedia(self, event: Any, uid: int, parts: List[str]) -> None:
1294
+ if not self.is_owner(uid):
1295
+ await event.answer("Owner only.", alert=True)
1296
+ return
1297
+ self.pending.pop(uid, None)
1298
+ action = parts[1] if len(parts) > 1 else ""
1299
+ if action in ("image", "gif"):
1300
+ mode = self._start_mode(uid, "smedia", ONE_FILE_WAIT, {"kind": action})
1301
+ text, rows = panels.smedia_prompt(action, ONE_FILE_WAIT // 60)
1302
+ await self._edit_panel(event, text + (f"\n\n{mode.dropped}" if mode.dropped else ""), rows)
1303
+ await event.answer()
1304
+ elif action == "menu":
1305
+ if self._mode(uid, "smedia") is not None:
1306
+ self._end_mode(uid)
1307
+ meta = await self._start_media_meta()
1308
+ text, rows = panels.smedia_menu(meta["kind"] if meta else "")
1309
+ await self._edit_panel(event, text, rows)
1310
+ await event.answer()
1311
+ elif action == "rm":
1312
+ await self._store_start_media("", "")
1313
+ text, rows = panels.smedia_menu("")
1314
+ await self._edit_panel(event, text, rows)
1315
+ await event.answer("Start media removed.")
1316
+ elif action == "stop":
1317
+ if self._mode(uid, "smedia") is not None:
1318
+ self._end_mode(uid)
1319
+ await self._edit_panel(event, "⏹ Stopped. Send /smedia again to change the start media.", [[("✖ Close", "x:close")]])
1320
+ await event.answer()
1321
+ else:
1322
+ await event.answer()
1323
+
1324
+ async def _take_start_media(self, event: Any, mode: Mode) -> None:
1325
+ """The owner sent the picture or GIF /smedia asked for: keep it, then show /start with it."""
1326
+ uid, msg, want = event.sender_id, event.message, mode.data["kind"]
1327
+ is_gif = getattr(msg, "gif", None) is not None
1328
+ if want == "gif" and not is_gif:
1329
+ await self._reply(event, "❌ That isn't a GIF. Send a GIF (an animation), or /cancel to stop.")
1330
+ return
1331
+ if want == "image" and (is_gif or not is_image(msg)):
1332
+ await self._reply(event, "❌ That isn't an image. Send a photo or an image file, or /cancel to stop.")
1333
+ return
1334
+ size = int(msg.file.size or 0) if getattr(msg, "file", None) is not None else 0
1335
+ if size > START_MEDIA_MAX_BYTES:
1336
+ await self._reply(
1337
+ event, f"❌ That's {human_size(size)}; the biggest I can keep is {human_size(START_MEDIA_MAX_BYTES)}. Send a smaller one, or /cancel to stop."
1338
+ )
1339
+ return
1340
+ self._consume(uid, "smedia")
1341
+ raw = await self.client.download_media(msg, file=bytes)
1342
+ if not raw:
1343
+ await self._reply(event, "❌ I couldn't download that. Send /smedia to try again.")
1344
+ return
1345
+ try:
1346
+ if want == "gif":
1347
+ data, meta = raw, {
1348
+ "kind": "gif", "mime": msg.file.mime_type or "video/mp4", "width": int(msg.file.width or 0),
1349
+ "height": int(msg.file.height or 0), "duration": float(msg.file.duration or 0),
1350
+ }
1351
+ elif getattr(msg, "photo", None) is not None: # Telegram already made it a JPEG that fits
1352
+ data, meta = raw, {"kind": "image"}
1353
+ else:
1354
+ data, meta = await asyncio.to_thread(media.fit_photo, raw, START_IMAGE_BOX), {"kind": "image"}
1355
+ except Exception: # noqa: BLE001
1356
+ await self._reply(event, "❌ I couldn't read that image. Send /smedia to try again.")
1357
+ return
1358
+ if len(data) > START_MEDIA_MAX_BYTES:
1359
+ await self._reply(event, f"❌ That's {human_size(len(data))}; the biggest I can keep is {human_size(START_MEDIA_MAX_BYTES)}. Send /smedia to try another.")
1360
+ return
1361
+ before = (await self.db.kv_get("start_media") or "", await self.db.kv_get("start_media_data") or "")
1362
+ await self._store_start_media(json.dumps({**meta, "file_id": ""}), base64.b64encode(data).decode("ascii"))
1363
+ text, rows = await self.panel(uid, "home")
1364
+ if not await self._send_start_media(event.chat_id, text, rows): # the preview is the real /start: if it fails, so would /start
1365
+ await self._store_start_media(*before)
1366
+ await self._reply(event, "❌ Telegram wouldn't take that as the start media, so nothing was changed. Send /smedia to try another.")
1367
+ return
1368
+ await self._reply(event, f"✅ Done: /start shows this {'image' if want == 'image' else 'GIF'} from now on.")
1369
+
1370
+ # ------------------------------------------------------------------- speed
1371
+ async def cmd_speed(self, event: Any) -> None:
1372
+ if self._speed_lock.locked():
1373
+ await self._reply(event, "⏳ A speed test is already running. Try again in a minute.")
1374
+ return
1375
+ async with self._speed_lock:
1376
+ busy = self.queue.running > 0
1377
+ status = await self._reply(event, "⚡ Running the speed test… this takes about half a minute.")
1378
+ try:
1379
+ text = speed.format_result(await speed.run_speedtest())
1380
+ if busy:
1381
+ text += "\nℹ️ A rename job was running during the test, so the numbers may be lower than usual."
1382
+ except speed.SpeedError as exc:
1383
+ text = f"❌ I couldn't run the speed test: {esc(exc)}"
1384
+ except Exception: # noqa: BLE001
1385
+ log.exception("speed test failed")
1386
+ text = "❌ The speed test failed unexpectedly."
1387
+ await self.client.edit_message(status, text, parse_mode="html", link_preview=False)
1388
 
1389
  # ----------------------------------------------------------- status/stats
1390
  async def cmd_status(self, event: Any) -> None:
 
1443
  if p is None or p.expires < time.time():
1444
  self.pending.pop(uid, None)
1445
  return
1446
+ if p.kind.startswith("fmt:"):
1447
+ await self._on_format_text(event, p)
1448
+ return
1449
+ if p.kind == "resize:size":
1450
+ await self._on_resize_text(event, p)
1451
+ return
1452
  ok, msg = await apply_setting(self.db, uid, p.kind, event.raw_text)
1453
  if not ok:
1454
  await self._reply(event, msg + "\n\nTry again, or tap Cancel.")
 
1493
  await self._cb_set(event, uid, parts[1], parts[2])
1494
  return
1495
 
1496
+ if head == "fm":
1497
+ await self._cb_format(event, uid, parts)
1498
+ return
1499
+
1500
+ if head == "rn":
1501
+ await self._cb_rename(event, uid, parts)
1502
+ return
1503
+
1504
+ if head == "sq":
1505
+ await self._cb_sequence(event, uid, parts)
1506
+ return
1507
+
1508
+ if head == "rz":
1509
+ await self._cb_resize(event, uid, parts)
1510
+ return
1511
+
1512
+ if head == "sm":
1513
+ await self._cb_smedia(event, uid, parts)
1514
+ return
1515
+
1516
+ if data == "et:stop":
1517
+ if self._mode(uid, "ethumb") is not None:
1518
+ self._end_mode(uid)
1519
+ await self._edit_panel(event, "⏹ Stopped. Send /ethumb again to extract a thumbnail.", [[("✖ Close", "x:close")]])
1520
+ await event.answer()
1521
+ return
1522
+
1523
  if head == "in":
1524
  await self._cb_input(event, uid, parts)
1525
  return
 
1622
  msg_obj = await event.get_message()
1623
  self.pending[uid] = Pending(
1624
  kind=kind, chat_id=event.chat_id, msg_id=event.message_id, back=BACK[kind],
1625
+ is_photo=bool(msg_obj is not None and (msg_obj.photo is not None or getattr(msg_obj, "media", None) is not None)),
1626
+ expires=time.time() + self.PENDING_TTL,
1627
  )
1628
  text, rows = panels.input_prompt(kind, BACK[kind])
1629
  await self._edit_panel(event, text, rows)
1630
  await event.answer()
1631
+
1632
+ # ------------------------------------------------------------ saved formats
1633
+ async def _show_format(self, event: Any, uid: int, fid: int, note: str = "") -> bool:
1634
+ fmt = await self.db.get_format(uid, fid)
1635
+ if fmt is None:
1636
+ return False
1637
+ text, rows = panels.format_view(fmt)
1638
+ await self._edit_panel(event, f"{note}\n\n{text}" if note else text, rows)
1639
+ return True
1640
+
1641
+ async def _cb_format(self, event: Any, uid: int, parts: List[str]) -> None:
1642
+ self.pending.pop(uid, None) # leaving a prompt open by tapping something else abandons it
1643
+ action = parts[1] if len(parts) > 1 else ""
1644
+ fid = int(parts[2]) if len(parts) > 2 and parts[2].isdigit() else 0
1645
+
1646
+ if action == "new":
1647
+ if await self.db.count_formats(uid) >= MAX_FORMATS:
1648
+ await event.answer(f"You already have {MAX_FORMATS} formats. Delete one first.", alert=True)
1649
+ return
1650
+ await self._format_prompt(event, uid, "new", 0)
1651
+ return
1652
+
1653
+ fmt = await self.db.get_format(uid, fid) if fid else None
1654
+ if fmt is None:
1655
+ text, rows = await self.panel(uid, "formats")
1656
+ await self._edit_panel(event, text, rows)
1657
+ await event.answer("That format no longer exists.", alert=True)
1658
+ return
1659
+
1660
+ if action in ("v", "cx"):
1661
+ await self._show_format(event, uid, fid)
1662
+ await event.answer()
1663
+ elif action == "in" and len(parts) == 4 and parts[3] in (*FIELD_KINDS, "name"):
1664
+ await self._format_prompt(event, uid, parts[3], fid)
1665
+ elif action == "th":
1666
+ await self._format_prompt(event, uid, "thumb", fid)
1667
+ elif action == "thd":
1668
+ await self.db.set_format_thumb(uid, fid, None)
1669
+ await self._show_format(event, uid, fid)
1670
+ await event.answer("Thumbnail deleted.")
1671
+ elif action == "del":
1672
+ text, rows = panels.format_delete_confirm(fmt)
1673
+ await self._edit_panel(event, text, rows)
1674
+ await event.answer()
1675
+ elif action == "ok":
1676
+ await self.db.delete_format(uid, fid)
1677
+ text, rows = await self.panel(uid, "formats")
1678
+ await self._edit_panel(event, text, rows)
1679
+ await event.answer(f"Deleted {clip(fmt['name'], 40)}.")
1680
+ else:
1681
+ await event.answer()
1682
+
1683
+ async def _format_prompt(self, event: Any, uid: int, field: str, fid: int) -> None:
1684
+ msg_obj = await event.get_message()
1685
+ self.pending[uid] = Pending(
1686
+ kind=f"fmt:{field}", chat_id=event.chat_id, msg_id=event.message_id, back="formats",
1687
+ is_photo=bool(msg_obj is not None and (msg_obj.photo is not None or getattr(msg_obj, "media", None) is not None)),
1688
+ expires=time.time() + self.PENDING_TTL, fid=fid,
1689
+ )
1690
+ text, rows = panels.format_prompt(field, fid)
1691
+ await self._edit_panel(event, text, rows)
1692
+ await event.answer()
1693
+
1694
+ async def _on_format_text(self, event: Any, p: Pending) -> None:
1695
+ uid = event.sender_id
1696
+ field = p.kind[4:]
1697
+ text = event.raw_text or ""
1698
+ if field == "thumb":
1699
+ await self._reply(event, "Send a photo for the thumbnail, or tap Cancel.")
1700
+ return
1701
+ try:
1702
+ if field in ("new", "name"):
1703
+ ok, value = validate_name(text)
1704
+ if ok and field == "new":
1705
+ fmt = await self.db.create_format(uid, value, template=(await self.db.get_settings(uid))["template"])
1706
+ msg = f"✅ Format <b>{esc(value)}</b> created. Now set its template, thumbnail and metadata."
1707
+ p.fid = fmt["id"]
1708
+ elif ok:
1709
+ if await self.db.update_format(uid, p.fid, name=value) is None:
1710
+ raise LookupError
1711
+ msg = f"✅ Renamed to <b>{esc(value)}</b>."
1712
+ else:
1713
+ msg = value
1714
+ else:
1715
+ ok, msg = await apply_setting(FormatSettings(self.db, p.fid), uid, FIELD_KINDS[field], text)
1716
+ except FormatError as exc:
1717
+ ok = False
1718
+ if exc.code == "limit":
1719
+ self.pending.pop(uid, None)
1720
+ await self._reply(event, f"❌ You already have {MAX_FORMATS} formats. Delete one first.")
1721
+ return
1722
+ msg = "❌ You already have a format with that name."
1723
+ except LookupError:
1724
+ self.pending.pop(uid, None)
1725
+ await self._reply(event, "⚠️ That format no longer exists.")
1726
+ return
1727
+ if not ok:
1728
+ await self._reply(event, msg + "\n\nTry again, or tap Cancel.")
1729
+ return
1730
+ self.pending.pop(uid, None)
1731
+ try:
1732
+ await event.delete()
1733
+ except Exception: # noqa: BLE001
1734
+ pass
1735
+ await self._refresh_format_panel(event, p, msg)
1736
+
1737
+ async def _format_thumb_saved(self, event: Any, p: Pending, thumb: bytes) -> None:
1738
+ uid = event.sender_id
1739
+ self.pending.pop(uid, None)
1740
+ if not await self.db.set_format_thumb(uid, p.fid, thumb):
1741
+ await self._reply(event, "⚠️ That format no longer exists.")
1742
+ return
1743
+ await self._refresh_format_panel(event, p, "✅ Thumbnail saved for this format.")
1744
+
1745
+ async def _refresh_format_panel(self, event: Any, p: Pending, msg: str) -> None:
1746
+ """Put the format's panel back into the message the prompt was shown in (a new message if that fails)."""
1747
+ fmt = await self.db.get_format(event.sender_id, p.fid)
1748
+ if fmt is None:
1749
+ await self._reply(event, msg)
1750
+ return
1751
+ text, rows = panels.format_view(fmt)
1752
+ limit = 1000 if p.is_photo else 3900
1753
+ full = f"{msg}\n\n{text}" if visible_len(msg) + visible_len(text) < limit else f"{msg}\n\n(Open the format to see the update.)"
1754
+ try:
1755
+ await self.client.edit_message(p.chat_id, p.msg_id, full, buttons=panels.to_buttons(rows), parse_mode="html")
1756
+ except Exception: # noqa: BLE001
1757
+ await self._reply(event, full, rows)
1758
+
1759
+ # ---------------------------------------------------------- /rename picker
1760
+ async def _cb_rename(self, event: Any, uid: int, parts: List[str]) -> None:
1761
+ self.pending.pop(uid, None)
1762
+ action = parts[1] if len(parts) > 1 else ""
1763
+ if action == "p" and len(parts) == 3 and parts[2].isdigit():
1764
+ fmt = await self.db.get_format(uid, int(parts[2]))
1765
+ if fmt is None:
1766
+ text, rows = panels.rename_picker(await self.db.list_formats(uid))
1767
+ await self._edit_panel(event, text, rows)
1768
+ await event.answer("That format no longer exists.", alert=True)
1769
+ return
1770
+ mode = self._start_mode(uid, "rename", ONE_FILE_WAIT, {"fid": fmt["id"]})
1771
+ text, rows = panels.rename_ready(fmt, ONE_FILE_WAIT // 60)
1772
+ await self._edit_panel(event, text + (f"\n\n{mode.dropped}" if mode.dropped else ""), rows)
1773
+ await event.answer(f"Format: {clip(fmt['name'], 40)}")
1774
+ elif action == "menu":
1775
+ if self._mode(uid, "rename") is not None:
1776
+ self._end_mode(uid)
1777
+ text, rows = panels.rename_picker(await self.db.list_formats(uid))
1778
+ await self._edit_panel(event, text, rows)
1779
+ await event.answer()
1780
+ elif action == "stop":
1781
+ if self._mode(uid, "rename") is not None:
1782
+ self._end_mode(uid)
1783
+ await self._edit_panel(event, "⏹ Stopped. Send /rename again to pick a format.", [[("✖ Close", "x:close")]])
1784
+ await event.answer()
1785
+ else:
1786
+ await event.answer()
1787
+
1788
+ # ------------------------------------------------------------ /sequence buttons
1789
+ async def _cb_sequence(self, event: Any, uid: int, parts: List[str]) -> None:
1790
+ action = parts[1] if len(parts) > 1 else ""
1791
+ mode = self._mode(uid, "sequence")
1792
+ if mode is None:
1793
+ await event.answer("That sequence is over.", alert=True)
1794
+ return
1795
+ if action == "sort":
1796
+ await event.answer()
1797
+ await self._run_sort(uid, event.chat_id)
1798
+ elif action == "cx":
1799
+ self._end_mode(uid)
1800
+ await event.answer("Cancelled.")
1801
+ await self._edit_panel(event, self._mode_cancelled_text(mode), [])
1802
+ else:
1803
+ await event.answer()
bot/config.py CHANGED
@@ -50,7 +50,6 @@ class Config:
50
  database_url: str
51
  session_string: str
52
  log_channel: Optional[int]
53
- start_image: Optional[str]
54
  port: int
55
  temp_dir: str
56
  max_jobs: int
@@ -91,7 +90,6 @@ def load_config(env: Optional[Mapping[str, str]] = None) -> Config:
91
  database_url=env.get("DATABASE_URL", "").strip() or "sqlite+aiosqlite:///bot.db",
92
  session_string=env.get("SESSION_STRING", "").strip(),
93
  log_channel=log_channel,
94
- start_image=env.get("START_IMAGE", "").strip() or None,
95
  port=_get_int(env, "PORT", 7860),
96
  temp_dir=env.get("TEMP_DIR", "").strip() or "/tmp/rename",
97
  max_jobs=max_jobs,
 
50
  database_url: str
51
  session_string: str
52
  log_channel: Optional[int]
 
53
  port: int
54
  temp_dir: str
55
  max_jobs: int
 
90
  database_url=env.get("DATABASE_URL", "").strip() or "sqlite+aiosqlite:///bot.db",
91
  session_string=env.get("SESSION_STRING", "").strip(),
92
  log_channel=log_channel,
 
93
  port=_get_int(env, "PORT", 7860),
94
  temp_dir=env.get("TEMP_DIR", "").strip() or "/tmp/rename",
95
  max_jobs=max_jobs,
bot/db.py CHANGED
@@ -18,6 +18,7 @@ from sqlalchemy.dialects.sqlite import insert as sqlite_insert
18
  from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine
19
  from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
20
 
 
21
  from .parser import DEFAULT_TEMPLATE
22
 
23
  DEFAULT_SETTINGS: Dict[str, Any] = {
@@ -34,6 +35,16 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
34
  }
35
 
36
 
 
 
 
 
 
 
 
 
 
 
37
  def _utcnow() -> datetime:
38
  return datetime.now(timezone.utc)
39
 
@@ -61,6 +72,17 @@ class Admin(Base):
61
  added_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
62
 
63
 
 
 
 
 
 
 
 
 
 
 
 
64
  class KV(Base):
65
  __tablename__ = "kv"
66
 
@@ -96,6 +118,28 @@ def _merge_settings(stored: Dict[str, Any]) -> Dict[str, Any]:
96
  return merged
97
 
98
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
  class Database:
100
  def __init__(self, url: str):
101
  self.url, connect_args = normalize_database_url(url)
@@ -191,6 +235,93 @@ class Database:
191
  ).one()
192
  return int(row[0]), int(row[1])
193
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
  # ----- admins ------------------------------------------------------------------
195
  async def list_admins(self) -> List[int]:
196
  async with self._sessions() as session:
 
18
  from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine
19
  from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
20
 
21
+ from .errors import FormatError
22
  from .parser import DEFAULT_TEMPLATE
23
 
24
  DEFAULT_SETTINGS: Dict[str, Any] = {
 
35
  }
36
 
37
 
38
+ MAX_FORMATS = 12
39
+ # what a saved /rename format holds; everything else (mode, caption, file type, ...) comes from the user's settings
40
+ DEFAULT_FORMAT: Dict[str, Any] = {
41
+ "template": DEFAULT_TEMPLATE,
42
+ "meta": {"title": "", "author": "", "artist": "", "audio": "", "subtitle": "", "video": ""},
43
+ "keep_audio": [],
44
+ "keep_subtitle": [],
45
+ }
46
+
47
+
48
  def _utcnow() -> datetime:
49
  return datetime.now(timezone.utc)
50
 
 
72
  added_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
73
 
74
 
75
+ class Format(Base):
76
+ __tablename__ = "formats"
77
+
78
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
79
+ user_id: Mapped[int] = mapped_column(BigInteger, index=True)
80
+ name: Mapped[str] = mapped_column(String(64))
81
+ data: Mapped[str] = mapped_column(Text, default="{}")
82
+ thumb: Mapped[Optional[bytes]] = mapped_column(LargeBinary, nullable=True)
83
+ created: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
84
+
85
+
86
  class KV(Base):
87
  __tablename__ = "kv"
88
 
 
118
  return merged
119
 
120
 
121
+ def _merge_format(stored: Dict[str, Any]) -> Dict[str, Any]:
122
+ merged = copy.deepcopy(DEFAULT_FORMAT)
123
+ if isinstance(stored.get("template"), str) and stored["template"]:
124
+ merged["template"] = stored["template"]
125
+ if isinstance(stored.get("meta"), dict):
126
+ merged["meta"].update({k: str(v) for k, v in stored["meta"].items() if k in merged["meta"]})
127
+ for key in ("keep_audio", "keep_subtitle"):
128
+ if isinstance(stored.get(key), list):
129
+ merged[key] = [str(t) for t in stored[key]]
130
+ return merged
131
+
132
+
133
+ def _format_dict(fid: int, name: str, raw: Optional[str], has_thumb: bool) -> Dict[str, Any]:
134
+ try:
135
+ stored = json.loads(raw or "{}")
136
+ except ValueError:
137
+ stored = {}
138
+ out = _merge_format(stored if isinstance(stored, dict) else {})
139
+ out.update(id=int(fid), name=name, has_thumb=bool(has_thumb))
140
+ return out
141
+
142
+
143
  class Database:
144
  def __init__(self, url: str):
145
  self.url, connect_args = normalize_database_url(url)
 
235
  ).one()
236
  return int(row[0]), int(row[1])
237
 
238
+ # ----- saved /rename formats ------------------------------------------------------
239
+ async def list_formats(self, uid: int) -> List[Dict[str, Any]]:
240
+ async with self._sessions() as session:
241
+ result = await session.execute(
242
+ select(Format.id, Format.name, Format.data, Format.thumb.isnot(None)).where(Format.user_id == uid).order_by(Format.id)
243
+ )
244
+ return [_format_dict(r[0], r[1], r[2], r[3]) for r in result.all()]
245
+
246
+ async def count_formats(self, uid: int) -> int:
247
+ async with self._sessions() as session:
248
+ return int((await session.execute(select(func.count()).select_from(Format).where(Format.user_id == uid))).scalar() or 0)
249
+
250
+ async def get_format(self, uid: int, fid: int) -> Optional[Dict[str, Any]]:
251
+ async with self._sessions() as session:
252
+ result = await session.execute(
253
+ select(Format.id, Format.name, Format.data, Format.thumb.isnot(None)).where(Format.id == fid, Format.user_id == uid)
254
+ )
255
+ r = result.first()
256
+ return _format_dict(r[0], r[1], r[2], r[3]) if r else None
257
+
258
+ async def create_format(self, uid: int, name: str, template: Optional[str] = None) -> Dict[str, Any]:
259
+ """Add a format; raises ``FormatError`` when the user already has ``MAX_FORMATS`` or the name is taken."""
260
+ async with self._sessions() as session:
261
+ names = list((await session.execute(select(Format.name).where(Format.user_id == uid))).scalars())
262
+ if len(names) >= MAX_FORMATS:
263
+ raise FormatError("limit")
264
+ if name.casefold() in {n.casefold() for n in names}:
265
+ raise FormatError("taken")
266
+ data = copy.deepcopy(DEFAULT_FORMAT)
267
+ if template:
268
+ data["template"] = template
269
+ row = Format(user_id=uid, name=name, data=json.dumps(data, ensure_ascii=False))
270
+ session.add(row)
271
+ await session.flush()
272
+ fid = row.id
273
+ await session.commit()
274
+ return _format_dict(fid, name, json.dumps(data, ensure_ascii=False), False)
275
+
276
+ async def update_format(self, uid: int, fid: int, **changes: Any) -> Optional[Dict[str, Any]]:
277
+ """Change ``name``, ``template``, ``keep_audio``, ``keep_subtitle`` or merge a ``meta`` dict; ``None`` if it is gone."""
278
+ async with self._sessions() as session:
279
+ row = await session.get(Format, fid)
280
+ if row is None or row.user_id != uid:
281
+ return None
282
+ current = _format_dict(row.id, row.name, row.data, False)
283
+ for key, value in changes.items():
284
+ if key == "name":
285
+ others = (
286
+ await session.execute(select(Format.name).where(Format.user_id == uid, Format.id != fid))
287
+ ).scalars()
288
+ if value.casefold() in {n.casefold() for n in others}:
289
+ raise FormatError("taken")
290
+ row.name = value
291
+ elif key == "meta":
292
+ current["meta"].update(value)
293
+ elif key in DEFAULT_FORMAT:
294
+ current[key] = value
295
+ else:
296
+ raise KeyError(key)
297
+ row.data = json.dumps({k: current[k] for k in DEFAULT_FORMAT}, ensure_ascii=False)
298
+ has_thumb = row.thumb is not None
299
+ await session.commit()
300
+ return _format_dict(row.id, row.name, row.data, has_thumb)
301
+
302
+ async def set_format_thumb(self, uid: int, fid: int, data: Optional[bytes]) -> bool:
303
+ async with self._sessions() as session:
304
+ row = await session.get(Format, fid)
305
+ if row is None or row.user_id != uid:
306
+ return False
307
+ row.thumb = data
308
+ await session.commit()
309
+ return True
310
+
311
+ async def get_format_thumb(self, uid: int, fid: int) -> Optional[bytes]:
312
+ async with self._sessions() as session:
313
+ row = await session.get(Format, fid)
314
+ return bytes(row.thumb) if row is not None and row.user_id == uid and row.thumb else None
315
+
316
+ async def delete_format(self, uid: int, fid: int) -> bool:
317
+ async with self._sessions() as session:
318
+ row = await session.get(Format, fid)
319
+ if row is None or row.user_id != uid:
320
+ return False
321
+ await session.delete(row)
322
+ await session.commit()
323
+ return True
324
+
325
  # ----- admins ------------------------------------------------------------------
326
  async def list_admins(self) -> List[int]:
327
  async with self._sessions() as session:
bot/errors.py CHANGED
@@ -4,3 +4,11 @@ class Cancelled(Exception):
4
 
5
  class MediaError(RuntimeError):
6
  """ffprobe / ffmpeg failed."""
 
 
 
 
 
 
 
 
 
4
 
5
  class MediaError(RuntimeError):
6
  """ffprobe / ffmpeg failed."""
7
+
8
+
9
+ class FormatError(Exception):
10
+ """A saved-format operation was refused; ``code`` is ``limit`` (12 formats already) or ``taken`` (name in use)."""
11
+
12
+ def __init__(self, code: str):
13
+ super().__init__(code)
14
+ self.code = code
bot/formats.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
9
+ import copy
10
+ from typing import Any, Dict, Optional, Tuple
11
+
12
+ from .db import DEFAULT_SETTINGS, Database
13
+
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]:
26
+ """``(True, cleaned name)`` or ``(False, html error)``."""
27
+ name = " ".join((text or "").split())
28
+ if not name:
29
+ return False, "❌ Send a name for the format."
30
+ if len(name) > NAME_MAX:
31
+ return False, f"❌ That name is too long ({NAME_MAX} characters max)."
32
+ return True, name
33
+
34
+
35
+ def has_metadata(fmt: Dict[str, Any]) -> bool:
36
+ return any(fmt["meta"].values()) or bool(fmt["keep_audio"] or fmt["keep_subtitle"])
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"])
50
+ out["metadata_on"] = has_metadata(fmt)
51
+ return out
52
+
53
+
54
+ class FormatSettings:
55
+ """Edits one saved format through the two methods ``apply_setting`` uses on ``Database``.
56
+
57
+ That keeps a single copy of every validation rule and message for the global settings and the formats.
58
+ Metadata is always "on" here, so the "metadata is off" notes never show for a format.
59
+ """
60
+
61
+ def __init__(self, db: Database, fid: int):
62
+ self.db = db
63
+ self.fid = fid
64
+
65
+ async def get_settings(self, uid: int) -> Dict[str, Any]:
66
+ fmt = await self.db.get_format(uid, self.fid)
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
+
74
+ async def update_settings(self, uid: int, **changes: Any) -> Dict[str, Any]:
75
+ wanted = {k: v for k, v in changes.items() if k in _EDITABLE}
76
+ if wanted and await self.db.update_format(uid, self.fid, **wanted) is None:
77
+ raise LookupError("that format no longer exists")
78
+ return await self.get_settings(uid)
bot/main.py CHANGED
@@ -15,13 +15,15 @@ from .db import Database
15
 
16
  log = logging.getLogger("bot")
17
 
 
 
18
 
19
  def clean_temp_dir(path: str) -> None:
20
  """Remove leftovers of a crashed run; only touches our own job folders."""
21
  root = Path(path)
22
  root.mkdir(parents=True, exist_ok=True)
23
  for child in root.iterdir():
24
- if child.is_dir() and (child.name.startswith("job") or child.name.startswith("tracks")):
25
  shutil.rmtree(child, ignore_errors=True)
26
 
27
 
@@ -63,6 +65,8 @@ async def amain() -> int:
63
  client = await build_client(cfg, db)
64
  me = await client.get_me()
65
  app = App(client, cfg, db)
 
 
66
  await app.setup()
67
  log.info("@%s is online (DC %s, %d connection(s) per transfer, fast transfer %s)",
68
  me.username, client.session.dc_id, cfg.connections, "on" if cfg.fast_transfer else "off")
 
15
 
16
  log = logging.getLogger("bot")
17
 
18
+ TEMP_PREFIXES = ("job", "tracks", "ethumb", "minfo") # the folders the bot makes in TEMP_DIR
19
+
20
 
21
  def clean_temp_dir(path: str) -> None:
22
  """Remove leftovers of a crashed run; only touches our own job folders."""
23
  root = Path(path)
24
  root.mkdir(parents=True, exist_ok=True)
25
  for child in root.iterdir():
26
+ if child.is_dir() and child.name.startswith(TEMP_PREFIXES):
27
  shutil.rmtree(child, ignore_errors=True)
28
 
29
 
 
65
  client = await build_client(cfg, db)
66
  me = await client.get_me()
67
  app = App(client, cfg, db)
68
+ username = getattr(me, "username", "") or ""
69
+ app.telegraph.set_author(getattr(me, "first_name", "") or username, f"https://t.me/{username}" if username else "")
70
  await app.setup()
71
  log.info("@%s is online (DC %s, %d connection(s) per transfer, fast transfer %s)",
72
  me.username, client.session.dc_id, cfg.connections, "on" if cfg.fast_transfer else "off")
bot/media.py CHANGED
@@ -208,6 +208,22 @@ async def probe(path: Path | str, cancel: Optional[asyncio.Event] = None) -> Med
208
  return parse_probe(data)
209
 
210
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
211
  # ---------------------------------------------------------------------------
212
  # track selection
213
  # ---------------------------------------------------------------------------
@@ -391,6 +407,95 @@ async def make_video_thumbnail(src: Path, dst: Path, duration: float, cancel: Op
391
  return code == 0 and dst.exists() and dst.stat().st_size > 0
392
 
393
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
394
  def prepare_thumb(data: bytes) -> bytes:
395
  """Resize/re-encode an image to Telegram's thumbnail limits (<=320px, <=200 KB JPEG)."""
396
  from PIL import Image
 
208
  return parse_probe(data)
209
 
210
 
211
+ async def probe_raw(path: Path | str, cancel: Optional[asyncio.Event] = None) -> Dict[str, Any]:
212
+ """ffprobe's own JSON (format, streams, chapters) for /mediainfo; an empty dict when it found nothing."""
213
+ code, out, err = await _run(
214
+ ["ffprobe", "-v", "error", "-print_format", "json", "-show_format", "-show_streams", "-show_chapters", str(path)], cancel
215
+ )
216
+ try:
217
+ data = json.loads(out.decode("utf-8", "replace") or "{}")
218
+ except ValueError:
219
+ data = {}
220
+ if not isinstance(data, dict):
221
+ data = {}
222
+ if not data.get("streams") and not data.get("format") and code != 0:
223
+ raise MediaError("ffprobe failed: " + err.decode("utf-8", "replace").strip()[-300:])
224
+ return data
225
+
226
+
227
  # ---------------------------------------------------------------------------
228
  # track selection
229
  # ---------------------------------------------------------------------------
 
407
  return code == 0 and dst.exists() and dst.stat().st_size > 0
408
 
409
 
410
+ def frame_seeks(duration: float) -> List[float]:
411
+ """Positions (seconds) to try for a picture, best first: ~10% in, then earlier ones for a file that is cut off."""
412
+ first = 1.0 if duration <= 0 else min(max(duration * 0.1, 0.0), 60.0)
413
+ return [round(first, 2)] + [seek for seek in (20.0, 5.0, 0.0) if seek < first]
414
+
415
+
416
+ async def extract_frame(
417
+ src: Path, dst: Path, seeks: Sequence[float], width: int = 1280, cancel: Optional[asyncio.Event] = None
418
+ ) -> bool:
419
+ """Save one frame of ``src`` as a JPEG (at most ``width`` px wide), trying each seek position until one gives a picture."""
420
+ for seek in seeks:
421
+ dst.unlink(missing_ok=True)
422
+ cmd = [
423
+ "ffmpeg", "-hide_banner", "-nostdin", "-y", "-loglevel", "error", "-ss", f"{seek:.2f}", "-i", str(src),
424
+ "-frames:v", "1", "-vf", f"scale='min({width},iw)':-2", "-q:v", "3", str(dst),
425
+ ]
426
+ try:
427
+ code, _o, _e = await _run(cmd, cancel)
428
+ except MediaError:
429
+ return False
430
+ if code == 0 and dst.exists() and dst.stat().st_size > 0:
431
+ return True
432
+ return False
433
+
434
+
435
+ SIZE_MIN, SIZE_MAX = 16, 4096
436
+ THUMB_BOX = 320 # Telegram shows custom thumbnails up to 320 px, as a JPEG under 200 KB
437
+
438
+
439
+ _SIZE_RE = re.compile(r"\s*(\d{1,5})(?:(?:\s*[x×*,:]\s*|\s+)(\d{1,5}))?\s*", re.I)
440
+
441
+
442
+ def parse_size(text: str) -> Optional[Tuple[int, int]]:
443
+ """``320`` -> (320, 320); ``320x180`` / ``320×180`` / ``320 180`` -> (320, 180); None when it isn't a usable size."""
444
+ m = _SIZE_RE.fullmatch(text or "")
445
+ if not m:
446
+ return None
447
+ width = int(m.group(1))
448
+ height = int(m.group(2)) if m.group(2) else width
449
+ if not (SIZE_MIN <= width <= SIZE_MAX and SIZE_MIN <= height <= SIZE_MAX):
450
+ return None
451
+ return width, height
452
+
453
+
454
+ def _flatten(img: Any) -> Any:
455
+ """``img`` as RGB; transparent parts become white."""
456
+ from PIL import Image
457
+
458
+ if img.mode in ("RGBA", "LA") or (img.mode == "P" and "transparency" in img.info):
459
+ rgba = img.convert("RGBA")
460
+ flat = Image.new("RGB", rgba.size, (255, 255, 255))
461
+ flat.paste(rgba, mask=rgba.getchannel("A"))
462
+ return flat
463
+ return img.convert("RGB")
464
+
465
+
466
+ def fit_photo(data: bytes, box: int = 1280) -> bytes:
467
+ """A picture as a JPEG that fits ``box`` x ``box`` (a smaller one is not enlarged): what Telegram takes as a photo."""
468
+ from PIL import Image, ImageOps
469
+
470
+ with Image.open(io.BytesIO(data)) as img:
471
+ img = _flatten(ImageOps.exif_transpose(img))
472
+ img.thumbnail((box, box), Image.LANCZOS)
473
+ buf = io.BytesIO()
474
+ img.save(buf, "JPEG", quality=90, optimize=True)
475
+ return buf.getvalue()
476
+
477
+
478
+ def resize_image(data: bytes, width: int, height: int) -> Tuple[bytes, Tuple[int, int], Tuple[int, int]]:
479
+ """Scale an image to fit inside ``width`` x ``height``, keeping its shape.
480
+
481
+ Returns ``(jpeg bytes, original size, new size)``. A result that fits Telegram's thumbnail box stays under 200 KB.
482
+ """
483
+ from PIL import Image, ImageOps
484
+
485
+ with Image.open(io.BytesIO(data)) as img:
486
+ img = ImageOps.exif_transpose(img)
487
+ original = img.size
488
+ img = _flatten(img)
489
+ out = ImageOps.contain(img, (width, height), Image.LANCZOS)
490
+ limit = 200 * 1024 if max(out.size) <= THUMB_BOX else None
491
+ for quality in (92, 85, 78, 70, 60, 50, 40):
492
+ buf = io.BytesIO()
493
+ out.save(buf, "JPEG", quality=quality, optimize=True)
494
+ if limit is None or buf.tell() <= limit:
495
+ break
496
+ return buf.getvalue(), original, out.size
497
+
498
+
499
  def prepare_thumb(data: bytes) -> bytes:
500
  """Resize/re-encode an image to Telegram's thumbnail limits (<=320px, <=200 KB JPEG)."""
501
  from PIL import Image
bot/mediainfo.py ADDED
@@ -0,0 +1,491 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """/mediainfo: everything we can tell about a file, laid out like MediaInfo and ready for a Telegraph page.
2
+
3
+ MediaInfo (the ``pymediainfo`` package) is used when it is available, because it reports the most. Without it the
4
+ report is built from ffprobe, which is always there because the renamer needs it.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import logging
10
+ import re
11
+ import textwrap
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import Any, Dict, List, Optional, Tuple
15
+
16
+ from . import media
17
+ from .errors import MediaError
18
+ from .telegraph import CONTENT_LIMIT, Node, content_size
19
+ from .utils import clip
20
+
21
+ log = logging.getLogger(__name__)
22
+
23
+ WRAP = 64 # columns of the monospace blocks on the page (Telegraph does not wrap them)
24
+ LABEL_ALIGN = 26 # labels longer than this are not used to line up the values
25
+ MIN_VALUE = 40 # columns a wrapped value always gets, even next to a long label
26
+ STREAM_KINDS = {"Video", "Audio", "Text", "Image", "Other"}
27
+ # lines MediaInfo adds when it is given only the start of a file; they describe our download, not the file
28
+ PARTIAL_ARTIFACTS = {"CRC_Error_Pos", "IsTruncated"}
29
+ _CONTROL_RE = re.compile(r"[\x00-\x08\x0b-\x1f\x7f]")
30
+
31
+
32
+ @dataclass
33
+ class Section:
34
+ title: str
35
+ lines: List[Tuple[str, str]] = field(default_factory=list) # (label, value); an empty label is a plain line
36
+
37
+ @property
38
+ def kind(self) -> str:
39
+ return self.title.split(" #")[0]
40
+
41
+
42
+ @dataclass
43
+ class Report:
44
+ sections: List[Section]
45
+ engine: str # "MediaInfo", "ffprobe" or "" when nothing could read the file
46
+ truncated: bool = False # only the start of the file was read
47
+ notes: List[str] = field(default_factory=list) # shown under the sections
48
+
49
+ @property
50
+ def has_streams(self) -> bool:
51
+ return any(s.kind in STREAM_KINDS for s in self.sections)
52
+
53
+ def section(self, title: str) -> Optional[Section]:
54
+ return next((s for s in self.sections if s.title == title), None)
55
+
56
+ def summary(self) -> str:
57
+ """``Matroska · 24 min 1 s · 1 video · 2 audio · 3 subtitle``"""
58
+ parts: List[str] = []
59
+ general = self.section("General")
60
+ for label in ("Format", "Duration"):
61
+ value = next((v for lab, v in general.lines if lab == label), "") if general is not None else ""
62
+ if value:
63
+ parts.append(value)
64
+ for kind, word in (("Video", "video"), ("Audio", "audio"), ("Text", "subtitle")):
65
+ count = sum(1 for s in self.sections if s.kind == kind)
66
+ if count:
67
+ parts.append(f"{count} {word}")
68
+ return " · ".join(parts)
69
+
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # MediaInfo's number formats
73
+ # ---------------------------------------------------------------------------
74
+
75
+
76
+ def mi_size(num: float) -> str:
77
+ """``71.0 KiB``, ``1.46 GiB``: three significant digits, binary units."""
78
+ value = float(num)
79
+ unit = "Bytes"
80
+ for unit in ("Bytes", "KiB", "MiB", "GiB", "TiB"):
81
+ if value < 1023.5 or unit == "TiB":
82
+ break
83
+ value /= 1024
84
+ if unit == "Bytes":
85
+ return f"{int(round(value))} Bytes"
86
+ digits = 0 if value >= 99.95 else 1 if value >= 9.995 else 2
87
+ return f"{value:.{digits}f} {unit}"
88
+
89
+
90
+ def mi_rate(bits_per_second: float) -> str:
91
+ """``62 b/s``, ``370 kb/s`` below 10 Mb/s, ``12.3 Mb/s`` above."""
92
+ if bits_per_second < 1000:
93
+ return f"{int(round(bits_per_second))} b/s"
94
+ kbps = bits_per_second / 1000
95
+ if kbps < 10000:
96
+ return f"{kbps:,.0f}".replace(",", " ") + " kb/s"
97
+ mbps = kbps / 1000
98
+ return f"{mbps:.0f} Mb/s" if mbps >= 100 else f"{mbps:.1f} Mb/s"
99
+
100
+
101
+ def mi_duration(milliseconds: float) -> str:
102
+ """``1 h 24 min``, ``24 min 1 s``, ``2 s 23 ms``."""
103
+ ms = int(round(milliseconds))
104
+ hours, rest = divmod(ms, 3_600_000)
105
+ minutes, rest = divmod(rest, 60_000)
106
+ seconds, millis = divmod(rest, 1000)
107
+ if hours:
108
+ return f"{hours} h {minutes} min"
109
+ if minutes:
110
+ return f"{minutes} min {seconds} s"
111
+ return f"{seconds} s {millis} ms"
112
+
113
+
114
+ def clean(value: Any) -> str:
115
+ return _CONTROL_RE.sub("", str(value)).strip()
116
+
117
+
118
+ # ---------------------------------------------------------------------------
119
+ # reading MediaInfo's text
120
+ # ---------------------------------------------------------------------------
121
+
122
+
123
+ def parse_text(text: str) -> List[Section]:
124
+ """Split MediaInfo's text output into sections of ``label : value`` lines (nested lines keep their indent)."""
125
+ sections: List[Section] = []
126
+ current: Optional[Section] = None
127
+ for raw in text.splitlines():
128
+ line = _CONTROL_RE.sub("", raw).rstrip()
129
+ if not line.strip():
130
+ current = None
131
+ continue
132
+ if current is None:
133
+ current = Section(title=line.strip())
134
+ sections.append(current)
135
+ continue
136
+ label, sep, value = line.partition(" : ")
137
+ if sep:
138
+ current.lines.append((label.rstrip(), value.strip()))
139
+ elif line.endswith(" :"):
140
+ current.lines.append((line[:-2].rstrip(), ""))
141
+ else:
142
+ current.lines.append(("", line.strip()))
143
+ return sections
144
+
145
+
146
+ def _set(section: Section, label: str, value: str, *, first: bool = False) -> None:
147
+ for i, (existing, _old) in enumerate(section.lines):
148
+ if existing == label:
149
+ section.lines[i] = (label, value)
150
+ return
151
+ section.lines.insert(0 if first else len(section.lines), (label, value))
152
+
153
+
154
+ def _drop(section: Section, label: str) -> None:
155
+ section.lines = [(lab, val) for lab, val in section.lines if lab != label]
156
+
157
+
158
+ def _drop_partial_artifacts(section: Section) -> None:
159
+ """Remove the conformance errors and the like that MediaInfo reports about a file that was cut short on purpose."""
160
+ kept: List[Tuple[str, str]] = []
161
+ skipping = False
162
+ for label, value in section.lines:
163
+ if skipping and label[:1].isspace(): # a line nested under the one being dropped
164
+ continue
165
+ skipping = False
166
+ bare = label.strip()
167
+ if bare.startswith("Conformance"):
168
+ skipping = True
169
+ continue
170
+ if bare in PARTIAL_ARTIFACTS:
171
+ continue
172
+ kept.append((label, value))
173
+ section.lines = kept
174
+
175
+
176
+ def fix_general(report: Report, name: str, true_size: int, duration_ms: Optional[float]) -> None:
177
+ """Show the file's own name instead of the temporary path, and its real size when only the start was read."""
178
+ general = report.section("General")
179
+ if general is None:
180
+ general = Section("General")
181
+ report.sections.insert(0, general)
182
+ _set(general, "Complete name", name, first=True)
183
+ if true_size:
184
+ _set(general, "File size", mi_size(true_size))
185
+ if report.truncated:
186
+ _drop_partial_artifacts(general)
187
+ if true_size and duration_ms:
188
+ _set(general, "Overall bit rate", mi_rate(true_size * 8 / (duration_ms / 1000)))
189
+ else:
190
+ _drop(general, "Overall bit rate") # it was worked out from the part we have, so it would be wrong
191
+
192
+
193
+ def telegram_section(name: str, size: int, *, mime: str = "", sent_as: str = "", sent_on: str = "", dc: str = "", caption: str = "") -> Section:
194
+ """What Telegram itself says about the file."""
195
+ lines = [("File name", name)]
196
+ if size:
197
+ lines.append(("File size", f"{mi_size(size)} ({size:,} bytes)".replace(",", " ")))
198
+ for label, value in (("MIME type", mime), ("Sent as", sent_as), ("Sent on", sent_on), ("Data center", dc), ("Caption", caption)):
199
+ if value:
200
+ lines.append((label, value))
201
+ return Section("Telegram", lines)
202
+
203
+
204
+ # ---------------------------------------------------------------------------
205
+ # the ffprobe fallback
206
+ # ---------------------------------------------------------------------------
207
+
208
+ _TAG_LABELS = {
209
+ "title": "Title", "encoder": "Writing application", "creation_time": "Encoded date", "artist": "Performer",
210
+ "album": "Album", "date": "Recorded date", "comment": "Comment", "genre": "Genre", "copyright": "Copyright",
211
+ }
212
+
213
+
214
+ def _language(code: str) -> str:
215
+ name = media.canonical_lang(code)
216
+ return name.title() if name else code.strip()
217
+
218
+
219
+ def _fraction(text: Any) -> float:
220
+ try:
221
+ num, _sep, den = str(text).partition("/")
222
+ return float(num) / float(den) if den else float(num)
223
+ except (ValueError, ZeroDivisionError):
224
+ return 0.0
225
+
226
+
227
+ def _seconds(*candidates: Any) -> float:
228
+ """The first usable duration among ``candidates``: plain seconds or ``HH:MM:SS.fff``."""
229
+ for cand in candidates:
230
+ if not cand:
231
+ continue
232
+ text = str(cand)
233
+ try:
234
+ value = media._parse_hms(text) if ":" in text else float(text)
235
+ except ValueError:
236
+ continue
237
+ if value > 0:
238
+ return value
239
+ return 0.0
240
+
241
+
242
+ def _add(section: Section, label: str, value: Any) -> None:
243
+ text = clean(value) if value not in (None, "") else ""
244
+ if text:
245
+ section.lines.append((label, text))
246
+
247
+
248
+ def _flag(value: Any) -> str:
249
+ return "Yes" if int(value or 0) else "No"
250
+
251
+
252
+ def _level(stream: Dict[str, Any]) -> str:
253
+ try:
254
+ level = int(stream.get("level"))
255
+ except (TypeError, ValueError):
256
+ return ""
257
+ if level <= 0:
258
+ return ""
259
+ codec = stream.get("codec_name")
260
+ if codec == "h264":
261
+ return f"L{level / 10:g}"
262
+ if codec == "hevc":
263
+ return f"L{level / 30:g}"
264
+ return ""
265
+
266
+
267
+ def _stream_section(stream: Dict[str, Any], title: str) -> Section:
268
+ sec = Section(title)
269
+ kind = stream.get("codec_type")
270
+ tags = {k.lower(): v for k, v in (stream.get("tags") or {}).items()}
271
+ disp = stream.get("disposition") or {}
272
+ _add(sec, "ID", int(stream.get("index", 0)) + 1)
273
+ _add(sec, "Format", stream.get("codec_long_name") or stream.get("codec_name"))
274
+ if kind == "video":
275
+ _add(sec, "Format profile", "@".join(p for p in (stream.get("profile"), _level(stream)) if p))
276
+ else:
277
+ _add(sec, "Format profile", stream.get("profile"))
278
+ tag = stream.get("codec_tag_string")
279
+ if tag and not tag.startswith("[0]"):
280
+ _add(sec, "Codec ID", tag)
281
+ seconds = _seconds(stream.get("duration"), tags.get("duration"))
282
+ if seconds:
283
+ _add(sec, "Duration", mi_duration(seconds * 1000))
284
+ try:
285
+ rate = float(stream.get("bit_rate") or tags.get("bps") or tags.get("bps-eng") or 0)
286
+ except ValueError:
287
+ rate = 0.0
288
+ if rate:
289
+ _add(sec, "Bit rate", mi_rate(rate))
290
+ if kind == "video":
291
+ if stream.get("width") and stream.get("height"):
292
+ _add(sec, "Width", f"{stream['width']} pixels")
293
+ _add(sec, "Height", f"{stream['height']} pixels")
294
+ _add(sec, "Display aspect ratio", stream.get("display_aspect_ratio"))
295
+ fps = _fraction(stream.get("avg_frame_rate") or "0") or _fraction(stream.get("r_frame_rate") or "0")
296
+ if fps:
297
+ _add(sec, "Frame rate", f"{fps:.3f} FPS")
298
+ _add(sec, "Pixel format", stream.get("pix_fmt"))
299
+ if stream.get("bits_per_raw_sample"):
300
+ _add(sec, "Bit depth", f"{stream['bits_per_raw_sample']} bits")
301
+ for label, key in (("Color space", "color_space"), ("Color range", "color_range"), ("Transfer characteristics", "color_transfer"),
302
+ ("Color primaries", "color_primaries"), ("Scan type", "field_order")):
303
+ _add(sec, label, stream.get(key))
304
+ elif kind == "audio":
305
+ channels = stream.get("channels")
306
+ if channels:
307
+ _add(sec, "Channel(s)", f"{channels} channel" + ("s" if channels != 1 else ""))
308
+ _add(sec, "Channel layout", stream.get("channel_layout"))
309
+ try:
310
+ if stream.get("sample_rate"):
311
+ _add(sec, "Sampling rate", f"{float(stream['sample_rate']) / 1000:g} kHz")
312
+ except ValueError:
313
+ pass
314
+ if stream.get("bits_per_raw_sample"):
315
+ _add(sec, "Bit depth", f"{stream['bits_per_raw_sample']} bits")
316
+ _add(sec, "Title", tags.get("title"))
317
+ if tags.get("language") not in (None, "", "und"):
318
+ _add(sec, "Language", _language(str(tags["language"])))
319
+ sec.lines.append(("Default", _flag(disp.get("default"))))
320
+ sec.lines.append(("Forced", _flag(disp.get("forced"))))
321
+ return sec
322
+
323
+
324
+ def ffprobe_sections(data: Dict[str, Any], name: str, true_size: int) -> List[Section]:
325
+ """MediaInfo-style sections from ffprobe's JSON (``-show_format -show_streams -show_chapters``)."""
326
+ fmt = data.get("format") or {}
327
+ streams = data.get("streams") or []
328
+ general = Section("General")
329
+ _add(general, "Complete name", name)
330
+ _add(general, "Format", fmt.get("format_long_name") or fmt.get("format_name"))
331
+ if true_size:
332
+ _add(general, "File size", mi_size(true_size))
333
+ seconds = _seconds(fmt.get("duration"))
334
+ if seconds:
335
+ _add(general, "Duration", mi_duration(seconds * 1000))
336
+ if true_size:
337
+ _add(general, "Overall bit rate", mi_rate(true_size * 8 / seconds))
338
+ for key, value in (fmt.get("tags") or {}).items():
339
+ _add(general, _TAG_LABELS.get(key.lower(), key.replace("_", " ").title()), value)
340
+ attached = [
341
+ (s.get("tags") or {}).get("filename") or (s.get("tags") or {}).get("FILENAME")
342
+ for s in streams if s.get("codec_type") == "attachment"
343
+ ]
344
+ _add(general, "Attachments", " / ".join(a for a in attached if a))
345
+ sections = [general]
346
+
347
+ titles = {"video": "Video", "audio": "Audio", "subtitle": "Text"}
348
+ counts = {kind: sum(1 for s in streams if s.get("codec_type") == kind) for kind in titles}
349
+ seen: Dict[str, int] = {}
350
+ for stream in streams:
351
+ kind = stream.get("codec_type")
352
+ if kind not in titles:
353
+ continue
354
+ seen[kind] = seen.get(kind, 0) + 1
355
+ title = titles[kind] if counts[kind] == 1 else f"{titles[kind]} #{seen[kind]}"
356
+ sections.append(_stream_section(stream, title))
357
+
358
+ chapters = data.get("chapters") or []
359
+ if chapters:
360
+ menu = Section("Menu")
361
+ for chapter in chapters:
362
+ millis = int(_seconds(chapter.get("start_time")) * 1000)
363
+ hours, rest = divmod(millis, 3_600_000)
364
+ minutes, rest = divmod(rest, 60_000)
365
+ stamp = f"{hours:02d}:{minutes:02d}:{rest // 1000:02d}.{rest % 1000:03d}"
366
+ menu.lines.append((stamp, clean((chapter.get("tags") or {}).get("title", ""))))
367
+ sections.append(menu)
368
+ return sections
369
+
370
+
371
+ # ---------------------------------------------------------------------------
372
+ # reading a file
373
+ # ---------------------------------------------------------------------------
374
+
375
+
376
+ def _mediainfo(path: Path) -> Optional[Tuple[str, Optional[float]]]:
377
+ """Blocking: MediaInfo's text and the duration (ms) of the file, or None when MediaInfo is not usable."""
378
+ try:
379
+ from pymediainfo import MediaInfo
380
+ except ImportError:
381
+ return None
382
+ if not MediaInfo.can_parse():
383
+ return None
384
+ text = MediaInfo.parse(str(path), output="text", full=False)
385
+ duration: Optional[float] = None
386
+ try:
387
+ first = MediaInfo.parse(str(path)).tracks[0]
388
+ duration = float(first.duration) if first.duration else None
389
+ except (IndexError, TypeError, ValueError, AttributeError):
390
+ duration = None
391
+ return str(text), duration
392
+
393
+
394
+ async def read_file(path: Path, name: str, true_size: int, *, extra: Optional[Section] = None) -> Report:
395
+ """The report for the file at ``path``, which is all of the file or only its start (``true_size`` is the real size).
396
+
397
+ The report may have no streams (``has_streams``): a video whose index is at the end of the file, for one.
398
+ """
399
+ sections: List[Section] = []
400
+ engine = ""
401
+ duration_ms: Optional[float] = None
402
+ try:
403
+ got = await asyncio.to_thread(_mediainfo, path)
404
+ except Exception as exc: # noqa: BLE001 - the library is native code; whatever goes wrong, ffprobe is the fallback
405
+ log.warning("MediaInfo failed on %s: %s", name, exc)
406
+ got = None
407
+ if got is not None:
408
+ sections, duration_ms = parse_text(got[0]), got[1]
409
+ engine = "MediaInfo" if sections else ""
410
+ if not sections:
411
+ try:
412
+ data = await media.probe_raw(path)
413
+ except MediaError:
414
+ data = {}
415
+ if data.get("format") or data.get("streams"):
416
+ sections, engine = ffprobe_sections(data, name, true_size), "ffprobe"
417
+ duration_ms = _seconds((data.get("format") or {}).get("duration")) * 1000 or None
418
+ report = Report(sections=sections, engine=engine, truncated=bool(true_size and path.stat().st_size < true_size))
419
+ fix_general(report, name, true_size, duration_ms)
420
+ if extra is not None:
421
+ report.sections.append(extra)
422
+ return report
423
+
424
+
425
+ # ---------------------------------------------------------------------------
426
+ # the Telegraph page
427
+ # ---------------------------------------------------------------------------
428
+
429
+
430
+ def render_section(section: Section, width: int = WRAP, max_value: int = 0) -> str:
431
+ """The section as aligned ``label : value`` lines, long values wrapped under the first one."""
432
+ label_w = max((len(lab) for lab, _ in section.lines if lab and len(lab) <= LABEL_ALIGN), default=0)
433
+ indent = " " * (label_w + 3)
434
+ room = max(MIN_VALUE, width - len(indent))
435
+ out: List[str] = []
436
+ for label, value in section.lines:
437
+ if max_value:
438
+ value = clip(value, max_value)
439
+ if not label:
440
+ out.extend(textwrap.wrap(value, width=width, break_long_words=True, break_on_hyphens=False) or [""])
441
+ continue
442
+ head = f"{clip(label, 60):<{label_w}} : "
443
+ parts = textwrap.wrap(value, width=room, break_long_words=True, break_on_hyphens=False) or [""]
444
+ out.append(head + parts[0])
445
+ out.extend(indent + part for part in parts[1:])
446
+ return "\n".join(line.rstrip() for line in out)
447
+
448
+
449
+ def render_text(report: Report, **kw: Any) -> str:
450
+ """The whole report as plain text (what is sent as a file when Telegraph cannot be reached)."""
451
+ blocks = [f"{s.title}\n{render_section(s, **kw)}" for s in report.sections]
452
+ blocks.extend(report_notes(report))
453
+ return "\n\n".join(blocks) + "\n"
454
+
455
+
456
+ def report_notes(report: Report) -> List[str]:
457
+ notes = list(report.notes)
458
+ if report.truncated:
459
+ notes.append("Only the start of the file was read; its size and overall bit rate are worked out from the real file size.")
460
+ notes.append(f"Made by the bot with {report.engine}." if report.engine else "Made by the bot.")
461
+ return notes
462
+
463
+
464
+ def _nodes(report: Report, max_value: int, keep: int, notes: List[str]) -> List[Node]:
465
+ nodes: List[Node] = []
466
+ for index, section in enumerate(report.sections):
467
+ if index >= keep and section.title != "Telegram":
468
+ continue
469
+ nodes.append({"tag": "h4", "children": [section.title]})
470
+ nodes.append({"tag": "pre", "children": [render_section(section, max_value=max_value)]})
471
+ for note in notes:
472
+ nodes.append({"tag": "p", "children": [{"tag": "i", "children": [note]}]})
473
+ return nodes
474
+
475
+
476
+ def to_nodes(report: Report, limit: int = CONTENT_LIMIT) -> List[Node]:
477
+ """Telegraph nodes for the report, shortened step by step until the page fits Telegraph's size limit."""
478
+ notes = report_notes(report)
479
+ total = len(report.sections)
480
+ for max_value in (0, 300, 150, 80):
481
+ nodes = _nodes(report, max_value, total, notes)
482
+ if content_size(nodes) <= limit:
483
+ return nodes
484
+ keep = total
485
+ while keep > 1: # still too big (a file with hundreds of tracks): drop the last sections, keeping General and Telegram
486
+ keep -= 1
487
+ left_out = [f"The page size limit was reached, so {total - keep} more section(s) were left out."]
488
+ nodes = _nodes(report, 80, keep, notes + left_out)
489
+ if content_size(nodes) <= limit:
490
+ return nodes
491
+ return _nodes(report, 80, 1, notes)
bot/panels.py CHANGED
@@ -67,7 +67,7 @@ def home(s: Dict[str, Any], *, owner: bool) -> Panel:
67
  rows: List[Row] = [
68
  [("Rename", "m:rename"), ("Caption & Thumbnail", "m:cap")],
69
  [("Metadata", "m:meta"), ("File Type", "m:ftype")],
70
- [("Help", "m:help")] + ([("Admin", "m:admin")] if owner else []),
71
  [("✖ Close", "x:close")],
72
  ]
73
  return text, rows
@@ -183,13 +183,16 @@ def help_panel() -> Panel:
183
  text = (
184
  "<b>How to use</b>\n\n"
185
  "1. Send a file — I rename it with your template and send it back.\n"
186
- "2. <code>/rename New Name</code> — reply to a file for a one-off name.\n"
 
187
  "3. /template and /caption — set the naming and caption templates.\n"
188
  "4. /mode — read details from the file name or the caption.\n"
189
  "5. /metadata, /keepaudio, /keepsubtitle — tags and track clean-up.\n"
190
  "6. /filetype — send as document, video or audio.\n"
191
  "7. Send a photo — it becomes your thumbnail.\n"
192
- "8. /queue and /cancel — manage running jobs.\n\n"
 
 
193
  "Template variables: <code>{title} {season} {episode} {quality} {year} {codec} {filename} {ext}</code>\n"
194
  "A [bracket] group is dropped when a variable inside it is empty."
195
  )
@@ -205,7 +208,7 @@ def admin(owner_id: int, admins: Sequence[int], running: int, waiting: int, log_
205
  f"<b>Allowed users:</b> {shown}{more}\n"
206
  f"<b>Queue:</b> {running} running · {waiting} waiting\n"
207
  f"<b>Log channel:</b> {('<code>%s</code>' % log_channel) if log_channel else 'not set (LOG_CHANNEL)'}\n\n"
208
- "Commands: /addadmin &lt;id&gt; · /deladmin &lt;id&gt; · /admins"
209
  )
210
  rows: List[Row] = [
211
  [("📊 Stats", "adm:stats"), ("🟢 Status", "adm:status")],
@@ -214,6 +217,209 @@ def admin(owner_id: int, admins: Sequence[int], running: int, waiting: int, log_
214
  return text, rows
215
 
216
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
217
  # ---------------------------------------------------------------------------
218
  # input prompts
219
  # ---------------------------------------------------------------------------
@@ -261,5 +467,22 @@ def input_prompt(kind: str, back: str) -> Panel:
261
  return PROMPTS[kind], [[("✖ Cancel", f"in:cancel:{back}")]]
262
 
263
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
264
  def known_languages() -> str:
265
  return ", ".join(sorted(LANGUAGES))
 
67
  rows: List[Row] = [
68
  [("Rename", "m:rename"), ("Caption & Thumbnail", "m:cap")],
69
  [("Metadata", "m:meta"), ("File Type", "m:ftype")],
70
+ [("Formats", "m:formats"), ("Help", "m:help")] + ([("Admin", "m:admin")] if owner else []),
71
  [("✖ Close", "x:close")],
72
  ]
73
  return text, rows
 
183
  text = (
184
  "<b>How to use</b>\n\n"
185
  "1. Send a file — I rename it with your template and send it back.\n"
186
+ "2. /formats — save up to 12 named formats (template, thumbnail, metadata, tracks); "
187
+ "/rename — pick one, then send a file (send /rename again for the next one).\n"
188
  "3. /template and /caption — set the naming and caption templates.\n"
189
  "4. /mode — read details from the file name or the caption.\n"
190
  "5. /metadata, /keepaudio, /keepsubtitle — tags and track clean-up.\n"
191
  "6. /filetype — send as document, video or audio.\n"
192
  "7. Send a photo — it becomes your thumbnail.\n"
193
+ "8. /sequence then /sort — put files in episode order.\n"
194
+ "9. /check, /mediainfo, /ethumb, /resize, /speed — inspect files and images.\n"
195
+ "10. /queue and /cancel — manage running jobs.\n\n"
196
  "Template variables: <code>{title} {season} {episode} {quality} {year} {codec} {filename} {ext}</code>\n"
197
  "A [bracket] group is dropped when a variable inside it is empty."
198
  )
 
208
  f"<b>Allowed users:</b> {shown}{more}\n"
209
  f"<b>Queue:</b> {running} running · {waiting} waiting\n"
210
  f"<b>Log channel:</b> {('<code>%s</code>' % log_channel) if log_channel else 'not set (LOG_CHANNEL)'}\n\n"
211
+ "Commands: /addadmin &lt;id&gt; · /deladmin &lt;id&gt; · /admins · /smedia"
212
  )
213
  rows: List[Row] = [
214
  [("📊 Stats", "adm:stats"), ("🟢 Status", "adm:status")],
 
217
  return text, rows
218
 
219
 
220
+ # ---------------------------------------------------------------------------
221
+ # saved formats
222
+ # ---------------------------------------------------------------------------
223
+
224
+ FORMAT_FIELD_LABELS = (
225
+ ("title", "Title"), ("author", "Author"), ("artist", "Artist"),
226
+ ("audio", "Audio"), ("subtitle", "Subtitle"), ("video", "Video"),
227
+ )
228
+
229
+
230
+ def _format_buttons(formats: Sequence[Dict[str, Any]], prefix: str) -> List[Row]:
231
+ rows: List[Row] = []
232
+ row: Row = []
233
+ for f in formats:
234
+ row.append((clip(f["name"], 24), f"{prefix}:{f['id']}"))
235
+ if len(row) == 2:
236
+ rows.append(row)
237
+ row = []
238
+ if row:
239
+ rows.append(row)
240
+ return rows
241
+
242
+
243
+ def formats_menu(formats: Sequence[Dict[str, Any]], limit: int) -> Panel:
244
+ text = (
245
+ f"<b>🗂 FORMATS</b> ({len(formats)}/{limit})\n\n"
246
+ "A format saves its own template, thumbnail, metadata titles and Keep Audio / Keep Subtitle lists "
247
+ "under a name, like <i>One Piece</i> or <i>Dragon Ball</i>.\n"
248
+ "Tap one to view or edit it, then use /rename to apply it to your files."
249
+ )
250
+ if not formats:
251
+ text += "\n\nYou have no formats yet."
252
+ rows = _format_buttons(formats, "fm:v")
253
+ if len(formats) < limit:
254
+ rows.append([("➕ New format", "fm:new")])
255
+ else:
256
+ text += f"\n\nYou have all {limit} formats. Delete one to add another."
257
+ return text, rows + nav("home")
258
+
259
+
260
+ def format_view(fmt: Dict[str, Any]) -> Panel:
261
+ m = fmt["meta"]
262
+ lines = [
263
+ f"<b>🗂 {esc(clip(fmt['name'], 30))}</b>",
264
+ "",
265
+ f"{DOT} <b>Template</b> ▸ {_code(fmt['template'], 60)}",
266
+ f"{DOT} <b>Thumbnail</b> ▸ " + ("✅ saved" if fmt.get("has_thumb") else "— (your default thumbnail is used)"),
267
+ ]
268
+ for key, label in FORMAT_FIELD_LABELS:
269
+ lines.append(f"{DOT} <b>{label}</b> ▸ {_code(m.get(key, ''), 50)}")
270
+ lines.append(f"{DOT} <b>Keep Audio</b> ▸ {esc(clip(fmt_tokens(fmt['keep_audio']), 50))}")
271
+ lines.append(f"{DOT} <b>Keep Subtitle</b> ▸ {esc(clip(fmt_tokens(fmt['keep_subtitle']), 50))}")
272
+ lines += ["", "Use it with /rename. Mode, caption, file type and the rest come from your normal settings."]
273
+ fid = fmt["id"]
274
+ thumb_row: Row = [("Thumbnail", f"fm:th:{fid}")]
275
+ if fmt.get("has_thumb"):
276
+ thumb_row.append(("🗑 Delete Thumb", f"fm:thd:{fid}"))
277
+ rows: List[Row] = [
278
+ [("Name", f"fm:in:{fid}:name"), ("Template", f"fm:in:{fid}:template")],
279
+ thumb_row,
280
+ [("Title", f"fm:in:{fid}:title"), ("Author", f"fm:in:{fid}:author"), ("Artist", f"fm:in:{fid}:artist")],
281
+ [("Audio", f"fm:in:{fid}:audio"), ("Subtitle", f"fm:in:{fid}:subtitle"), ("Video", f"fm:in:{fid}:video")],
282
+ [("Keep Audio", f"fm:in:{fid}:keep_audio"), ("Keep Subtitle", f"fm:in:{fid}:keep_subtitle")],
283
+ [("🗑 Delete format", f"fm:del:{fid}")],
284
+ ] + nav("formats")
285
+ return "\n".join(lines), rows
286
+
287
+
288
+ def format_delete_confirm(fmt: Dict[str, Any]) -> Panel:
289
+ 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."
290
+ return text, [[("🗑 Yes, delete", f"fm:ok:{fmt['id']}"), ("◀ Keep it", f"fm:v:{fmt['id']}")]]
291
+
292
+
293
+ def rename_picker(formats: Sequence[Dict[str, Any]]) -> Panel:
294
+ if not formats:
295
+ text = (
296
+ "<b>🏷 RENAME</b>\n\nYou have no saved formats yet.\n"
297
+ "A format keeps a template, thumbnail and metadata under a name, like <i>One Piece</i>. Create one, then come back here."
298
+ )
299
+ return text, [[("➕ Create a format", "fm:new")], [("✖ Close", "x:close")]]
300
+ text = "<b>🏷 RENAME</b>\n\nChoose the format to rename with. I'll ask for your files next."
301
+ rows = _format_buttons(formats, "rn:p") + [[("⚙️ Manage formats", "m:formats"), ("✖ Close", "x:close")]]
302
+ return text, rows
303
+
304
+
305
+ def rename_ready(fmt: Dict[str, Any], minutes: int) -> Panel:
306
+ text = (
307
+ f"✅ <b>{esc(clip(fmt['name'], 30))}</b> selected.\n\n"
308
+ "Now send me the file to rename. I'll apply this format to it and send it back.\n"
309
+ "It works for one file at a time: for another file, send /rename again.\n"
310
+ f"I'll wait {minutes} minutes. /cancel to stop."
311
+ )
312
+ return text, [[("🔄 Change format", "rn:menu"), ("⏹ Stop", "rn:stop")]]
313
+
314
+
315
+ def _detail_lines(info: Any) -> str:
316
+ """Title, Season, Episode and Quality always (``None`` when not found); Year and Codec only when found."""
317
+ fields = [("Title", info.title), ("Season", info.season), ("Episode", info.episode), ("Quality", info.quality)]
318
+ fields += [(label, value) for label, value in (("Year", info.year), ("Codec", info.codec)) if value]
319
+ return "\n".join(f"➦ {label} ⇛ <code>{esc(clip(value, 60)) if value else 'None'}</code>" for label, value in fields)
320
+
321
+
322
+ def check_text(mode: str, name_info: Any, caption_info: Any, new_name: str) -> str:
323
+ """What /check shows: the details read from the file name and from the caption, and the name they give."""
324
+ return (
325
+ "<b>Yᴏᴜ Cᴀɴ Cʜᴀɴɢᴇ Mᴏᴅᴇ Bʏ</b> /mode\n\n"
326
+ f"<b>Rᴇɴᴀᴍɪɴɢ Mᴏᴅᴇ:</b> <code>{esc(mode.upper())}</code>\n\n"
327
+ f"<b>Fɪʟᴇɴᴀᴍᴇ:</b>\n{_detail_lines(name_info)}\n\n"
328
+ f"<b>Cᴀᴘᴛɪᴏɴ Mᴏᴅᴇ:</b>\n{_detail_lines(caption_info)}\n\n"
329
+ f"<b>Nᴇᴡ Nᴀᴍᴇ:</b>\n<code>{esc(clip(new_name, 150))}</code>"
330
+ )
331
+
332
+
333
+ def ethumb_prompt(minutes: int) -> Panel:
334
+ text = (
335
+ "<b>🖼 EXTRACT THUMBNAIL</b>\n\n"
336
+ "Send me a file or video and I'll send back its thumbnail.\n"
337
+ "It works for one file at a time: for another file, send /ethumb again.\n"
338
+ f"I'll wait {minutes} minutes. /cancel to stop."
339
+ )
340
+ return text, [[("⏹ Stop", "et:stop")]]
341
+
342
+
343
+ RESIZE_PRESETS = ((320, "Thumbnail · 320 px"), (640, "640 px"), (1280, "1280 px"))
344
+
345
+
346
+ def describe_box(box: Tuple[int, int]) -> str:
347
+ width, height = box
348
+ return f"{width} px" if width == height else f"{width}×{height}"
349
+
350
+
351
+ def resize_menu() -> Panel:
352
+ text = (
353
+ "<b>🖼 RESIZE IMAGE</b>\n\n"
354
+ "Choose the size. The picture keeps its shape and fits inside it, and comes back as a JPEG. "
355
+ "Thumbnail size (320 px) stays under 200 KB, which is what Telegram accepts.\n\n"
356
+ "Or send the size with the command, like <code>/resize 320x180</code>."
357
+ )
358
+ rows: List[Row] = [[(label, f"rz:s:{side}")] for side, label in RESIZE_PRESETS]
359
+ rows.append([("✏️ Custom size", "rz:custom")])
360
+ return text, rows + [[("✖ Close", "x:close")]]
361
+
362
+
363
+ def resize_ready(box: Tuple[int, int], minutes: int) -> Panel:
364
+ text = (
365
+ f"✅ Size: <b>{describe_box(box)}</b>\n\n"
366
+ "Now send me the image (a photo, or an image sent as a file). I'll resize it and send it back.\n"
367
+ "It works for one image at a time: for another, send /resize again.\n"
368
+ f"I'll wait {minutes} minutes. /cancel to stop."
369
+ )
370
+ return text, [[("🔄 Change size", "rz:menu"), ("⏹ Stop", "rz:stop")]]
371
+
372
+
373
+ RESIZE_CUSTOM_PROMPT = (
374
+ "<b>Send the size</b> as width and height, like <code>320x180</code>, or one number like <code>320</code> "
375
+ "for a square box (16 to 4096 pixels). The picture keeps its shape and fits inside the box."
376
+ )
377
+
378
+
379
+ def smedia_menu(current: str) -> Panel:
380
+ """/smedia: ``current`` is what /start shows above the menu now: ``image``, ``gif`` or ``""`` (nothing)."""
381
+ shown = {"image": "an image", "gif": "a GIF"}.get(current, "nothing, just the menu")
382
+ text = (
383
+ "<b>🖼 START MEDIA</b>\n\n"
384
+ f"/start shows: <b>{shown}</b>\n\n"
385
+ "Choose what goes above the menu. I keep a copy of it, so it survives restarts."
386
+ )
387
+ rows: List[Row] = [[("🖼 Image", "sm:image"), ("🎞 GIF", "sm:gif")]]
388
+ if current:
389
+ rows.append([("🗑 Remove", "sm:rm")])
390
+ return text, rows + [[("✖ Close", "x:close")]]
391
+
392
+
393
+ def smedia_prompt(kind: str, minutes: int) -> Panel:
394
+ what = "the image (a photo, or an image sent as a file)" if kind == "image" else "the GIF (a Telegram GIF or animation)"
395
+ text = (
396
+ f"<b>{'🖼' if kind == 'image' else '🎞'} Send {what}.</b>\n\n"
397
+ "I'll set it as the /start media and show you the result.\n"
398
+ f"I'll wait {minutes} minutes. /cancel to stop."
399
+ )
400
+ return text, [[("◀ Back", "sm:menu"), ("⏹ Stop", "sm:stop")]]
401
+
402
+
403
+ def sequence_intro(limit: int) -> Panel:
404
+ text = (
405
+ "<b>📑 SEQUENCE</b>\n\n"
406
+ f"Send me the files to put in order (up to {limit}), in any order. I only collect them for now.\n"
407
+ "When they're all here, send /sort: I'll send them back in episode order, then list the missing and repeated episodes.\n"
408
+ "The episode is read from the file name, or from the caption when your /mode is Caption.\n"
409
+ "/cancel to throw them away."
410
+ )
411
+ return text, [[("✖ Cancel", "sq:cx")]]
412
+
413
+
414
+ def sequence_status(count: int, limit: int) -> Panel:
415
+ text = f"📥 <b>{count} file{'s' if count != 1 else ''} collected</b>"
416
+ if count >= limit:
417
+ text += f"\n\nThat's the most I can sort at once ({limit}). Send /sort now."
418
+ else:
419
+ text += "\n\nSend more files, or /sort when you're done."
420
+ return text, [[("🔢 Sort now", "sq:sort"), ("🗑 Discard", "sq:cx")]]
421
+
422
+
423
  # ---------------------------------------------------------------------------
424
  # input prompts
425
  # ---------------------------------------------------------------------------
 
467
  return PROMPTS[kind], [[("✖ Cancel", f"in:cancel:{back}")]]
468
 
469
 
470
+ FORMAT_PROMPTS: Dict[str, str] = {
471
+ "new": "<b>Send a name for the new format</b>, like <code>One Piece</code> or <code>Dragon Ball</code> (30 characters max).",
472
+ "name": "<b>Send the new name</b> for this format (30 characters max).",
473
+ "thumb": "<b>Send a photo</b> to use as this format's thumbnail.",
474
+ "template": PROMPTS["template"],
475
+ "title": PROMPTS["mt_title"], "author": PROMPTS["mt_author"], "artist": PROMPTS["mt_artist"],
476
+ "audio": PROMPTS["mt_audio"], "subtitle": PROMPTS["mt_subtitle"], "video": PROMPTS["mt_video"],
477
+ "keep_audio": PROMPTS["keep_audio"], "keep_subtitle": PROMPTS["keep_subtitle"],
478
+ }
479
+
480
+
481
+ def format_prompt(field: str, fid: int) -> Panel:
482
+ """The "send me the value" screen for one field of a format (``fid`` 0 = a format that is being created)."""
483
+ cancel = f"fm:cx:{fid}" if fid else "m:formats"
484
+ return FORMAT_PROMPTS[field], [[("✖ Cancel", cancel)]]
485
+
486
+
487
  def known_languages() -> str:
488
  return ", ".join(sorted(LANGUAGES))
bot/parser.py CHANGED
@@ -443,14 +443,6 @@ def compute_new_name(
443
  return finalize_name(out_stem, ext), info
444
 
445
 
446
- def build_manual_name(text: str, original_ext: str) -> str:
447
- """Name typed by the user (``/rename``): the original extension is always kept."""
448
- text = sanitize(text)
449
- stem, typed_ext = split_ext(text, known_only=True)
450
- ext = original_ext or typed_ext
451
- return finalize_name(stem or "file", ext)
452
-
453
-
454
  # ---------------------------------------------------------------------------
455
  # captions
456
  # ---------------------------------------------------------------------------
 
443
  return finalize_name(out_stem, ext), info
444
 
445
 
 
 
 
 
 
 
 
 
446
  # ---------------------------------------------------------------------------
447
  # captions
448
  # ---------------------------------------------------------------------------
bot/pipeline.py CHANGED
@@ -16,6 +16,7 @@ from . import media
16
  from .config import Config
17
  from .db import Database
18
  from .errors import Cancelled, MediaError
 
19
  from .parser import build_values, parse_info, render_caption, split_ext
20
  from .progress import ProgressReporter
21
  from .queue import Job
@@ -95,6 +96,8 @@ class Renamer:
95
  # ------------------------------------------------------------------
96
  async def process(self, job: Job) -> None:
97
  s = await self.db.get_settings(job.user_id)
 
 
98
  work = Path(self.cfg.temp_dir) / f"job{job.id}"
99
  work.mkdir(parents=True, exist_ok=True)
100
  reporter = ProgressReporter(self.client, job.status_msg, job.id, job.new_name, self.cfg.progress_interval)
@@ -178,7 +181,9 @@ class Renamer:
178
  )
179
  )
180
 
181
- thumb = await self.db.get_thumb(job.user_id)
 
 
182
  if thumb is None and ext.lower() in media.VIDEO_CONTAINERS and info is not None and info.of("video"):
183
  thumb_path = work / "thumb.jpg"
184
  if await media.make_video_thumbnail(current, thumb_path, info.duration, job.cancel):
 
16
  from .config import Config
17
  from .db import Database
18
  from .errors import Cancelled, MediaError
19
+ from .formats import apply_format
20
  from .parser import build_values, parse_info, render_caption, split_ext
21
  from .progress import ProgressReporter
22
  from .queue import Job
 
96
  # ------------------------------------------------------------------
97
  async def process(self, job: Job) -> None:
98
  s = await self.db.get_settings(job.user_id)
99
+ if job.fmt:
100
+ s = apply_format(s, job.fmt)
101
  work = Path(self.cfg.temp_dir) / f"job{job.id}"
102
  work.mkdir(parents=True, exist_ok=True)
103
  reporter = ProgressReporter(self.client, job.status_msg, job.id, job.new_name, self.cfg.progress_interval)
 
181
  )
182
  )
183
 
184
+ thumb = await self.db.get_format_thumb(job.user_id, job.fmt["id"]) if job.fmt else None
185
+ if thumb is None:
186
+ thumb = await self.db.get_thumb(job.user_id)
187
  if thumb is None and ext.lower() in media.VIDEO_CONTAINERS and info is not None and info.of("video"):
188
  thumb_path = work / "thumb.jpg"
189
  if await media.make_video_thumbnail(current, thumb_path, info.duration, job.cancel):
bot/queue.py CHANGED
@@ -20,7 +20,7 @@ class Job:
20
  file_name: str
21
  size: int
22
  new_name: str
23
- manual: bool = False
24
  notes: List[str] = field(default_factory=list) # warnings shown with the queued / finished message
25
  order: int = 0 # id of the message that triggered the job: files are processed oldest message first
26
  status_msg: Any = None
 
20
  file_name: str
21
  size: int
22
  new_name: str
23
+ fmt: Optional[Dict[str, Any]] = None # the saved /rename format this job uses (a snapshot taken when it was queued)
24
  notes: List[str] = field(default_factory=list) # warnings shown with the queued / finished message
25
  order: int = 0 # id of the message that triggered the job: files are processed oldest message first
26
  status_msg: Any = None
bot/sequence.py ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """/sequence + /sort: put files in episode order and report which episodes are missing or repeated."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ from dataclasses import dataclass, field
6
+ from typing import Dict, List, Optional, Sequence, Tuple
7
+
8
+ from .parser import parse_caption, parse_info, split_ext
9
+ from .utils import clip, esc
10
+
11
+ MAX_LISTED = 12 # names shown per section of the report
12
+
13
+
14
+ @dataclass
15
+ class SeqItem:
16
+ msg_id: int
17
+ name: str
18
+ caption: str = ""
19
+ season: Optional[int] = None
20
+ episode: Optional[int] = None
21
+
22
+
23
+ @dataclass
24
+ class SequenceState:
25
+ """What /sequence has collected so far for one user."""
26
+
27
+ items: List[SeqItem] = field(default_factory=list)
28
+ status_id: Optional[int] = None # the "N files collected" message, replaced after each burst of files
29
+ task: Optional["asyncio.Future[None]"] = None # the debounced status update that is waiting for the burst to end
30
+ version: int = 0 # bumped for every file, so the status task can tell whether more are still arriving
31
+ closed: bool = False # /sort or /cancel ended the collection
32
+ full_noted: bool = False # the "limit reached" notice was shown
33
+
34
+
35
+ def detect(name: str, caption: str, mode: str) -> Tuple[Optional[int], Optional[int]]:
36
+ """``(season, episode)`` read from the file name and the caption, the one the rename mode names first.
37
+
38
+ The season is 1 when only an episode number is found; both are ``None`` when there is no episode number.
39
+ """
40
+ from_name = lambda: parse_info(split_ext(name)[0]) # noqa: E731
41
+ from_caption = lambda: parse_caption(caption) # noqa: E731
42
+ for read in ((from_caption, from_name) if mode == "caption" else (from_name, from_caption)):
43
+ info = read()
44
+ if info.episode.isdigit():
45
+ return (int(info.season) if info.season.isdigit() else 1), int(info.episode)
46
+ return None, None
47
+
48
+
49
+ @dataclass
50
+ class Analysis:
51
+ ordered: List[SeqItem] # numbered files by season / episode, then the others in the order they were sent
52
+ unknown: List[SeqItem] # no episode number found
53
+ missing: Dict[int, List[int]] # season -> episode numbers absent between its first and last
54
+ repeated: Dict[Tuple[int, int], List[SeqItem]] # (season, episode) -> the files that share it
55
+ first: Optional[Tuple[int, int]] = None
56
+ last: Optional[Tuple[int, int]] = None
57
+
58
+
59
+ def analyse(items: Sequence[SeqItem], mode: str) -> Analysis:
60
+ for item in items:
61
+ item.season, item.episode = detect(item.name, item.caption, mode)
62
+ numbered = sorted((i for i in items if i.episode is not None), key=lambda i: (i.season, i.episode, i.msg_id))
63
+ unknown = sorted((i for i in items if i.episode is None), key=lambda i: i.msg_id)
64
+
65
+ by_season: Dict[int, List[int]] = {}
66
+ groups: Dict[Tuple[int, int], List[SeqItem]] = {}
67
+ for item in numbered:
68
+ by_season.setdefault(item.season, []).append(item.episode)
69
+ groups.setdefault((item.season, item.episode), []).append(item)
70
+ missing: Dict[int, List[int]] = {}
71
+ for season, episodes in by_season.items():
72
+ have = set(episodes)
73
+ gaps = [n for n in range(min(have), max(have) + 1) if n not in have]
74
+ if gaps:
75
+ missing[season] = gaps
76
+ return Analysis(
77
+ ordered=numbered + unknown,
78
+ unknown=unknown,
79
+ missing=missing,
80
+ repeated={k: v for k, v in groups.items() if len(v) > 1},
81
+ first=(numbered[0].season, numbered[0].episode) if numbered else None,
82
+ last=(numbered[-1].season, numbered[-1].episode) if numbered else None,
83
+ )
84
+
85
+
86
+ def _ep(n: int) -> str:
87
+ return f"E{n:02d}"
88
+
89
+
90
+ def ranges(numbers: Sequence[int], limit: int = 0) -> str:
91
+ """``[3, 7, 8, 9]`` -> ``E03, E07–E09``; with ``limit`` the text is cut after that many characters."""
92
+ spans: List[Tuple[int, int]] = []
93
+ for n in sorted(set(numbers)):
94
+ if spans and n == spans[-1][1] + 1:
95
+ spans[-1] = (spans[-1][0], n)
96
+ else:
97
+ spans.append((n, n))
98
+ texts = [_ep(a) if a == b else f"{_ep(a)}–{_ep(b)}" for a, b in spans]
99
+ if limit and len(", ".join(texts)) > limit:
100
+ kept: List[str] = []
101
+ used = 0
102
+ for i, text in enumerate(texts):
103
+ if used + len(text) + 2 > limit - 24:
104
+ kept.append(f"… and {sum(b - a + 1 for a, b in spans[i:])} more")
105
+ break
106
+ kept.append(text)
107
+ used += len(text) + 2
108
+ return ", ".join(kept)
109
+ return ", ".join(texts)
110
+
111
+
112
+ def _label(season: int, episode: int, multi: bool) -> str:
113
+ return f"S{season:02d}{_ep(episode)}" if multi else _ep(episode)
114
+
115
+
116
+ def _bullets(lines: Sequence[str]) -> List[str]:
117
+ out = [f"• {line}" for line in lines[:MAX_LISTED]]
118
+ if len(lines) > MAX_LISTED:
119
+ out.append(f"… and {len(lines) - MAX_LISTED} more")
120
+ return out
121
+
122
+
123
+ def report_sections(a: Analysis, total: int, sent: int, failed: Sequence[SeqItem] = (), note: str = "") -> List[str]:
124
+ """The pieces of the message shown after the files were sent (HTML), each short enough on its own."""
125
+ seasons = {i.season for i in a.ordered if i.season is not None}
126
+ multi = len(seasons) > 1
127
+ head = f"✅ <b>Sorted {sent} of {total} files</b>" if sent != total else f"✅ <b>Sorted {total} file{'s' if total != 1 else ''}</b>"
128
+ if a.first and a.last:
129
+ head += f" · {_label(*a.first, multi)} → {_label(*a.last, multi)}"
130
+ parts = [head]
131
+ if note:
132
+ parts.append(note)
133
+
134
+ if a.missing:
135
+ lines = []
136
+ for season in sorted(a.missing)[:MAX_LISTED]:
137
+ text = ranges(a.missing[season], limit=300)
138
+ lines.append(f"S{season:02d}: {text}" if multi else text)
139
+ if len(a.missing) > MAX_LISTED:
140
+ lines.append(f"… and {len(a.missing) - MAX_LISTED} more seasons")
141
+ parts.append("⚠️ <b>Missing episodes</b>\n" + "\n".join(lines))
142
+ if a.repeated:
143
+ lines = []
144
+ for (season, episode), files in sorted(a.repeated.items()):
145
+ names = ", ".join(f"<code>{esc(clip(f.name, 40))}</code>" for f in files[:3])
146
+ more = f" +{len(files) - 3} more" if len(files) > 3 else ""
147
+ lines.append(f"{_label(season, episode, multi)} ×{len(files)}: {names}{more}")
148
+ parts.append("🔁 <b>Repeated episodes</b>\n" + "\n".join(_bullets(lines)))
149
+ if a.unknown:
150
+ lines = [f"<code>{esc(clip(i.name, 50))}</code>" for i in a.unknown]
151
+ parts.append("❓ <b>No episode number found</b> (sent last)\n" + "\n".join(_bullets(lines)))
152
+ if failed:
153
+ lines = [f"<code>{esc(clip(i.name, 50))}</code>" for i in failed]
154
+ parts.append("🚫 <b>Couldn't be sent</b>\n" + "\n".join(_bullets(lines)))
155
+ if a.first and not (a.missing or a.repeated):
156
+ parts.append("No missing or repeated episodes.")
157
+ return parts
158
+
159
+
160
+ def format_report(
161
+ a: Analysis, total: int, sent: int, failed: Sequence[SeqItem] = (), note: str = "", limit: int = 3600
162
+ ) -> List[str]:
163
+ """The report as one message, or a few when the lists are long (Telegram allows about 4000 characters)."""
164
+ messages: List[str] = []
165
+ current = ""
166
+ for part in report_sections(a, total, sent, failed, note):
167
+ if current and len(current) + 2 + len(part) > limit:
168
+ messages.append(current)
169
+ current = part
170
+ else:
171
+ current = f"{current}\n\n{part}" if current else part
172
+ messages.append(current)
173
+ return messages
bot/speed.py ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """/speed: an Ookla Speedtest run from the bot's own network.
2
+
3
+ The official Speedtest CLI is used when it is installed. If it is missing or fails, the ``speedtest-cli`` Python
4
+ package (also an Ookla client) is tried. Both give the same figures; only the parsing differs.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import html
10
+ import json
11
+ import logging
12
+ import os
13
+ import shutil
14
+ from dataclasses import dataclass
15
+ from typing import Any, Dict, List, Optional
16
+
17
+ from .utils import esc
18
+
19
+ log = logging.getLogger(__name__)
20
+
21
+ CLI_TIMEOUT = 120 # seconds for one run of either client
22
+ CANDIDATES = ("ookla-speedtest", "speedtest") # the Dockerfile installs the official binary as ookla-speedtest
23
+
24
+
25
+ class SpeedError(RuntimeError):
26
+ """The speed test could not be run; the message is safe to show to the user."""
27
+
28
+
29
+ @dataclass
30
+ class SpeedResult:
31
+ download_mbps: float
32
+ upload_mbps: float
33
+ ping_ms: float
34
+ jitter_ms: Optional[float] = None
35
+ packet_loss: Optional[float] = None
36
+ isp: str = ""
37
+ server: str = ""
38
+ location: str = ""
39
+ url: str = "" # the speedtest.net page of this result
40
+ source: str = ""
41
+
42
+
43
+ def _num(value: Any) -> Optional[float]:
44
+ try:
45
+ return float(value)
46
+ except (TypeError, ValueError):
47
+ return None
48
+
49
+
50
+ def parse_cli(data: Dict[str, Any]) -> SpeedResult:
51
+ """The JSON the official CLI prints (bandwidth is in bytes per second)."""
52
+ download = _num((data.get("download") or {}).get("bandwidth"))
53
+ upload = _num((data.get("upload") or {}).get("bandwidth"))
54
+ if download is None or upload is None:
55
+ raise SpeedError("the Speedtest CLI gave no download or upload figure")
56
+ ping = data.get("ping") or {}
57
+ server = data.get("server") or {}
58
+ location = ", ".join(part for part in (server.get("location"), server.get("country")) if part)
59
+ return SpeedResult(
60
+ download_mbps=download * 8 / 1e6,
61
+ upload_mbps=upload * 8 / 1e6,
62
+ ping_ms=_num(ping.get("latency")) or 0.0,
63
+ jitter_ms=_num(ping.get("jitter")),
64
+ packet_loss=_num(data.get("packetLoss")),
65
+ isp=str(data.get("isp") or ""),
66
+ server=str(server.get("name") or server.get("host") or ""),
67
+ location=location,
68
+ url=str((data.get("result") or {}).get("url") or ""),
69
+ source="Ookla Speedtest CLI",
70
+ )
71
+
72
+
73
+ def parse_python(data: Dict[str, Any]) -> SpeedResult:
74
+ """``Speedtest.results.dict()`` of the speedtest-cli package (speeds are in bits per second)."""
75
+ download, upload = _num(data.get("download")), _num(data.get("upload"))
76
+ if download is None or upload is None:
77
+ raise SpeedError("speedtest-cli gave no download or upload figure")
78
+ server = data.get("server") or {}
79
+ client = data.get("client") or {}
80
+ location = ", ".join(part for part in (server.get("name"), server.get("country")) if part)
81
+ return SpeedResult(
82
+ download_mbps=download / 1e6,
83
+ upload_mbps=upload / 1e6,
84
+ ping_ms=_num(data.get("ping")) or 0.0,
85
+ isp=str(client.get("isp") or ""),
86
+ server=str(server.get("sponsor") or ""),
87
+ location=location,
88
+ url=str(data.get("share") or ""),
89
+ source="speedtest-cli (Ookla)",
90
+ )
91
+
92
+
93
+ def _last_result(stdout: str) -> Optional[Dict[str, Any]]:
94
+ """The ``{"type": "result", ...}`` line of the CLI's JSON output."""
95
+ found = None
96
+ for line in stdout.splitlines():
97
+ line = line.strip()
98
+ if not line.startswith("{"):
99
+ continue
100
+ try:
101
+ obj = json.loads(line)
102
+ except ValueError:
103
+ continue
104
+ if isinstance(obj, dict) and obj.get("type") == "result":
105
+ found = obj
106
+ return found
107
+
108
+
109
+ def _log_message(stdout: str, stderr: str) -> str:
110
+ """Why the CLI failed, as far as it says."""
111
+ for text in (stdout, stderr):
112
+ for line in reversed(text.splitlines()):
113
+ line = line.strip()
114
+ if line.startswith("{"):
115
+ try:
116
+ obj = json.loads(line)
117
+ except ValueError:
118
+ continue
119
+ if isinstance(obj, dict) and obj.get("message"):
120
+ return str(obj["message"])
121
+ elif line:
122
+ return line
123
+ return "no output"
124
+
125
+
126
+ async def _run(cmd: List[str], timeout: float) -> "tuple[int, str, str]":
127
+ try:
128
+ proc = await asyncio.create_subprocess_exec(
129
+ *cmd, stdin=asyncio.subprocess.DEVNULL, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
130
+ )
131
+ except (FileNotFoundError, PermissionError, OSError) as exc:
132
+ raise SpeedError(f"{cmd[0]} cannot be started ({exc})") from exc
133
+ try:
134
+ out, err = await asyncio.wait_for(proc.communicate(), timeout)
135
+ except asyncio.TimeoutError:
136
+ proc.kill()
137
+ await proc.wait()
138
+ raise SpeedError("the speed test took too long") from None
139
+ return proc.returncode or 0, out.decode("utf-8", "replace"), err.decode("utf-8", "replace")
140
+
141
+
142
+ async def find_ookla_cli() -> Optional[str]:
143
+ """The official CLI, if installed. ``speedtest`` can also be the Python package's script, so ``--version`` decides."""
144
+ names = [os.environ.get("SPEEDTEST_BIN", "")] + list(CANDIDATES)
145
+ for name in dict.fromkeys(n for n in names if n):
146
+ path = shutil.which(name)
147
+ if not path:
148
+ continue
149
+ try:
150
+ code, out, _err = await _run([path, "--version"], 15)
151
+ except SpeedError:
152
+ continue
153
+ if code == 0 and "ookla" in out.lower():
154
+ return path
155
+ return None
156
+
157
+
158
+ async def run_cli(path: str, timeout: float = CLI_TIMEOUT) -> SpeedResult:
159
+ code, out, err = await _run([path, "--accept-license", "--accept-gdpr", "--format=json"], timeout)
160
+ result = _last_result(out)
161
+ if result is None:
162
+ raise SpeedError(f"the Speedtest CLI failed: {_log_message(out, err)}")
163
+ return parse_cli(result)
164
+
165
+
166
+ def _run_python() -> Dict[str, Any]:
167
+ """Blocking: one test with the speedtest-cli package."""
168
+ try:
169
+ import speedtest # type: ignore
170
+ except ImportError as exc:
171
+ raise SpeedError("the speedtest-cli package is not installed") from exc
172
+ try:
173
+ test = speedtest.Speedtest(secure=True)
174
+ test.get_best_server()
175
+ test.download()
176
+ test.upload()
177
+ return dict(test.results.dict())
178
+ except Exception as exc: # noqa: BLE001 - the package raises its own family of errors
179
+ raise SpeedError(f"speedtest-cli failed: {exc}") from exc
180
+
181
+
182
+ async def run_speedtest() -> SpeedResult:
183
+ """Run a test with the official CLI, or with the Python client when the CLI is missing or fails."""
184
+ problems: List[str] = []
185
+ cli = await find_ookla_cli()
186
+ if cli is not None:
187
+ try:
188
+ return await run_cli(cli)
189
+ except SpeedError as exc:
190
+ log.warning("Speedtest CLI failed: %s", exc)
191
+ problems.append(str(exc))
192
+ else:
193
+ problems.append("the Speedtest CLI is not installed")
194
+ try:
195
+ data = await asyncio.wait_for(asyncio.to_thread(_run_python), CLI_TIMEOUT)
196
+ return parse_python(data)
197
+ except asyncio.TimeoutError:
198
+ problems.append("speedtest-cli took too long")
199
+ except SpeedError as exc:
200
+ problems.append(str(exc))
201
+ raise SpeedError("; ".join(problems))
202
+
203
+
204
+ def _mbps(value: float) -> str:
205
+ return f"{value:,.2f} Mbps"
206
+
207
+
208
+ def format_result(r: SpeedResult) -> str:
209
+ lines = [
210
+ "<b>⚡ SPEEDTEST</b> · Ookla",
211
+ "",
212
+ f"◈ <b>Download</b> ▸ <code>{_mbps(r.download_mbps)}</code>",
213
+ f"◈ <b>Upload</b> ▸ <code>{_mbps(r.upload_mbps)}</code>",
214
+ f"◈ <b>Ping</b> ▸ <code>{r.ping_ms:.2f} ms</code>" + (f" · jitter <code>{r.jitter_ms:.2f} ms</code>" if r.jitter_ms is not None else ""),
215
+ ]
216
+ if r.packet_loss is not None:
217
+ lines.append(f"◈ <b>Packet loss</b> ▸ <code>{r.packet_loss:g}%</code>")
218
+ if r.isp:
219
+ lines.append(f"◈ <b>ISP</b> ▸ {esc(r.isp)}")
220
+ server = " · ".join(part for part in (esc(r.server), esc(r.location)) if part)
221
+ if server:
222
+ lines.append(f"◈ <b>Server</b> ▸ {server}")
223
+ if r.url.startswith(("http://", "https://")):
224
+ lines.append(f"◈ <b>Result</b> ▸ <a href=\"{html.escape(r.url, quote=True)}\">speedtest.net</a>")
225
+ lines.append(f"\n<i>Measured from the machine the bot runs on · {esc(r.source)}</i>")
226
+ return "\n".join(lines)
bot/telegraph.py ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """A minimal Telegraph (telegra.ph) client: one account for the bot, pages created from node lists."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import json
6
+ import logging
7
+ from typing import Any, Awaitable, Callable, Dict, List, Optional
8
+
9
+ import aiohttp
10
+
11
+ log = logging.getLogger(__name__)
12
+
13
+ API = "https://api.telegra.ph"
14
+ CONTENT_LIMIT = 60_000 # Telegraph refuses a page whose content is over 64 KB; stay clear of it
15
+ TITLE_LIMIT = 256
16
+
17
+ Node = Dict[str, Any]
18
+
19
+
20
+ class TelegraphError(RuntimeError):
21
+ """Telegraph refused or could not be reached; the message is safe to show to the user."""
22
+
23
+ def __init__(self, message: str, code: str = ""):
24
+ super().__init__(message)
25
+ self.code = code
26
+
27
+
28
+ def content_size(nodes: List[Node]) -> int:
29
+ """Bytes the content takes as it is sent."""
30
+ return len(dump(nodes).encode("utf-8"))
31
+
32
+
33
+ def dump(nodes: List[Node]) -> str:
34
+ return json.dumps(nodes, ensure_ascii=False, separators=(",", ":"))
35
+
36
+
37
+ class Telegraph:
38
+ """Creates pages under one account. The access token comes from ``load_token`` and is saved with ``save_token``."""
39
+
40
+ def __init__(
41
+ self,
42
+ load_token: Callable[[], Awaitable[Optional[str]]],
43
+ save_token: Callable[[str], Awaitable[None]],
44
+ *,
45
+ api: str = API,
46
+ short_name: str = "AutoRename",
47
+ author: str = "Auto Rename Bot",
48
+ timeout: float = 30,
49
+ ):
50
+ self._load = load_token
51
+ self._save = save_token
52
+ self.api = api.rstrip("/")
53
+ self.short_name = short_name
54
+ self.author = author
55
+ self.author_url = ""
56
+ self.timeout = timeout
57
+ self._token: Optional[str] = None
58
+ self._lock = asyncio.Lock()
59
+
60
+ def set_author(self, name: str, url: str = "") -> None:
61
+ """The name (and link) shown under the title of the pages; the bot's own, once it is known."""
62
+ if name.strip():
63
+ self.author = name.strip()[:128]
64
+ self.author_url = url.strip()[:512]
65
+
66
+ async def _call(self, method: str, **params: str) -> Dict[str, Any]:
67
+ try:
68
+ async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=self.timeout)) as session:
69
+ async with session.post(f"{self.api}/{method}", data=params) as resp:
70
+ data = await resp.json(content_type=None)
71
+ except asyncio.TimeoutError:
72
+ raise TelegraphError("Telegraph took too long to answer") from None
73
+ except (aiohttp.ClientError, ValueError) as exc:
74
+ raise TelegraphError(f"I couldn't reach Telegraph ({type(exc).__name__})") from exc
75
+ if not isinstance(data, dict) or not data.get("ok"):
76
+ code = str(data.get("error") if isinstance(data, dict) else "") or "UNKNOWN"
77
+ if code.startswith("FLOOD_WAIT_"):
78
+ raise TelegraphError(f"Telegraph asks to wait {code.rsplit('_', 1)[-1]} seconds", code)
79
+ raise TelegraphError(f"Telegraph said {code}", code)
80
+ result = data.get("result")
81
+ if not isinstance(result, dict):
82
+ raise TelegraphError("Telegraph sent an answer I don't understand")
83
+ return result
84
+
85
+ async def token(self) -> str:
86
+ """The account's access token: remembered, else loaded, else a new account is made."""
87
+ async with self._lock:
88
+ if self._token:
89
+ return self._token
90
+ saved = await self._load()
91
+ if saved:
92
+ self._token = saved
93
+ return saved
94
+ result = await self._call("createAccount", short_name=self.short_name, author_name=self.author)
95
+ token = str(result.get("access_token") or "")
96
+ if not token:
97
+ raise TelegraphError("Telegraph did not give an access token")
98
+ self._token = token
99
+ await self._save(token)
100
+ return token
101
+
102
+ async def _forget_token(self) -> None:
103
+ async with self._lock:
104
+ self._token = None
105
+ await self._save("")
106
+
107
+ async def create_page(self, title: str, nodes: List[Node]) -> str:
108
+ """Create a page and return its URL."""
109
+ content = dump(nodes)
110
+ if len(content.encode("utf-8")) > 64 * 1024:
111
+ raise TelegraphError("The page is too big for Telegraph", "CONTENT_TOO_BIG")
112
+ for attempt in range(2):
113
+ token = await self.token()
114
+ params = {"access_token": token, "title": (title.strip() or "MediaInfo")[:TITLE_LIMIT],
115
+ "author_name": self.author, "content": content, "return_content": "false"}
116
+ if self.author_url:
117
+ params["author_url"] = self.author_url
118
+ try:
119
+ result = await self._call("createPage", **params)
120
+ except TelegraphError as exc:
121
+ if exc.code == "ACCESS_TOKEN_INVALID" and attempt == 0:
122
+ log.warning("the saved Telegraph token was rejected; making a new account")
123
+ await self._forget_token()
124
+ continue
125
+ raise
126
+ url = str(result.get("url") or "")
127
+ if not url:
128
+ raise TelegraphError("Telegraph did not return the page address")
129
+ return url
130
+ raise TelegraphError("Telegraph rejected the account twice") # pragma: no cover - the loop returns or raises
requirements.txt CHANGED
@@ -6,4 +6,6 @@ asyncpg>=0.29
6
  aiohttp>=3.9
7
  psutil>=5.9
8
  Pillow>=10.0
 
 
9
  uvloop>=0.19; sys_platform != "win32"
 
6
  aiohttp>=3.9
7
  psutil>=5.9
8
  Pillow>=10.0
9
+ pymediainfo>=6.1
10
+ speedtest-cli>=2.1.3
11
  uvloop>=0.19; sys_platform != "win32"
tests/appkit.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared fixture and helpers for the handler-level tests (real Telethon event builders, fake events and client)."""
2
+ import io
3
+ from types import SimpleNamespace
4
+
5
+ import pytest
6
+ from PIL import Image
7
+
8
+ from bot.app import App
9
+ from bot.config import load_config
10
+ from bot.db import Database
11
+
12
+ from .tgfakes import FakeCallback, FakeClient, FakeEvent, dispatch, document_message, flat_buttons
13
+
14
+ OWNER, ADMIN_CFG, ADMIN_DB, STRANGER = 1000001, 1000002, 1000003, 1999999
15
+ LOG = -1001111111111
16
+ EPISODE = "[SubsPlease] Tokyo Revengers - 05 (1080p) [ABCD1234].mkv"
17
+ EPISODE_NEW = "[S01E05] Tokyo Revengers [1080p].mkv"
18
+
19
+
20
+ def env_for(tmp_path, **extra):
21
+ return {
22
+ "API_ID": "1", "API_HASH": "h", "BOT_TOKEN": "1:t", "OWNER_ID": str(OWNER), "ADMIN_IDS": str(ADMIN_CFG),
23
+ "LOG_CHANNEL": str(LOG), "TEMP_DIR": str(tmp_path / "work"), "FAST_TRANSFER": "0", "MAX_FILE_MB": "100",
24
+ **extra,
25
+ }
26
+
27
+
28
+ @pytest.fixture
29
+ async def ctx(tmp_path):
30
+ cfg = load_config(env_for(tmp_path))
31
+ db = Database(f"sqlite+aiosqlite:///{tmp_path}/app.db")
32
+ await db.init()
33
+ client = FakeClient()
34
+ app = App(client, cfg, db)
35
+ app._register() # setup() minus the queue workers: jobs stay queued so they can be inspected
36
+ yield SimpleNamespace(app=app, client=client, db=db, cfg=cfg, tmp=tmp_path)
37
+ await app.shutdown()
38
+ await db.close()
39
+
40
+
41
+ async def say(ctx, sender, text="", **kw):
42
+ ev = FakeEvent(sender, text, **kw)
43
+ await dispatch(ctx.client, ev)
44
+ return ev
45
+
46
+
47
+ async def send_doc(ctx, sender, name=EPISODE, size=50 * 1024 * 1024, **kw):
48
+ message = document_message(name, size, **kw)
49
+ ctx.client.messages[message.id] = message # so get_messages() can find it again
50
+ ev = FakeEvent(sender, message=message)
51
+ await dispatch(ctx.client, ev)
52
+ return ev
53
+
54
+
55
+ async def click(ctx, sender, data, **kw):
56
+ cb = FakeCallback(sender, data, **kw)
57
+ await dispatch(ctx.client, cb)
58
+ return cb
59
+
60
+
61
+ async def prefs(ctx, uid=OWNER):
62
+ return await ctx.db.get_settings(uid)
63
+
64
+
65
+ def jpeg(size=(640, 480)):
66
+ buf = io.BytesIO()
67
+ Image.new("RGB", size, (200, 30, 30)).save(buf, "JPEG")
68
+ return buf.getvalue()
69
+
70
+
71
+ def data_of(buttons):
72
+ return [d for _l, d in flat_buttons(buttons)]
tests/conftest.py CHANGED
@@ -9,6 +9,10 @@ import pytest
9
 
10
  sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
11
 
 
 
 
 
12
  HAVE_FFMPEG = shutil.which("ffmpeg") is not None and shutil.which("ffprobe") is not None
13
  needs_ffmpeg = pytest.mark.skipif(not HAVE_FFMPEG, reason="ffmpeg/ffprobe not installed")
14
 
 
9
 
10
  sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
11
 
12
+ from .appkit import ctx # noqa: E402 the handler-level fixture, shared by every test module
13
+
14
+ __all__ = ["ctx", "needs_ffmpeg"]
15
+
16
  HAVE_FFMPEG = shutil.which("ffmpeg") is not None and shutil.which("ffprobe") is not None
17
  needs_ffmpeg = pytest.mark.skipif(not HAVE_FFMPEG, reason="ffmpeg/ffprobe not installed")
18
 
tests/faketelegraph.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """A local stand-in for api.telegra.ph (createAccount and createPage), used by the Telegraph and /mediainfo tests."""
2
+ import asyncio
3
+
4
+ from aiohttp import web
5
+ from aiohttp.test_utils import TestServer
6
+
7
+
8
+ class FakeTelegraph:
9
+ """api.telegra.ph for tests: createAccount and createPage, with switches for the ways it can go wrong."""
10
+
11
+ def __init__(self):
12
+ self.accounts = 0
13
+ self.valid = set()
14
+ self.attempts = [] # every createPage form, accepted or not
15
+ self.pages = [] # the accepted ones
16
+ self.error = None # answer every createPage with this error
17
+ self.reject_all = False # a token is never good enough
18
+ self.delay = 0.0
19
+ self.raw = None # (status, body) to answer createPage with instead of JSON
20
+
21
+ async def create_account(self, request):
22
+ form = await request.post()
23
+ self.accounts += 1
24
+ token = f"token{self.accounts}"
25
+ self.valid.add(token)
26
+ return web.json_response({"ok": True, "result": {"short_name": form["short_name"], "access_token": token}})
27
+
28
+ async def create_page(self, request):
29
+ form = dict(await request.post())
30
+ self.attempts.append(form)
31
+ if self.delay:
32
+ await asyncio.sleep(self.delay)
33
+ if self.raw is not None:
34
+ return web.Response(status=self.raw[0], text=self.raw[1], content_type="text/html")
35
+ if self.error:
36
+ return web.json_response({"ok": False, "error": self.error})
37
+ if self.reject_all or form.get("access_token") not in self.valid:
38
+ return web.json_response({"ok": False, "error": "ACCESS_TOKEN_INVALID"})
39
+ self.pages.append(form)
40
+ n = len(self.pages)
41
+ return web.json_response({"ok": True, "result": {"path": f"Page-{n}", "url": f"https://telegra.ph/Page-{n}", "title": form["title"]}})
42
+
43
+
44
+ async def serve_fake_telegraph() -> "FakeTelegraph":
45
+ """Start the fake server; ``fake.url`` is the API address and ``await fake.server.close()`` stops it."""
46
+ fake = FakeTelegraph()
47
+ app = web.Application()
48
+ app.router.add_post("/createAccount", fake.create_account)
49
+ app.router.add_post("/createPage", fake.create_page)
50
+ fake.server = TestServer(app)
51
+ await fake.server.start_server()
52
+ fake.url = str(fake.server.make_url("")).rstrip("/")
53
+ return fake
tests/test_app.py CHANGED
@@ -1,14 +1,10 @@
1
  """Handler-level tests: real Telethon event builders, fake events and client."""
2
  import asyncio
3
- import dataclasses
4
- import io
5
  import shutil
6
  import time
7
  from pathlib import Path
8
- from types import SimpleNamespace
9
 
10
  import pytest
11
- from PIL import Image
12
  from telethon import functions
13
 
14
  from bot import app as appmod
@@ -19,69 +15,11 @@ from bot.db import Database
19
  from bot.queue import JobQueue
20
 
21
  from .conftest import needs_ffmpeg
 
22
  from .tgfakes import (
23
  FakeCallback, FakeClient, FakeEvent, FakeMessage, dispatch, document_message, flat_buttons, make_file,
24
  )
25
 
26
- OWNER, ADMIN_CFG, ADMIN_DB, STRANGER = 1000001, 1000002, 1000003, 1999999
27
- LOG = -1001111111111
28
- EPISODE = "[SubsPlease] Tokyo Revengers - 05 (1080p) [ABCD1234].mkv"
29
- EPISODE_NEW = "[S01E05] Tokyo Revengers [1080p].mkv"
30
-
31
-
32
- def env_for(tmp_path, **extra):
33
- return {
34
- "API_ID": "1", "API_HASH": "h", "BOT_TOKEN": "1:t", "OWNER_ID": str(OWNER), "ADMIN_IDS": str(ADMIN_CFG),
35
- "LOG_CHANNEL": str(LOG), "TEMP_DIR": str(tmp_path / "work"), "FAST_TRANSFER": "0", "MAX_FILE_MB": "100",
36
- **extra,
37
- }
38
-
39
-
40
- @pytest.fixture
41
- async def ctx(tmp_path):
42
- cfg = load_config(env_for(tmp_path))
43
- db = Database(f"sqlite+aiosqlite:///{tmp_path}/app.db")
44
- await db.init()
45
- client = FakeClient()
46
- app = App(client, cfg, db)
47
- app._register() # setup() minus the queue workers: jobs stay queued so they can be inspected
48
- yield SimpleNamespace(app=app, client=client, db=db, cfg=cfg, tmp=tmp_path)
49
- await app.queue.stop()
50
- await db.close()
51
-
52
-
53
- async def say(ctx, sender, text="", **kw):
54
- ev = FakeEvent(sender, text, **kw)
55
- await dispatch(ctx.client, ev)
56
- return ev
57
-
58
-
59
- async def send_doc(ctx, sender, name=EPISODE, size=50 * 1024 * 1024, **kw):
60
- ev = FakeEvent(sender, message=document_message(name, size, **kw))
61
- await dispatch(ctx.client, ev)
62
- return ev
63
-
64
-
65
- async def click(ctx, sender, data, **kw):
66
- cb = FakeCallback(sender, data, **kw)
67
- await dispatch(ctx.client, cb)
68
- return cb
69
-
70
-
71
- async def prefs(ctx, uid=OWNER):
72
- return await ctx.db.get_settings(uid)
73
-
74
-
75
- def jpeg(size=(640, 480)):
76
- buf = io.BytesIO()
77
- Image.new("RGB", size, (200, 30, 30)).save(buf, "JPEG")
78
- return buf.getvalue()
79
-
80
-
81
- def data_of(buttons):
82
- return [d for _l, d in flat_buttons(buttons)]
83
-
84
-
85
  # ---------------------------------------------------------------------------
86
  # access control & routing
87
  # ---------------------------------------------------------------------------
@@ -186,18 +124,6 @@ async def test_start_variants(ctx, text):
186
  assert (await say(ctx, OWNER, text)).out
187
 
188
 
189
- async def test_start_image_is_sent_as_a_photo_with_the_panel_as_caption(ctx):
190
- ctx.app.cfg = dataclasses.replace(ctx.cfg, start_image="https://example.com/banner.jpg")
191
- ev = await say(ctx, OWNER, "/start")
192
- assert ev.out == []
193
- (chat, file, kw), = ctx.client.files
194
- assert (chat, file) == (OWNER, "https://example.com/banner.jpg")
195
- assert "Auto Rename Bot" in kw["caption"] and kw["buttons"] and kw["parse_mode"] == "html"
196
- ctx.client.fail_send_file = True # a broken banner must not break /start
197
- ev = await say(ctx, OWNER, "/start")
198
- assert "Auto Rename Bot" in ev.texts[0]
199
-
200
-
201
  async def test_start_and_help(ctx):
202
  ctx.app.pending[OWNER] = Pending("template", OWNER, 1, "rename", False, time.time() + 100)
203
  await say(ctx, OWNER, "/start")
@@ -370,7 +296,7 @@ async def test_a_file_is_queued_with_the_template_name_and_a_cancel_button(ctx):
370
  ev = await send_doc(ctx, OWNER, mid=10)
371
  (job,) = ctx.app.queue.jobs.values()
372
  assert job.new_name == EPISODE_NEW and (job.user_id, job.chat_id, job.msg_id) == (OWNER, OWNER, 10)
373
- assert job.file_name == EPISODE and job.size == 50 * 1024 * 1024 and not job.manual and job.state == "queued"
374
  text, buttons, kw = ev.out[0]
375
  assert "Queued" in text and "starting soon" in text and EPISODE_NEW in text
376
  assert flat_buttons(buttons) == [("✖ Cancel", f"job:x:{job.id}")]
@@ -407,7 +333,7 @@ async def test_a_template_variable_that_cannot_be_filled_is_flagged(ctx):
407
  ev = await send_doc(ctx, OWNER, "Plain Movie.mkv")
408
  (job,) = ctx.app.queue.jobs.values()
409
  assert job.new_name == "One Piece - E.mkv"
410
- assert "⚠️" in ev.texts[0] and "{episode}" in ev.texts[0] and "file name" in ev.texts[0] and "/rename" in ev.texts[0]
411
  assert job.notes and "{episode}" in job.notes[0]
412
 
413
  await say(ctx, OWNER, "/mode caption")
@@ -415,12 +341,9 @@ async def test_a_template_variable_that_cannot_be_filled_is_flagged(ctx):
415
  assert "in the caption" in ev.texts[0]
416
 
417
 
418
- async def test_default_template_and_manual_names_never_warn(ctx):
419
  ev = await send_doc(ctx, OWNER, "Plain Movie.mkv")
420
  assert "⚠️" not in ev.texts[0] # bracket groups vanish on purpose
421
- await say(ctx, OWNER, "/template One Piece - E{episode}")
422
- ev = await say(ctx, OWNER, "/rename My Name", reply=document_message("Plain Movie.mkv", mid=40), mid=41)
423
- assert "⚠️" not in ev.texts[0]
424
 
425
 
426
  class SlowEvent(FakeEvent):
@@ -438,7 +361,7 @@ class SlowEvent(FakeEvent):
438
  async def test_jobs_are_ordered_by_the_message_that_triggered_them(ctx):
439
  await send_doc(ctx, OWNER, "B.mkv", mid=31)
440
  await send_doc(ctx, OWNER, "A.mkv", mid=30)
441
- await say(ctx, OWNER, "/rename X", reply=document_message("old.mkv", 1024, mid=5), mid=34)
442
  assert sorted(j.order for j in ctx.app.queue.jobs.values()) == [30, 31, 34]
443
  assert [j.file_name for j in ctx.app.queue.for_user(OWNER)] == ["A.mkv", "B.mkv", "old.mkv"]
444
 
@@ -467,30 +390,6 @@ async def test_a_forwarded_batch_comes_out_in_the_order_it_was_sent(ctx):
467
  assert ran == [names[1145], names[1146], names[1147]]
468
 
469
 
470
- async def test_rename_command_uses_the_given_name_and_keeps_the_extension(ctx):
471
- reply = document_message("old.name.mkv", 10 * 1024 * 1024, mid=33)
472
- ev = await say(ctx, OWNER, "/rename My Custom Name", reply=reply, mid=34)
473
- (job,) = ctx.app.queue.jobs.values()
474
- assert job.new_name == "My Custom Name.mkv" and job.manual and job.msg_id == 33
475
- assert "My Custom Name.mkv" in ev.texts[0]
476
-
477
- ev = await say(ctx, OWNER, "/rename Movie.2024.mp4", reply=reply, mid=35) # a typed extension never wins
478
- assert sorted(j.new_name for j in ctx.app.queue.jobs.values())[1].endswith(".mkv")
479
-
480
-
481
- async def test_rename_usage_messages(ctx):
482
- for kwargs in (
483
- {"reply": None}, # no reply
484
- {"reply": FakeMessage("just text", mid=5)}, # reply to text
485
- {"reply": FakeMessage("", mid=6, file=make_file("s.webp"), sticker=True)},
486
- ):
487
- ev = await say(ctx, OWNER, "/rename New Name", **kwargs)
488
- assert "Reply to a file" in ev.texts[0], kwargs
489
- ev = await say(ctx, OWNER, "/rename", reply=document_message()) # reply but no name
490
- assert "Reply to a file" in ev.texts[0]
491
- assert not ctx.app.queue.jobs
492
-
493
-
494
  async def test_oversized_files_are_refused(ctx):
495
  ev = await send_doc(ctx, OWNER, size=101 * 1024 * 1024)
496
  assert "limit is 100.00 MB" in ev.texts[0] and not ctx.app.queue.jobs
 
1
  """Handler-level tests: real Telethon event builders, fake events and client."""
2
  import asyncio
 
 
3
  import shutil
4
  import time
5
  from pathlib import Path
 
6
 
7
  import pytest
 
8
  from telethon import functions
9
 
10
  from bot import app as appmod
 
15
  from bot.queue import JobQueue
16
 
17
  from .conftest import needs_ffmpeg
18
+ from .appkit import ADMIN_CFG, ADMIN_DB, EPISODE, EPISODE_NEW, LOG, OWNER, STRANGER, click, data_of, env_for, jpeg, prefs, say, send_doc
19
  from .tgfakes import (
20
  FakeCallback, FakeClient, FakeEvent, FakeMessage, dispatch, document_message, flat_buttons, make_file,
21
  )
22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  # ---------------------------------------------------------------------------
24
  # access control & routing
25
  # ---------------------------------------------------------------------------
 
124
  assert (await say(ctx, OWNER, text)).out
125
 
126
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  async def test_start_and_help(ctx):
128
  ctx.app.pending[OWNER] = Pending("template", OWNER, 1, "rename", False, time.time() + 100)
129
  await say(ctx, OWNER, "/start")
 
296
  ev = await send_doc(ctx, OWNER, mid=10)
297
  (job,) = ctx.app.queue.jobs.values()
298
  assert job.new_name == EPISODE_NEW and (job.user_id, job.chat_id, job.msg_id) == (OWNER, OWNER, 10)
299
+ assert job.file_name == EPISODE and job.size == 50 * 1024 * 1024 and job.fmt is None and job.state == "queued"
300
  text, buttons, kw = ev.out[0]
301
  assert "Queued" in text and "starting soon" in text and EPISODE_NEW in text
302
  assert flat_buttons(buttons) == [("✖ Cancel", f"job:x:{job.id}")]
 
333
  ev = await send_doc(ctx, OWNER, "Plain Movie.mkv")
334
  (job,) = ctx.app.queue.jobs.values()
335
  assert job.new_name == "One Piece - E.mkv"
336
+ assert "⚠️" in ev.texts[0] and "{episode}" in ev.texts[0] and "file name" in ev.texts[0] and "change the template" in ev.texts[0]
337
  assert job.notes and "{episode}" in job.notes[0]
338
 
339
  await say(ctx, OWNER, "/mode caption")
 
341
  assert "in the caption" in ev.texts[0]
342
 
343
 
344
+ async def test_the_default_template_never_warns(ctx):
345
  ev = await send_doc(ctx, OWNER, "Plain Movie.mkv")
346
  assert "⚠️" not in ev.texts[0] # bracket groups vanish on purpose
 
 
 
347
 
348
 
349
  class SlowEvent(FakeEvent):
 
361
  async def test_jobs_are_ordered_by_the_message_that_triggered_them(ctx):
362
  await send_doc(ctx, OWNER, "B.mkv", mid=31)
363
  await send_doc(ctx, OWNER, "A.mkv", mid=30)
364
+ await send_doc(ctx, OWNER, "old.mkv", 1024, mid=34)
365
  assert sorted(j.order for j in ctx.app.queue.jobs.values()) == [30, 31, 34]
366
  assert [j.file_name for j in ctx.app.queue.for_user(OWNER)] == ["A.mkv", "B.mkv", "old.mkv"]
367
 
 
390
  assert ran == [names[1145], names[1146], names[1147]]
391
 
392
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
393
  async def test_oversized_files_are_refused(ctx):
394
  ev = await send_doc(ctx, OWNER, size=101 * 1024 * 1024)
395
  assert "limit is 100.00 MB" in ev.texts[0] and not ctx.app.queue.jobs
tests/test_check.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """/check: what the bot can read from a file's name and caption."""
2
+ from .appkit import OWNER, say
3
+ from .tgfakes import FakeMessage, document_message
4
+
5
+ NAME = "[S01 E02] Dragon Ball Super Beerus [1080p].mkv"
6
+
7
+
8
+ def detail(text, block, label):
9
+ """The value shown for ``label`` inside the ``block`` ("Fɪʟᴇɴᴀᴍᴇ" / "Cᴀᴘᴛɪᴏɴ Mᴏᴅᴇ") of a /check reply."""
10
+ section = text.split(f"<b>{block}:</b>\n", 1)[1].split("\n\n", 1)[0]
11
+ line = next(ln for ln in section.splitlines() if ln.startswith(f"➦ {label} ⇛"))
12
+ return line.split("<code>", 1)[1].split("</code>", 1)[0]
13
+
14
+
15
+ async def test_check_lists_what_it_reads_from_the_name_and_the_caption(ctx):
16
+ reply = document_message(NAME, caption="Dragon Ball Super Beerus - S01E02 [720p]")
17
+ ev = await say(ctx, OWNER, "/check", reply=reply)
18
+ text = ev.texts[0]
19
+ assert text.startswith("<b>Yᴏᴜ Cᴀɴ Cʜᴀɴɢᴇ Mᴏᴅᴇ Bʏ</b> /mode")
20
+ assert "<b>Rᴇɴᴀᴍɪɴɢ Mᴏᴅᴇ:</b> <code>FILENAME</code>" in text
21
+ for label, expected in (("Title", "Dragon Ball Super Beerus"), ("Season", "01"), ("Episode", "02"), ("Quality", "1080p")):
22
+ assert detail(text, "Fɪʟᴇɴᴀᴍᴇ", label) == expected
23
+ for label, expected in (("Title", "Dragon Ball Super Beerus"), ("Season", "01"), ("Episode", "02"), ("Quality", "720p")):
24
+ assert detail(text, "Cᴀᴘᴛɪᴏɴ Mᴏᴅᴇ", label) == expected
25
+ assert ev.out[0][2]["parse_mode"] == "html"
26
+
27
+
28
+ async def test_there_is_no_chapter_field(ctx):
29
+ ev = await say(ctx, OWNER, "/check", reply=document_message(NAME))
30
+ assert "Chapter" not in ev.texts[0]
31
+
32
+
33
+ async def test_what_is_not_found_shows_as_none(ctx):
34
+ ev = await say(ctx, OWNER, "/check", reply=document_message("holiday video.mp4"))
35
+ text = ev.texts[0]
36
+ assert detail(text, "Fɪʟᴇɴᴀᴍᴇ", "Season") == "None" and detail(text, "Fɪʟᴇɴᴀᴍᴇ", "Episode") == "None"
37
+ assert all(detail(text, "Cᴀᴘᴛɪᴏɴ Mᴏᴅᴇ", f) == "None" for f in ("Title", "Season", "Episode", "Quality"))
38
+ assert "Year" not in text and "Codec" not in text # only listed when there is one
39
+
40
+
41
+ async def test_year_and_codec_show_up_when_the_file_has_them(ctx):
42
+ ev = await say(ctx, OWNER, "/check", reply=document_message("Some Movie (2023) 1080p x265.mkv"))
43
+ assert detail(ev.texts[0], "Fɪʟᴇɴᴀᴍᴇ", "Year") == "2023" and detail(ev.texts[0], "Fɪʟᴇɴᴀᴍᴇ", "Codec") != "None"
44
+
45
+
46
+ async def test_the_current_mode_is_shown_and_decides_the_new_name(ctx):
47
+ await say(ctx, OWNER, "/template [S{season}E{episode}] {title} [{quality}]")
48
+ reply = document_message("random.mkv", caption="One Piece - S01E1147 [720p]")
49
+ ev = await say(ctx, OWNER, "/check", reply=reply)
50
+ assert "<code>FILENAME</code>" in ev.texts[0]
51
+ assert "<b>Nᴇᴡ Nᴀᴍᴇ:</b>\n<code>random.mkv</code>" in ev.texts[0] # nothing to read in the name, so it is left as it is
52
+
53
+ await say(ctx, OWNER, "/mode caption")
54
+ ev = await say(ctx, OWNER, "/check", reply=reply)
55
+ assert "<b>Rᴇɴᴀᴍɪɴɢ Mᴏᴅᴇ:</b> <code>CAPTION</code>" in ev.texts[0]
56
+ assert "<code>[S01E1147] One Piece [720p].mkv</code>" in ev.texts[0]
57
+ assert detail(ev.texts[0], "Fɪʟᴇɴᴀᴍᴇ", "Episode") == "None" and detail(ev.texts[0], "Cᴀᴘᴛɪᴏɴ Mᴏᴅᴇ", "Episode") == "1147"
58
+
59
+
60
+ async def test_the_new_name_follows_the_users_template_and_remove_words(ctx):
61
+ await say(ctx, OWNER, "/template {title} - E{episode}")
62
+ await say(ctx, OWNER, "/removewords @Anime_X_Universe")
63
+ reply = document_message("1147 - One Piece [Dual] 720p @Anime_X_Universe.mkv")
64
+ ev = await say(ctx, OWNER, "/check", reply=reply)
65
+ assert "<code>One Piece - E1147.mkv</code>" in ev.texts[0]
66
+ assert detail(ev.texts[0], "Fɪʟᴇɴᴀᴍᴇ", "Episode") == "1147" and detail(ev.texts[0], "Fɪʟᴇɴᴀᴍᴇ", "Title") == "One Piece"
67
+
68
+
69
+ async def test_values_are_escaped(ctx):
70
+ ev = await say(ctx, OWNER, "/check", reply=document_message("Fate <b>Zero</b> & Co - 03.mkv"))
71
+ assert "<b>Zero</b>" not in ev.texts[0].split("Fɪʟᴇɴᴀᴍᴇ")[1] and "&lt;b&gt;" in ev.texts[0]
72
+
73
+
74
+ async def test_check_without_a_file_explains_itself(ctx):
75
+ for kwargs in ({}, {"reply": FakeMessage("just text")}, {"reply": FakeMessage("", photo=True)}):
76
+ ev = await say(ctx, OWNER, "/check", **kwargs)
77
+ assert "Reply to a file with /check" in ev.texts[0] and "Fɪʟᴇɴᴀᴍᴇ" not in ev.texts[0]
78
+
79
+
80
+ async def test_check_is_in_the_command_menu(ctx):
81
+ from bot.app import COMMANDS
82
+
83
+ assert "check" in dict(COMMANDS)
tests/test_ethumb.py ADDED
@@ -0,0 +1,209 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """/ethumb: ask for a file or video, send back its thumbnail."""
2
+ import io
3
+ import shutil
4
+ import time
5
+ from pathlib import Path
6
+ from types import SimpleNamespace
7
+
8
+ from PIL import Image
9
+ from telethon import types
10
+
11
+ from bot import app as appmod
12
+
13
+ from .appkit import OWNER, STRANGER, click, data_of, jpeg, say, send_doc
14
+ from .conftest import needs_ffmpeg
15
+ from .tgfakes import FakeEvent, FakeMessage, dispatch, document_message
16
+
17
+ REAL_THUMB = [SimpleNamespace(type="m")] # stands in for a PhotoSize
18
+ STRIPPED = [types.PhotoStrippedSize(type="i", bytes=b"\x01\x14\x28abc")]
19
+
20
+
21
+ def photos_sent(ctx):
22
+ return [(entity, file, kw) for entity, file, kw in ctx.client.files if isinstance(file, io.BytesIO)]
23
+
24
+
25
+ def temp_leftovers(ctx):
26
+ root = Path(ctx.cfg.temp_dir)
27
+ return list(root.glob("ethumb*")) if root.exists() else []
28
+
29
+
30
+ def serve(monkeypatch, media_dir, name="sample.mkv"):
31
+ """Make transfer.download copy a real sample file; returns the list of calls."""
32
+ calls = []
33
+
34
+ async def fake_download(client, message, dest, **kw):
35
+ calls.append(kw)
36
+ shutil.copy(media_dir / name, dest)
37
+
38
+ monkeypatch.setattr(appmod.transfer, "download", fake_download)
39
+ return calls
40
+
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # the flow
44
+ # ---------------------------------------------------------------------------
45
+
46
+
47
+ async def test_ethumb_asks_for_a_file(ctx):
48
+ ev = await say(ctx, OWNER, "/ethumb")
49
+ assert "EXTRACT THUMBNAIL" in ev.texts[0] and "one file at a time" in ev.texts[0] and "/cancel" in ev.texts[0]
50
+ assert data_of(ev.out[0][1]) == ["et:stop"]
51
+ mode = ctx.app.modes[OWNER]
52
+ assert mode.kind == "ethumb" and mode.expires > time.time() + 500
53
+
54
+
55
+ async def test_the_files_own_telegram_thumbnail_is_sent_when_it_has_one(ctx, monkeypatch):
56
+ async def no_download(*a, **kw):
57
+ raise AssertionError("nothing needs downloading when Telegram already has a preview")
58
+
59
+ monkeypatch.setattr(appmod.transfer, "download", no_download)
60
+ await say(ctx, OWNER, "/ethumb")
61
+ ctx.client.thumb_data[60] = jpeg((320, 180))
62
+ ev = await send_doc(ctx, OWNER, "Episode 01.mp4", thumbs=REAL_THUMB, mid=60)
63
+ assert ev.texts == ["🖼 Extracting the thumbnail…"]
64
+ (entity, file, kw), = photos_sent(ctx)
65
+ assert entity == OWNER and file.name == "thumbnail.jpg" and Image.open(io.BytesIO(file.getvalue())).size == (320, 180)
66
+ assert "Thumbnail of <b>Episode 01.mp4</b>" in kw["caption"] and kw["parse_mode"] == "html"
67
+ assert (OWNER, [1001]) in ctx.client.deleted # the "Extracting…" message goes
68
+ assert OWNER not in ctx.app.modes and ctx.client.downloads == [(60, bytes, -1)]
69
+
70
+
71
+ @needs_ffmpeg
72
+ async def test_without_a_telegram_thumbnail_a_frame_is_grabbed_from_the_video(ctx, media_dir, monkeypatch):
73
+ calls = serve(monkeypatch, media_dir)
74
+ await say(ctx, OWNER, "/ethumb")
75
+ await send_doc(ctx, OWNER, "Show - 05.mkv", size=(media_dir / "sample.mkv").stat().st_size, mid=61)
76
+ (_e, file, kw), = photos_sent(ctx)
77
+ img = Image.open(io.BytesIO(file.getvalue()))
78
+ assert img.format == "JPEG" and img.size == (320, 240)
79
+ assert len(calls) == 1 and calls[0]["max_bytes"] == appmod.TRACKS_PROBE_BYTES
80
+ assert "Show - 05.mkv" in kw["caption"] and (OWNER, [1001]) in ctx.client.deleted
81
+ assert temp_leftovers(ctx) == []
82
+
83
+
84
+ @needs_ffmpeg
85
+ async def test_the_whole_file_is_fetched_when_the_start_has_no_picture(ctx, media_dir, monkeypatch):
86
+ calls = []
87
+
88
+ async def fake_download(client, message, dest, **kw):
89
+ calls.append(kw)
90
+ if "max_bytes" in kw:
91
+ Path(dest).write_bytes(b"\x00" * 4096) # an mp4 whose index is at the end
92
+ else:
93
+ shutil.copy(media_dir / "sample.mp4", dest)
94
+
95
+ monkeypatch.setattr(appmod.transfer, "download", fake_download)
96
+ await say(ctx, OWNER, "/ethumb")
97
+ await send_doc(ctx, OWNER, "Movie.mp4", size=40 * 1024 * 1024, mid=62)
98
+ assert len(calls) == 2 and "max_bytes" not in calls[1]
99
+ assert len(photos_sent(ctx)) == 1 and temp_leftovers(ctx) == []
100
+
101
+
102
+ @needs_ffmpeg
103
+ async def test_a_file_with_no_picture_anywhere_gets_an_honest_answer(ctx, monkeypatch):
104
+ async def junk(client, message, dest, **kw):
105
+ Path(dest).write_bytes(b"\x00" * 4096)
106
+
107
+ monkeypatch.setattr(appmod.transfer, "download", junk)
108
+ for size in (2 * 1024 * 1024, 900 * 1024 * 1024): # small / too big to fetch whole
109
+ ctx.client.edited.clear()
110
+ await say(ctx, OWNER, "/ethumb")
111
+ await send_doc(ctx, OWNER, "Broken.mkv", size=size, mid=63)
112
+ assert "couldn't find a thumbnail" in ctx.client.edited[-1][1], size
113
+ assert photos_sent(ctx) == [] and temp_leftovers(ctx) == []
114
+
115
+
116
+ async def test_files_that_cannot_have_a_frame_are_not_downloaded(ctx, monkeypatch):
117
+ async def no_download(*a, **kw):
118
+ raise AssertionError("a zip has no picture to grab")
119
+
120
+ monkeypatch.setattr(appmod.transfer, "download", no_download)
121
+ await say(ctx, OWNER, "/ethumb")
122
+ await send_doc(ctx, OWNER, "archive.zip", size=5 * 1024 * 1024, video=False, mime="application/zip", mid=64)
123
+ assert "couldn't find a thumbnail" in ctx.client.edited[-1][1] and photos_sent(ctx) == []
124
+
125
+
126
+ @needs_ffmpeg
127
+ async def test_the_tiny_blurred_preview_is_only_the_last_resort(ctx, media_dir, monkeypatch):
128
+ serve(monkeypatch, media_dir)
129
+ await say(ctx, OWNER, "/ethumb")
130
+ ctx.client.thumb_data[65] = b"BLURRY-PREVIEW"
131
+ await send_doc(ctx, OWNER, "Show.mkv", size=(media_dir / "sample.mkv").stat().st_size, thumbs=STRIPPED, mid=65)
132
+ (_e, file, _kw), = photos_sent(ctx)
133
+ assert file.getvalue() != b"BLURRY-PREVIEW" and Image.open(io.BytesIO(file.getvalue())).size == (320, 240) # a real frame wins
134
+
135
+ async def junk(client, message, dest, **kw):
136
+ Path(dest).write_bytes(b"\x00" * 4096)
137
+
138
+ monkeypatch.setattr(appmod.transfer, "download", junk)
139
+ await say(ctx, OWNER, "/ethumb")
140
+ await send_doc(ctx, OWNER, "Show2.mkv", size=2 * 1024 * 1024, thumbs=STRIPPED, mid=65)
141
+ assert photos_sent(ctx)[-1][1].getvalue() == b"BLURRY-PREVIEW" # nothing better could be made
142
+
143
+
144
+ async def test_ethumb_as_a_reply_works_at_once_without_asking(ctx):
145
+ ctx.client.thumb_data[66] = jpeg((320, 180))
146
+ reply = document_message("Episode.mp4", mid=66, thumbs=REAL_THUMB)
147
+ await say(ctx, OWNER, "/ethumb", reply=reply, mid=67)
148
+ assert len(photos_sent(ctx)) == 1 and OWNER not in ctx.app.modes
149
+
150
+
151
+ async def test_it_handles_one_file_per_command(ctx):
152
+ ctx.client.thumb_data[70] = jpeg()
153
+ await say(ctx, OWNER, "/ethumb")
154
+ await send_doc(ctx, OWNER, "One.mp4", thumbs=REAL_THUMB, mid=70)
155
+ extra = await send_doc(ctx, OWNER, "Two.mp4", thumbs=REAL_THUMB, mid=71) # right after: a hint, not an auto-rename
156
+ assert "/ethumb handles one file at a time" in extra.texts[0] and ctx.app.queue.waiting == 0
157
+ assert (await send_doc(ctx, OWNER, "Three.mp4", thumbs=REAL_THUMB, mid=72)).out == [] # further ones: silence
158
+ assert len(photos_sent(ctx)) == 1
159
+ ctx.app._recent[OWNER][1] -= appmod.ONE_SHOT_GRACE + 1
160
+ later = await send_doc(ctx, OWNER, "Four.mp4", mid=73)
161
+ assert "Queued" in later.texts[0] # normal auto-rename is back
162
+
163
+
164
+ async def test_a_photo_while_waiting_still_sets_the_thumbnail_as_usual(ctx):
165
+ await say(ctx, OWNER, "/ethumb")
166
+ await dispatch(ctx.client, FakeEvent(OWNER, message=FakeMessage("", photo=True, mid=80), photo_bytes=jpeg()))
167
+ assert await ctx.db.has_thumb(OWNER) and ctx.app.modes[OWNER].kind == "ethumb"
168
+
169
+
170
+ async def test_cancel_and_the_stop_button_end_it(ctx):
171
+ await say(ctx, OWNER, "/ethumb")
172
+ ev = await say(ctx, OWNER, "/cancel")
173
+ assert "Cancelled" in ev.texts[0] and OWNER not in ctx.app.modes
174
+ ev = await send_doc(ctx, OWNER, "A.mkv", mid=90)
175
+ assert "Queued" in ev.texts[0]
176
+
177
+ await say(ctx, OWNER, "/ethumb")
178
+ cb = await click(ctx, OWNER, "et:stop")
179
+ assert "Stopped" in cb.edits[-1][0] and OWNER not in ctx.app.modes
180
+
181
+
182
+ async def test_it_expires(ctx):
183
+ await say(ctx, OWNER, "/ethumb")
184
+ ctx.app.modes[OWNER].expires = time.time() - 1
185
+ ev = await send_doc(ctx, OWNER, "A.mkv", mid=91)
186
+ assert "Queued" in ev.texts[0]
187
+
188
+
189
+ async def test_strangers_cannot_use_it(ctx):
190
+ ev = await say(ctx, STRANGER, "/ethumb")
191
+ assert ev.out == [] and STRANGER not in ctx.app.modes
192
+
193
+
194
+ async def test_starting_it_ends_a_sequence_and_says_so(ctx):
195
+ await say(ctx, OWNER, "/sequence")
196
+ await send_doc(ctx, OWNER, "Show - 01.mkv", mid=92)
197
+ ev = await say(ctx, OWNER, "/ethumb")
198
+ assert "1 file(s) was discarded" in ev.texts[0] and ctx.app.modes[OWNER].kind == "ethumb"
199
+
200
+
201
+ async def test_a_failing_extraction_is_reported_not_raised(ctx, monkeypatch):
202
+ async def boom(*a, **kw):
203
+ raise RuntimeError("network down")
204
+
205
+ monkeypatch.setattr(appmod.transfer, "download", boom)
206
+ await say(ctx, OWNER, "/ethumb")
207
+ ev = await send_doc(ctx, OWNER, "A.mkv", mid=93)
208
+ assert ev.texts == ["🖼 Extracting the thumbnail…"] and "couldn't find a thumbnail" in ctx.client.edited[-1][1]
209
+ assert temp_leftovers(ctx) == []
tests/test_formats.py ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+
3
+ from bot.db import DEFAULT_FORMAT, DEFAULT_SETTINGS, MAX_FORMATS, Database
4
+ from bot.errors import FormatError
5
+ from bot.formats import NAME_MAX, FormatSettings, apply_format, has_metadata, validate_name
6
+ from bot.settings_ops import apply_setting
7
+
8
+ U, V = 111, 222
9
+
10
+
11
+ @pytest.fixture
12
+ async def db(tmp_path):
13
+ database = Database(f"sqlite+aiosqlite:///{tmp_path}/f.db")
14
+ await database.init()
15
+ yield database
16
+ await database.close()
17
+
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # storage
21
+ # ---------------------------------------------------------------------------
22
+
23
+
24
+ async def test_a_new_format_has_defaults_and_shows_up_in_the_list(db):
25
+ fmt = await db.create_format(U, "One Piece")
26
+ assert fmt["name"] == "One Piece" and fmt["id"] > 0 and fmt["has_thumb"] is False
27
+ assert {k: fmt[k] for k in DEFAULT_FORMAT} == DEFAULT_FORMAT
28
+ await db.create_format(U, "Dragon Ball", template="DB - E{episode}")
29
+ listed = await db.list_formats(U)
30
+ assert [f["name"] for f in listed] == ["One Piece", "Dragon Ball"] # creation order
31
+ assert listed[1]["template"] == "DB - E{episode}"
32
+ assert await db.count_formats(U) == 2 and await db.count_formats(V) == 0
33
+ assert (await db.get_format(U, fmt["id"]))["name"] == "One Piece"
34
+
35
+
36
+ async def test_names_are_unique_per_user_ignoring_case(db):
37
+ await db.create_format(U, "One Piece")
38
+ with pytest.raises(FormatError) as exc:
39
+ await db.create_format(U, "one piece")
40
+ assert exc.value.code == "taken"
41
+ await db.create_format(V, "One Piece") # someone else may use the same name
42
+
43
+
44
+ async def test_a_user_can_save_twelve_formats_and_no_more(db):
45
+ for i in range(MAX_FORMATS):
46
+ await db.create_format(U, f"Format {i}")
47
+ with pytest.raises(FormatError) as exc:
48
+ await db.create_format(U, "One too many")
49
+ assert exc.value.code == "limit"
50
+ first = (await db.list_formats(U))[0]
51
+ assert await db.delete_format(U, first["id"])
52
+ await db.create_format(U, "One too many") # a slot is free again
53
+ assert await db.count_formats(U) == MAX_FORMATS
54
+
55
+
56
+ async def test_update_changes_fields_and_merges_meta(db):
57
+ fmt = await db.create_format(U, "One Piece")
58
+ out = await db.update_format(U, fmt["id"], template="One Piece - E{episode}", keep_audio=["japanese", "english"])
59
+ assert out["template"] == "One Piece - E{episode}" and out["keep_audio"] == ["japanese", "english"]
60
+ await db.update_format(U, fmt["id"], meta={"title": "One Piece"})
61
+ out = await db.update_format(U, fmt["id"], meta={"author": "@Me"}, keep_subtitle=["english"])
62
+ assert out["meta"]["title"] == "One Piece" and out["meta"]["author"] == "@Me" and out["meta"]["artist"] == ""
63
+ assert out["keep_subtitle"] == ["english"]
64
+ with pytest.raises(KeyError):
65
+ await db.update_format(U, fmt["id"], bogus=1)
66
+
67
+
68
+ async def test_formats_persist_across_database_instances(db):
69
+ fmt = await db.create_format(U, "One Piece", template="X {title}")
70
+ await db.update_format(U, fmt["id"], meta={"video": "V"}, keep_audio=["2", "1"])
71
+ await db.set_format_thumb(U, fmt["id"], b"jpegbytes")
72
+ other = Database(db.url)
73
+ got = await other.get_format(U, fmt["id"])
74
+ assert got["template"] == "X {title}" and got["meta"]["video"] == "V" and got["keep_audio"] == ["2", "1"] and got["has_thumb"]
75
+ assert await other.get_format_thumb(U, fmt["id"]) == b"jpegbytes"
76
+ await other.close()
77
+
78
+
79
+ async def test_renaming_a_format_checks_the_other_names_only(db):
80
+ a = await db.create_format(U, "One Piece")
81
+ await db.create_format(U, "Dragon Ball")
82
+ assert (await db.update_format(U, a["id"], name="ONE PIECE"))["name"] == "ONE PIECE" # its own name, new case
83
+ with pytest.raises(FormatError):
84
+ await db.update_format(U, a["id"], name="dragon ball")
85
+ assert (await db.get_format(U, a["id"]))["name"] == "ONE PIECE"
86
+
87
+
88
+ async def test_thumbnails_belong_to_one_format(db):
89
+ a = await db.create_format(U, "A")
90
+ b = await db.create_format(U, "B")
91
+ assert await db.get_format_thumb(U, a["id"]) is None
92
+ assert await db.set_format_thumb(U, a["id"], b"A-thumb")
93
+ assert [f["has_thumb"] for f in await db.list_formats(U)] == [True, False]
94
+ assert await db.get_format_thumb(U, b["id"]) is None
95
+ assert await db.set_format_thumb(U, a["id"], None)
96
+ assert (await db.get_format(U, a["id"]))["has_thumb"] is False
97
+
98
+
99
+ async def test_nobody_can_touch_another_users_format(db):
100
+ fmt = await db.create_format(U, "Mine")
101
+ assert await db.get_format(V, fmt["id"]) is None
102
+ assert await db.update_format(V, fmt["id"], template="x") is None
103
+ assert await db.set_format_thumb(V, fmt["id"], b"x") is False
104
+ assert await db.get_format_thumb(V, fmt["id"]) is None
105
+ assert await db.delete_format(V, fmt["id"]) is False
106
+ assert (await db.get_format(U, fmt["id"]))["name"] == "Mine"
107
+
108
+
109
+ async def test_delete_and_missing_ids(db):
110
+ fmt = await db.create_format(U, "Gone")
111
+ assert await db.delete_format(U, fmt["id"]) is True
112
+ assert await db.delete_format(U, fmt["id"]) is False
113
+ assert await db.get_format(U, fmt["id"]) is None and await db.update_format(U, fmt["id"], template="x") is None
114
+
115
+
116
+ # ---------------------------------------------------------------------------
117
+ # helpers
118
+ # ---------------------------------------------------------------------------
119
+
120
+
121
+ def test_validate_name():
122
+ assert validate_name(" One Piece ") == (True, "One Piece")
123
+ assert validate_name("")[0] is False and validate_name(" ")[0] is False and validate_name(None)[0] is False
124
+ ok, msg = validate_name("x" * (NAME_MAX + 1))
125
+ assert not ok and str(NAME_MAX) in msg
126
+ assert validate_name("x" * NAME_MAX)[0] is True
127
+ assert validate_name("one\npiece") == (True, "one piece")
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"])
142
+ out = apply_format(settings, fmt)
143
+ assert out["template"] == "One Piece - E{episode}"
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():
154
+ assert not has_metadata(fmt_with())
155
+ settings = dict(DEFAULT_SETTINGS, metadata_on=True) # global metadata on, but the format sets nothing
156
+ assert apply_format(settings, fmt_with())["metadata_on"] is False
157
+ assert has_metadata(fmt_with(keep_audio=["1"])) and has_metadata(fmt_with(meta=dict(DEFAULT_FORMAT["meta"], video="v")))
158
+
159
+
160
+ # ---------------------------------------------------------------------------
161
+ # editing a format through the shared validation
162
+ # ---------------------------------------------------------------------------
163
+
164
+
165
+ async def test_settings_ops_edit_a_format_with_the_same_rules_as_the_global_settings(db):
166
+ fmt = await db.create_format(U, "One Piece")
167
+ store = FormatSettings(db, fmt["id"])
168
+
169
+ ok, msg = await apply_setting(store, U, "template", "One Piece - E{episode}")
170
+ assert ok and "Template saved" in msg and "One Piece - E" in msg
171
+ ok, msg = await apply_setting(store, U, "mt_title", "One Piece")
172
+ assert ok and "Metadata is Off" not in msg
173
+ ok, _ = await apply_setting(store, U, "mt_audio", "{lang} Audio")
174
+ assert ok
175
+ ok, msg = await apply_setting(store, U, "keep_audio", "Japanese English")
176
+ assert ok and "turned it On" not in msg
177
+ ok, _ = await apply_setting(store, U, "keep_subtitle", "2 1")
178
+ assert ok
179
+
180
+ got = await db.get_format(U, fmt["id"])
181
+ assert got["template"] == "One Piece - E{episode}"
182
+ assert got["meta"]["title"] == "One Piece" and got["meta"]["audio"] == "{lang} Audio"
183
+ assert got["keep_audio"] == ["japanese", "english"] and got["keep_subtitle"] == ["2", "1"]
184
+ # the user's own settings were not touched
185
+ assert await db.get_settings(U) == DEFAULT_SETTINGS
186
+
187
+ ok, msg = await apply_setting(store, U, "keep_audio", "off")
188
+ assert ok and "cleared" in msg and (await db.get_format(U, fmt["id"]))["keep_audio"] == []
189
+ ok, msg = await apply_setting(store, U, "mt_title", "off")
190
+ assert ok and (await db.get_format(U, fmt["id"]))["meta"]["title"] == ""
191
+ ok, msg = await apply_setting(store, U, "keep_audio", "bad!word")
192
+ assert not ok and "❌" in msg
193
+
194
+
195
+ async def test_editing_a_deleted_format_raises_lookup_error(db):
196
+ fmt = await db.create_format(U, "Gone")
197
+ store = FormatSettings(db, fmt["id"])
198
+ await db.delete_format(U, fmt["id"])
199
+ with pytest.raises(LookupError):
200
+ await apply_setting(store, U, "template", "x {title}")
201
+ with pytest.raises(LookupError):
202
+ await store.get_settings(U)
tests/test_formats_app.py ADDED
@@ -0,0 +1,436 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """/formats (the manager) and /rename (pick a format, send one file)."""
2
+ import asyncio
3
+ import time
4
+
5
+ from bot import app as appmod
6
+ from bot import panels
7
+ from bot.db import MAX_FORMATS
8
+
9
+ from .appkit import ADMIN_CFG, EPISODE, OWNER, click, data_of, jpeg, say, send_doc
10
+ from .tgfakes import FakeEvent, FakeMessage, dispatch, document_message
11
+
12
+ ONE_PIECE = "1147 - One Piece [Dual] 720p @Anime_X_Universe.mkv"
13
+
14
+
15
+ async def make_format(ctx, name="One Piece", uid=OWNER, **changes):
16
+ fmt = await ctx.db.create_format(uid, name, template="One Piece - E{episode}")
17
+ if changes:
18
+ fmt = await ctx.db.update_format(uid, fmt["id"], **changes)
19
+ return fmt
20
+
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # the manager
24
+ # ---------------------------------------------------------------------------
25
+
26
+
27
+ async def test_formats_command_lists_the_formats_and_offers_a_new_one(ctx):
28
+ ev = await say(ctx, OWNER, "/formats")
29
+ assert "FORMATS" in ev.texts[0] and "(0/12)" in ev.texts[0] and "no formats yet" in ev.texts[0]
30
+ assert "fm:new" in data_of(ev.out[0][1])
31
+
32
+ a, b = await make_format(ctx, "One Piece"), await make_format(ctx, "Dragon Ball")
33
+ ev = await say(ctx, OWNER, "/formats")
34
+ data = data_of(ev.out[0][1])
35
+ assert "(2/12)" in ev.texts[0] and f"fm:v:{a['id']}" in data and f"fm:v:{b['id']}" in data and "fm:new" in data
36
+ labels = [label for row in ev.out[0][1] for label in [b.text for b in row]]
37
+ assert "One Piece" in labels and "Dragon Ball" in labels
38
+
39
+
40
+ async def test_formats_are_reachable_from_the_home_menu(ctx):
41
+ ev = await say(ctx, OWNER, "/start")
42
+ assert "m:formats" in data_of(ev.out[0][1])
43
+ cb = await click(ctx, OWNER, "m:formats")
44
+ assert "FORMATS" in cb.edits[-1][0] and cb.answers
45
+
46
+
47
+ async def test_creating_a_format_asks_for_a_name_and_opens_it(ctx):
48
+ await say(ctx, OWNER, "/template Mine - {title}")
49
+ cb = await click(ctx, OWNER, "fm:new", msg_id=500)
50
+ p = ctx.app.pending[OWNER]
51
+ assert p.kind == "fmt:new" and p.fid == 0 and p.msg_id == 500
52
+ assert cb.edits[-1][0] == panels.FORMAT_PROMPTS["new"] and data_of(cb.edits[-1][1]) == ["m:formats"]
53
+
54
+ ev = await say(ctx, OWNER, " One Piece ", mid=60)
55
+ assert ev.deleted and OWNER not in ctx.app.pending
56
+ (fmt,) = await ctx.db.list_formats(OWNER)
57
+ assert fmt["name"] == "One Piece" and fmt["template"] == "Mine - {title}" # starts from the user's current template
58
+ (chat, text, kw), = ctx.client.edited
59
+ assert kw["message"] == 500 and "created" in text and "🗂 One Piece" in text and f"fm:in:{fmt['id']}:template" in data_of(kw["buttons"])
60
+
61
+
62
+ async def test_a_bad_or_taken_name_keeps_the_prompt_open(ctx):
63
+ await make_format(ctx, "One Piece")
64
+ await click(ctx, OWNER, "fm:new")
65
+ for text, expected in (("x" * 31, "too long"), ("one piece", "already have a format")):
66
+ ev = await say(ctx, OWNER, text)
67
+ assert expected in ev.texts[0] and "Try again" in ev.texts[0]
68
+ assert OWNER in ctx.app.pending and await ctx.db.count_formats(OWNER) == 1
69
+ cb = await click(ctx, OWNER, "m:formats") # Cancel
70
+ assert OWNER not in ctx.app.pending and "FORMATS" in cb.edits[-1][0]
71
+
72
+
73
+ async def test_the_twelve_format_limit(ctx):
74
+ for i in range(MAX_FORMATS):
75
+ await make_format(ctx, f"F{i}")
76
+ ev = await say(ctx, OWNER, "/formats")
77
+ assert "all 12 formats" in ev.texts[0] and "fm:new" not in data_of(ev.out[0][1])
78
+ cb = await click(ctx, OWNER, "fm:new")
79
+ assert "already have 12" in cb.alerts[0] and OWNER not in ctx.app.pending
80
+
81
+ # the limit is also enforced when it is reached while the name prompt is open
82
+ await ctx.db.delete_format(OWNER, (await ctx.db.list_formats(OWNER))[0]["id"])
83
+ await click(ctx, OWNER, "fm:new")
84
+ await make_format(ctx, "Sneaked in")
85
+ ev = await say(ctx, OWNER, "Late one")
86
+ assert "already have 12" in ev.texts[0] and OWNER not in ctx.app.pending
87
+
88
+
89
+ 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):
107
+ fmt = await make_format(ctx)
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"),
114
+ "audio": ("{lang} Audio", lambda f: f["meta"]["audio"] == "{lang} Audio"),
115
+ "subtitle": ("{lang}", lambda f: f["meta"]["subtitle"] == "{lang}"),
116
+ "video": ("HD", lambda f: f["meta"]["video"] == "HD"),
117
+ "keep_audio": ("Japanese English", lambda f: f["keep_audio"] == ["japanese", "english"]),
118
+ "keep_subtitle": ("2 1", lambda f: f["keep_subtitle"] == ["2", "1"]),
119
+ }
120
+ for n, (field, (value, check)) in enumerate(edits.items()):
121
+ cb = await click(ctx, OWNER, f"fm:in:{i}:{field}", msg_id=700 + n)
122
+ p = ctx.app.pending[OWNER]
123
+ assert p.kind == f"fmt:{field}" and p.fid == i and p.msg_id == 700 + n
124
+ assert cb.edits[-1][0] == panels.FORMAT_PROMPTS[field] and data_of(cb.edits[-1][1]) == [f"fm:cx:{i}"]
125
+ ev = await say(ctx, OWNER, value)
126
+ assert ev.deleted and OWNER not in ctx.app.pending, field
127
+ assert check(await ctx.db.get_format(OWNER, i)), field
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):
135
+ fmt = await make_format(ctx)
136
+ i = fmt["id"]
137
+ await click(ctx, OWNER, f"fm:in:{i}:keep_audio")
138
+ ev = await say(ctx, OWNER, "bad!word")
139
+ assert "❌" in ev.texts[0] and "Try again" in ev.texts[0] and OWNER in ctx.app.pending
140
+ cb = await click(ctx, OWNER, f"fm:cx:{i}")
141
+ assert OWNER not in ctx.app.pending and "🗂 One Piece" in cb.edits[-1][0]
142
+
143
+
144
+ async def test_clearing_a_field_with_off(ctx):
145
+ fmt = await make_format(ctx, meta={"title": "X"}, keep_audio=["english"])
146
+ await click(ctx, OWNER, f"fm:in:{fmt['id']}:title")
147
+ await say(ctx, OWNER, "off")
148
+ await click(ctx, OWNER, f"fm:in:{fmt['id']}:keep_audio")
149
+ await say(ctx, OWNER, "off")
150
+ got = await ctx.db.get_format(OWNER, fmt["id"])
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")
157
+ await click(ctx, OWNER, f"fm:in:{fmt['id']}:name")
158
+ ev = await say(ctx, OWNER, "dragon ball")
159
+ assert "already have a format" in ev.texts[0] and OWNER in ctx.app.pending
160
+ await say(ctx, OWNER, "Straw Hats")
161
+ assert (await ctx.db.get_format(OWNER, fmt["id"]))["name"] == "Straw Hats"
162
+ assert "🗂 Straw Hats" in ctx.client.edited[-1][1]
163
+
164
+
165
+ async def test_a_format_thumbnail_is_saved_shown_and_deleted(ctx):
166
+ fmt = await make_format(ctx)
167
+ i = fmt["id"]
168
+ cb = await click(ctx, OWNER, f"fm:th:{i}", msg_id=800)
169
+ assert ctx.app.pending[OWNER].kind == "fmt:thumb"
170
+ assert cb.edits[-1][0] == panels.FORMAT_PROMPTS["thumb"]
171
+ ev = await say(ctx, OWNER, "not a photo") # text while a photo is expected
172
+ assert "Send a photo" in ev.texts[0] and OWNER in ctx.app.pending
173
+
174
+ ev = FakeEvent(OWNER, message=FakeMessage("", mid=801, photo=True), photo_bytes=jpeg((900, 600)))
175
+ await dispatch(ctx.client, ev)
176
+ assert OWNER not in ctx.app.pending and ev.out == [] # no "thumbnail saved" for the global one
177
+ stored = await ctx.db.get_format_thumb(OWNER, i)
178
+ assert stored[:2] == b"\xff\xd8" and await ctx.db.get_thumb(OWNER) is None # the format's, not the user's default
179
+ chat, text, kw = ctx.client.edited[-1]
180
+ assert kw["message"] == 800 and "Thumbnail saved" in text and "✅ saved" in text and f"fm:thd:{i}" in data_of(kw["buttons"])
181
+
182
+ cb = await click(ctx, OWNER, f"fm:thd:{i}")
183
+ assert cb.toasts == ["Thumbnail deleted."] and await ctx.db.get_format_thumb(OWNER, i) is None
184
+ assert "your default thumbnail" in cb.edits[-1][0]
185
+
186
+
187
+ async def test_a_photo_without_a_pending_format_prompt_is_still_the_global_thumbnail(ctx):
188
+ await make_format(ctx)
189
+ ev = FakeEvent(OWNER, message=FakeMessage("", mid=900, photo=True), photo_bytes=jpeg())
190
+ await dispatch(ctx.client, ev)
191
+ assert "Thumbnail saved" in ev.texts[0] and (await ctx.db.get_thumb(OWNER)) is not None
192
+
193
+
194
+ async def test_deleting_a_format_asks_first(ctx):
195
+ fmt = await make_format(ctx)
196
+ i = fmt["id"]
197
+ cb = await click(ctx, OWNER, f"fm:del:{i}")
198
+ assert "Delete the format" in cb.edits[-1][0] and set(data_of(cb.edits[-1][1])) == {f"fm:ok:{i}", f"fm:v:{i}"}
199
+ assert await ctx.db.get_format(OWNER, i) is not None
200
+ cb = await click(ctx, OWNER, f"fm:v:{i}") # "Keep it"
201
+ assert "🗂 One Piece" in cb.edits[-1][0]
202
+ cb = await click(ctx, OWNER, f"fm:ok:{i}")
203
+ assert await ctx.db.get_format(OWNER, i) is None
204
+ assert "FORMATS" in cb.edits[-1][0] and cb.toasts == ["Deleted One Piece."]
205
+
206
+
207
+ async def test_buttons_of_a_deleted_format_say_so_and_show_the_list(ctx):
208
+ fmt = await make_format(ctx)
209
+ await ctx.db.delete_format(OWNER, fmt["id"])
210
+ for data in (f"fm:v:{fmt['id']}", f"fm:in:{fmt['id']}:title", f"fm:th:{fmt['id']}", f"fm:del:{fmt['id']}", f"fm:ok:{fmt['id']}"):
211
+ cb = await click(ctx, OWNER, data)
212
+ assert cb.alerts == ["That format no longer exists."] and "FORMATS" in cb.edits[-1][0], data
213
+ assert OWNER not in ctx.app.pending
214
+
215
+
216
+ async def test_typing_into_a_prompt_for_a_deleted_format_is_handled(ctx):
217
+ fmt = await make_format(ctx)
218
+ await click(ctx, OWNER, f"fm:in:{fmt['id']}:title")
219
+ await ctx.db.delete_format(OWNER, fmt["id"])
220
+ ev = await say(ctx, OWNER, "Title")
221
+ assert "no longer exists" in ev.texts[0] and OWNER not in ctx.app.pending
222
+
223
+
224
+ async def test_formats_belong_to_one_user(ctx):
225
+ mine = await make_format(ctx, "Mine")
226
+ cb = await click(ctx, ADMIN_CFG, f"fm:v:{mine['id']}")
227
+ assert cb.alerts == ["That format no longer exists."]
228
+ cb = await click(ctx, ADMIN_CFG, f"fm:ok:{mine['id']}")
229
+ assert await ctx.db.get_format(OWNER, mine["id"]) is not None
230
+ ev = await say(ctx, ADMIN_CFG, "/formats")
231
+ assert "no formats yet" in ev.texts[0]
232
+ ev = await say(ctx, ADMIN_CFG, "/rename")
233
+ assert "no saved formats" in ev.texts[0]
234
+
235
+
236
+ async def test_any_other_tap_abandons_an_open_prompt(ctx):
237
+ fmt = await make_format(ctx)
238
+ await click(ctx, OWNER, f"fm:in:{fmt['id']}:title")
239
+ await click(ctx, OWNER, "fm:new")
240
+ assert ctx.app.pending[OWNER].kind == "fmt:new"
241
+ await click(ctx, OWNER, f"fm:v:{fmt['id']}")
242
+ assert OWNER not in ctx.app.pending
243
+
244
+
245
+ # ---------------------------------------------------------------------------
246
+ # /rename: pick a format, send one file
247
+ # ---------------------------------------------------------------------------
248
+
249
+
250
+ async def test_rename_without_formats_points_to_creating_one(ctx):
251
+ ev = await say(ctx, OWNER, "/rename")
252
+ assert "no saved formats" in ev.texts[0] and "fm:new" in data_of(ev.out[0][1])
253
+ ev = await say(ctx, OWNER, "/rename New Name", reply=FakeMessage("x", mid=3)) # the old reply-with-a-name form is gone
254
+ assert "no saved formats" in ev.texts[0] and not ctx.app.queue.jobs
255
+
256
+
257
+ async def test_rename_offers_the_saved_formats_by_name(ctx):
258
+ a, b = await make_format(ctx, "One Piece"), await make_format(ctx, "Dragon Ball")
259
+ ev = await say(ctx, OWNER, "/rename")
260
+ assert "Choose the format" in ev.texts[0]
261
+ data = data_of(ev.out[0][1])
262
+ assert f"rn:p:{a['id']}" in data and f"rn:p:{b['id']}" in data and "m:formats" in data
263
+ labels = [btn.text for row in ev.out[0][1] for btn in row]
264
+ assert "One Piece" in labels and "Dragon Ball" in labels
265
+ assert not ctx.app.queue.jobs and ctx.app._mode(OWNER) is None # nothing is armed until a format is chosen
266
+
267
+
268
+ async def test_choosing_a_format_then_sending_a_file_renames_it_with_that_format(ctx):
269
+ await ctx.db.update_settings(OWNER, template="DEFAULT {title}")
270
+ fmt = await make_format(ctx, meta={"title": "One Piece"})
271
+ cb = await click(ctx, OWNER, f"rn:p:{fmt['id']}")
272
+ assert cb.toasts == ["Format: One Piece"] and ctx.app._mode(OWNER, "rename") is not None
273
+ text, buttons = cb.edits[-1]
274
+ assert "One Piece" in text and "one file at a time" in text and "/rename again" in text
275
+ assert set(data_of(buttons)) == {"rn:menu", "rn:stop"}
276
+
277
+ ev = await send_doc(ctx, OWNER, ONE_PIECE, mid=50)
278
+ (job,) = ctx.app.queue.jobs.values()
279
+ assert job.new_name == "One Piece - E1147.mkv" # the format's template, not the default one
280
+ assert job.fmt["id"] == fmt["id"] and job.fmt["meta"]["title"] == "One Piece" and job.order == 50
281
+ assert "Queued" in ev.texts[0] and "🗂 Format: <b>One Piece</b>" in ev.texts[0] and "One Piece - E1147.mkv" in ev.texts[0]
282
+ assert ctx.app._mode(OWNER) is None # it was for one file
283
+
284
+
285
+ async def test_other_settings_still_come_from_the_users_own_settings(ctx):
286
+ await ctx.db.update_settings(OWNER, mode="caption", remove_words=["@Junk"])
287
+ fmt = await make_format(ctx)
288
+ await click(ctx, OWNER, f"rn:p:{fmt['id']}")
289
+ await send_doc(ctx, OWNER, "xyz.mkv", caption="1148 - One Piece 720p @Junk", mid=51)
290
+ (job,) = ctx.app.queue.jobs.values()
291
+ assert job.new_name == "One Piece - E1148.mkv" # read from the caption (the user's mode)
292
+
293
+
294
+ async def test_the_next_file_needs_rename_again(ctx):
295
+ fmt = await make_format(ctx)
296
+ await click(ctx, OWNER, f"rn:p:{fmt['id']}")
297
+ await send_doc(ctx, OWNER, ONE_PIECE, mid=50)
298
+ # a second file straight after is not auto-renamed behind the user's back: one hint, then silence
299
+ ev = await send_doc(ctx, OWNER, "1148 - One Piece [Dual] 720p.mkv", mid=51)
300
+ assert "one file at a time" in ev.texts[0] and "/rename again" in ev.texts[0]
301
+ ev = await send_doc(ctx, OWNER, "1149 - One Piece [Dual] 720p.mkv", mid=52)
302
+ assert ev.out == []
303
+ assert [j.file_name for j in ctx.app.queue.jobs.values()] == [ONE_PIECE]
304
+
305
+ # once the short grace period is over, files are renamed with the normal settings as usual
306
+ ctx.app._recent[OWNER][1] -= appmod.ONE_SHOT_GRACE + 1
307
+ ev = await send_doc(ctx, OWNER, "1150 - One Piece [Dual] 720p.mkv", mid=53)
308
+ assert "Queued" in ev.texts[0] and "Format" not in ev.texts[0]
309
+ assert len(ctx.app.queue.jobs) == 2
310
+
311
+ # and choosing a format again renames the next one with it
312
+ await click(ctx, OWNER, f"rn:p:{fmt['id']}")
313
+ ev = await send_doc(ctx, OWNER, "1151 - One Piece [Dual] 720p.mkv", mid=54)
314
+ assert "🗂 Format: <b>One Piece</b>" in ev.texts[0] and len(ctx.app.queue.jobs) == 3
315
+
316
+
317
+ async def test_a_forwarded_batch_gives_the_format_to_exactly_one_file(ctx):
318
+ fmt = await make_format(ctx)
319
+ await click(ctx, OWNER, f"rn:p:{fmt['id']}")
320
+ events = [FakeEvent(OWNER, message=document_message(f"114{n} - One Piece 720p.mkv", 1024, mid=60 + n)) for n in range(5, 9)]
321
+ await asyncio.gather(*(dispatch(ctx.client, ev) for ev in events))
322
+ jobs = list(ctx.app.queue.jobs.values())
323
+ assert len(jobs) == 1 and jobs[0].fmt["id"] == fmt["id"]
324
+ hints = [t for ev in events for t in ev.texts if "one file at a time" in t]
325
+ assert len(hints) == 1 # one hint for the whole batch, not one per file
326
+
327
+
328
+ async def test_the_chosen_format_is_forgotten_after_the_wait(ctx):
329
+ fmt = await make_format(ctx)
330
+ await click(ctx, OWNER, f"rn:p:{fmt['id']}")
331
+ ctx.app.modes[OWNER].expires = time.time() - 1
332
+ ev = await send_doc(ctx, OWNER, ONE_PIECE, mid=50)
333
+ (job,) = ctx.app.queue.jobs.values()
334
+ assert job.fmt is None and "Format" not in ev.texts[0] # a normal auto-rename
335
+ assert ctx.app._mode(OWNER) is None
336
+
337
+
338
+ async def test_a_format_deleted_after_choosing_it_is_reported_and_nothing_is_queued(ctx):
339
+ fmt = await make_format(ctx)
340
+ await click(ctx, OWNER, f"rn:p:{fmt['id']}")
341
+ await ctx.db.delete_format(OWNER, fmt["id"])
342
+ ev = await send_doc(ctx, OWNER, ONE_PIECE, mid=50)
343
+ assert "format was deleted" in ev.texts[0] and not ctx.app.queue.jobs and ctx.app._mode(OWNER) is None
344
+
345
+
346
+ async def test_a_template_variable_missing_from_the_file_is_flagged_for_the_format_too(ctx):
347
+ fmt = await make_format(ctx)
348
+ await click(ctx, OWNER, f"rn:p:{fmt['id']}")
349
+ ev = await send_doc(ctx, OWNER, "Plain Movie.mkv", mid=50)
350
+ (job,) = ctx.app.queue.jobs.values()
351
+ assert job.new_name == "One Piece - E.mkv" and "⚠️" in ev.texts[0] and "{episode}" in ev.texts[0]
352
+
353
+
354
+ async def test_choosing_a_format_that_is_gone_or_not_yours(ctx):
355
+ fmt = await make_format(ctx)
356
+ cb = await click(ctx, ADMIN_CFG, f"rn:p:{fmt['id']}") # somebody else's
357
+ assert cb.alerts == ["That format no longer exists."] and ctx.app._mode(ADMIN_CFG) is None
358
+ await ctx.db.delete_format(OWNER, fmt["id"])
359
+ cb = await click(ctx, OWNER, f"rn:p:{fmt['id']}")
360
+ assert cb.alerts == ["That format no longer exists."] and "no saved formats" in cb.edits[-1][0]
361
+
362
+
363
+ async def test_one_users_chosen_format_does_not_affect_another(ctx):
364
+ fmt = await make_format(ctx)
365
+ await click(ctx, OWNER, f"rn:p:{fmt['id']}")
366
+ await send_doc(ctx, ADMIN_CFG, EPISODE, mid=70)
367
+ (job,) = ctx.app.queue.jobs.values()
368
+ assert job.user_id == ADMIN_CFG and job.fmt is None
369
+ assert ctx.app._mode(OWNER, "rename") is not None # still waiting for the owner's file
370
+
371
+
372
+ async def test_change_format_and_stop_buttons(ctx):
373
+ a, b = await make_format(ctx, "One Piece"), await make_format(ctx, "Dragon Ball")
374
+ await click(ctx, OWNER, f"rn:p:{a['id']}")
375
+ cb = await click(ctx, OWNER, "rn:menu")
376
+ assert "Choose the format" in cb.edits[-1][0] and ctx.app._mode(OWNER) is None
377
+ await click(ctx, OWNER, f"rn:p:{b['id']}")
378
+ assert ctx.app.modes[OWNER].data["fid"] == b["id"]
379
+ cb = await click(ctx, OWNER, "rn:stop")
380
+ assert "Stopped" in cb.edits[-1][0] and ctx.app._mode(OWNER) is None
381
+ ev = await send_doc(ctx, OWNER, ONE_PIECE, mid=50)
382
+ (job,) = ctx.app.queue.jobs.values()
383
+ assert job.fmt is None and "Format" not in ev.texts[0]
384
+ assert not ctx.app._recent # stopping is not a finished one-file command: no hint for later files
385
+
386
+
387
+ async def test_cancel_ends_the_chosen_format_before_it_touches_queued_files(ctx):
388
+ fmt = await make_format(ctx)
389
+ await send_doc(ctx, OWNER, EPISODE, mid=40) # something is already queued
390
+ await click(ctx, OWNER, f"rn:p:{fmt['id']}")
391
+ ev = await say(ctx, OWNER, "/cancel")
392
+ assert "Cancelled" in ev.texts[0] and "/rename again" in ev.texts[0] and "/cancel again" in ev.texts[0]
393
+ assert ctx.app._mode(OWNER) is None and len(ctx.app.queue.jobs) == 1
394
+ ev = await say(ctx, OWNER, "/cancel")
395
+ assert "Cancelling 1 job" in ev.texts[0] and not ctx.app.queue.jobs
396
+
397
+
398
+ async def test_cancel_with_nothing_armed_still_cancels_jobs(ctx):
399
+ await send_doc(ctx, OWNER, EPISODE, mid=40)
400
+ ev = await say(ctx, OWNER, "/cancel")
401
+ assert "Cancelling 1 job" in ev.texts[0]
402
+ ev = await say(ctx, OWNER, "/cancel")
403
+ assert "no active jobs" in ev.texts[0]
404
+
405
+
406
+ async def test_start_and_the_rename_picker_close_an_open_text_prompt(ctx):
407
+ fmt = await make_format(ctx)
408
+ await click(ctx, OWNER, f"fm:in:{fmt['id']}:title")
409
+ await say(ctx, OWNER, "/rename")
410
+ assert OWNER not in ctx.app.pending
411
+ await click(ctx, OWNER, f"fm:in:{fmt['id']}:title")
412
+ await say(ctx, OWNER, "/formats")
413
+ assert OWNER not in ctx.app.pending
414
+
415
+
416
+ def test_the_format_screens_fit_in_a_caption_even_with_the_longest_values():
417
+ from .test_panels import CAPTION_LIMIT, visible
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
+ }
425
+ formats = [dict(fmt, id=i, name="F" * 30) for i in range(12)]
426
+ for name, (text, rows) in {
427
+ "view": panels.format_view(fmt), "menu": panels.formats_menu(formats, 12), "delete": panels.format_delete_confirm(fmt),
428
+ "ready": panels.rename_ready(fmt, 10), "picker": panels.rename_picker(formats),
429
+ **{f"prompt_{k}": panels.format_prompt(k, 12) for k in panels.FORMAT_PROMPTS},
430
+ }.items():
431
+ assert len(visible(text)) <= CAPTION_LIMIT, (name, len(visible(text)))
432
+ assert text.count("<b>") == text.count("</b>") and text.count("<code>") == text.count("</code>"), name
433
+ for row in rows:
434
+ assert 1 <= len(row) <= 3, name
435
+ for label, data in row:
436
+ assert label.strip() and len(label) <= 32 and 0 < len(data.encode()) <= 64, (name, label, data)
tests/test_media.py CHANGED
@@ -9,12 +9,16 @@ from bot.media import (
9
  apply_plan,
10
  build_plan,
11
  canonical_lang,
 
12
  format_tracks,
 
13
  make_video_thumbnail,
14
  parse_probe,
 
15
  parse_track_tokens,
16
  prepare_thumb,
17
  probe,
 
18
  resolve_tokens,
19
  validate_track_tokens,
20
  )
@@ -319,3 +323,118 @@ async def test_thumbnail_and_prepare_thumb(media_dir, tmp_path):
319
  img = Image.open(io.BytesIO(jpg))
320
  assert max(img.size) <= 320 and len(jpg) <= 200 * 1024 and img.format == "JPEG"
321
  assert not await make_video_thumbnail(tmp_path / "missing.mkv", tmp_path / "x.jpg", 1.0)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  apply_plan,
10
  build_plan,
11
  canonical_lang,
12
+ extract_frame,
13
  format_tracks,
14
+ frame_seeks,
15
  make_video_thumbnail,
16
  parse_probe,
17
+ parse_size,
18
  parse_track_tokens,
19
  prepare_thumb,
20
  probe,
21
+ resize_image,
22
  resolve_tokens,
23
  validate_track_tokens,
24
  )
 
323
  img = Image.open(io.BytesIO(jpg))
324
  assert max(img.size) <= 320 and len(jpg) <= 200 * 1024 and img.format == "JPEG"
325
  assert not await make_video_thumbnail(tmp_path / "missing.mkv", tmp_path / "x.jpg", 1.0)
326
+
327
+
328
+ # ---------------------------------------------------------------------------
329
+ # /ethumb and /resize helpers
330
+ # ---------------------------------------------------------------------------
331
+
332
+
333
+ def test_frame_seeks_start_at_about_a_tenth_and_fall_back_to_earlier_positions():
334
+ assert frame_seeks(1400) == [60.0, 20.0, 5.0, 0.0]
335
+ assert frame_seeks(100) == [10.0, 5.0, 0.0]
336
+ assert frame_seeks(2) == [0.2, 0.0]
337
+ assert frame_seeks(0) == [1.0, 0.0] # duration unknown (a cut-off file)
338
+ assert frame_seeks(-5) == [1.0, 0.0]
339
+
340
+
341
+ @pytest.mark.parametrize("text,expected", [
342
+ ("320", (320, 320)), (" 320 ", (320, 320)), ("320x180", (320, 180)), ("320X180", (320, 180)), ("320×180", (320, 180)),
343
+ ("320 x 180", (320, 180)), ("320*180", (320, 180)), ("320 180", (320, 180)), ("320,180", (320, 180)),
344
+ ("16x16", (16, 16)), ("4096x4096", (4096, 4096)),
345
+ ("15x300", None), ("5000", None), ("99999x99999", None), ("320x", None), ("x180", None), ("320x180x90", None),
346
+ ("320 px", None), ("-5", None), ("abc", None), ("", None),
347
+ ])
348
+ def test_parse_size(text, expected):
349
+ assert parse_size(text) == expected
350
+
351
+
352
+ def _png(size, color=(10, 120, 200), mode="RGB"):
353
+ import io
354
+ from PIL import Image
355
+
356
+ buf = io.BytesIO()
357
+ Image.new(mode, size, color).save(buf, "PNG")
358
+ return buf.getvalue()
359
+
360
+
361
+ def test_resize_image_fits_inside_the_box_and_keeps_the_shape():
362
+ import io
363
+ from PIL import Image
364
+
365
+ data, original, new = resize_image(_png((1920, 1080)), 320, 320)
366
+ assert original == (1920, 1080) and new == (320, 180)
367
+ img = Image.open(io.BytesIO(data))
368
+ assert img.format == "JPEG" and img.size == new
369
+ assert resize_image(_png((1920, 1080)), 100, 400)[2] == (100, 56)
370
+ assert resize_image(_png((1000, 2000)), 500, 500)[2] == (250, 500)
371
+ assert resize_image(_png((100, 50)), 400, 400)[2] == (400, 200) # small images are scaled up to the size asked for
372
+
373
+
374
+ def test_a_thumbnail_sized_result_stays_under_200_kb_even_for_noisy_images():
375
+ import io
376
+ from PIL import Image
377
+
378
+ noisy = io.BytesIO()
379
+ Image.effect_noise((2000, 2000), 90).convert("RGB").save(noisy, "PNG")
380
+ data, _original, new = resize_image(noisy.getvalue(), 320, 320)
381
+ assert max(new) == 320 and len(data) <= 200 * 1024
382
+ big, _o, bigsize = resize_image(noisy.getvalue(), 1280, 1280) # larger results are not squeezed
383
+ assert max(bigsize) == 1280 and len(big) > 200 * 1024
384
+
385
+
386
+ def test_transparent_images_get_a_white_background_not_a_black_one():
387
+ import io
388
+ from PIL import Image
389
+
390
+ data, _o, _n = resize_image(_png((200, 200), (0, 0, 0, 0), mode="RGBA"), 100, 100)
391
+ pixel = Image.open(io.BytesIO(data)).getpixel((50, 50))
392
+ assert min(pixel) > 240
393
+
394
+
395
+ def test_exif_orientation_is_applied():
396
+ import io
397
+ from PIL import Image
398
+
399
+ img = Image.new("RGB", (200, 100), (200, 0, 0))
400
+ exif = img.getexif()
401
+ exif[274] = 6 # rotate 90 degrees clockwise when displayed
402
+ buf = io.BytesIO()
403
+ img.save(buf, "JPEG", exif=exif)
404
+ _data, original, new = resize_image(buf.getvalue(), 100, 100)
405
+ assert original == (100, 200) and new == (50, 100)
406
+
407
+
408
+ def test_resize_image_rejects_what_is_not_an_image():
409
+ with pytest.raises(Exception):
410
+ resize_image(b"definitely not an image", 100, 100)
411
+
412
+
413
+ @needs_ffmpeg
414
+ async def test_extract_frame_writes_a_jpeg_and_tries_the_next_position_after_a_miss(media_dir, tmp_path):
415
+ out = tmp_path / "f.jpg"
416
+ assert await extract_frame(media_dir / "sample.mkv", out, [1.0])
417
+ assert out.read_bytes()[:2] == b"\xff\xd8"
418
+ from PIL import Image
419
+
420
+ assert Image.open(out).size == (320, 240) # the sample is smaller than the 1280 px limit
421
+ out.unlink()
422
+ assert await extract_frame(media_dir / "sample.mkv", out, [500.0, 0.0]) # nothing at 500 s: the next position works
423
+ assert out.stat().st_size > 0
424
+
425
+
426
+ @needs_ffmpeg
427
+ async def test_extract_frame_scales_down_wide_pictures(media_dir, tmp_path):
428
+ from PIL import Image
429
+
430
+ out = tmp_path / "small.jpg"
431
+ assert await extract_frame(media_dir / "sample.mkv", out, [0.0], width=160)
432
+ assert Image.open(out).size == (160, 120)
433
+
434
+
435
+ @needs_ffmpeg
436
+ async def test_extract_frame_gives_up_on_junk_without_raising(tmp_path):
437
+ junk = tmp_path / "junk.mkv"
438
+ junk.write_bytes(b"\x00" * 4096)
439
+ assert not await extract_frame(junk, tmp_path / "j.jpg", [1.0, 0.0])
440
+ assert not await extract_frame(tmp_path / "missing.mkv", tmp_path / "m.jpg", [0.0])
tests/test_mediainfo.py ADDED
@@ -0,0 +1,383 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """/mediainfo: reading a file and laying the report out."""
2
+ import pytest
3
+
4
+ from bot import mediainfo as mi
5
+ from bot import telegraph
6
+
7
+ from .conftest import needs_ffmpeg
8
+
9
+ TEXT = """General
10
+ Complete name : /tmp/work/minfo1_2/probe.mkv
11
+ Format : Matroska
12
+ Conformance errors : 2
13
+ 0x8538067 : Yes
14
+ General compliance : Element size 1 is more than maximal permitted size 2 (offset 0x34)
15
+ Matroska : Yes
16
+ CRC_Error_Pos : 943
17
+ Writing library : Lavf
18
+
19
+ Video
20
+ ID : 1
21
+ Format : AVC
22
+ Title : \x07bell\x1b[0m here
23
+
24
+ Menu
25
+ 00:00:00.000 : Intro
26
+ Plain line without a separator
27
+ Empty value :
28
+ """
29
+
30
+ FFPROBE = {
31
+ "format": {
32
+ "format_long_name": "Matroska / WebM", "duration": "1441.419",
33
+ "tags": {"title": "Show", "ENCODER": "libebml", "creation_time": "2026-01-01T00:00:00Z", "WEIRD_KEY": "x"},
34
+ },
35
+ "streams": [
36
+ {"index": 0, "codec_type": "video", "codec_name": "hevc", "codec_long_name": "H.265 / HEVC", "profile": "Main 10",
37
+ "level": 123, "width": 1920, "height": 1080, "avg_frame_rate": "24000/1001", "pix_fmt": "yuv420p10le",
38
+ "bits_per_raw_sample": "10", "display_aspect_ratio": "16:9", "color_space": "bt709",
39
+ "disposition": {"default": 1}, "tags": {"BPS": "3200000", "DURATION": "00:24:01.419000000"}},
40
+ {"index": 1, "codec_type": "audio", "codec_name": "aac", "profile": "LC", "channels": 2, "channel_layout": "stereo",
41
+ "sample_rate": "48000", "disposition": {"default": 1, "forced": 0}, "tags": {"language": "jpn", "title": "Japanese"}},
42
+ {"index": 2, "codec_type": "audio", "codec_name": "ac3", "channels": 1, "sample_rate": "44100", "tags": {"language": "hin"}},
43
+ {"index": 3, "codec_type": "subtitle", "codec_name": "ass", "disposition": {"forced": 1}, "tags": {"language": "eng"}},
44
+ {"index": 4, "codec_type": "attachment", "codec_name": "ttf", "tags": {"filename": "font.ttf"}},
45
+ ],
46
+ "chapters": [{"start_time": "0.000000", "tags": {"title": "Intro"}}, {"start_time": "90.5", "tags": {"title": "Main"}}],
47
+ }
48
+
49
+
50
+ def lines_of(report, title):
51
+ return dict(report.section(title).lines)
52
+
53
+
54
+ def report_of(*sections, **kw):
55
+ return mi.Report(sections=list(sections), engine=kw.pop("engine", "MediaInfo"), **kw)
56
+
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # MediaInfo's number formats
60
+ # ---------------------------------------------------------------------------
61
+
62
+
63
+ @pytest.mark.parametrize("num, text", [
64
+ (0, "0 Bytes"), (999, "999 Bytes"), (1024, "1.00 KiB"), (71 * 1024, "71.0 KiB"), (93550, "91.4 KiB"),
65
+ (845 * 1024 ** 2, "845 MiB"), (int(1.46 * 1024 ** 3), "1.46 GiB"), (int(1023.9 * 1024), "1.00 MiB"), (5 * 1024 ** 4, "5.00 TiB"),
66
+ ])
67
+ def test_sizes_are_written_like_mediainfo_does(num, text):
68
+ assert mi.mi_size(num) == text
69
+
70
+
71
+ @pytest.mark.parametrize("bps, text", [
72
+ (62, "62 b/s"), (370_000, "370 kb/s"), (1_936_000, "1 936 kb/s"), (9_999_000, "9 999 kb/s"),
73
+ (12_340_000, "12.3 Mb/s"), (150_000_000, "150 Mb/s"),
74
+ ])
75
+ def test_bit_rates_are_written_like_mediainfo_does(bps, text):
76
+ assert mi.mi_rate(bps) == text
77
+
78
+
79
+ @pytest.mark.parametrize("ms, text", [
80
+ (2023, "2 s 23 ms"), (1_441_000, "24 min 1 s"), (5_040_000, "1 h 24 min"), (0, "0 s 0 ms"), (59_999.6, "1 min 0 s"),
81
+ ])
82
+ def test_durations_are_written_like_mediainfo_does(ms, text):
83
+ assert mi.mi_duration(ms) == text
84
+
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # reading MediaInfo's text
88
+ # ---------------------------------------------------------------------------
89
+
90
+
91
+ def test_the_text_is_split_into_sections_of_labels_and_values():
92
+ sections = mi.parse_text(TEXT)
93
+ assert [s.title for s in sections] == ["General", "Video", "Menu"]
94
+ general = sections[0].lines
95
+ assert general[0] == ("Complete name", "/tmp/work/minfo1_2/probe.mkv")
96
+ assert (" 0x8538067", "Yes") in general and (" General compliance", general[4][1]) == general[4] # nesting is kept
97
+ video = dict(sections[1].lines)
98
+ assert video["Title"] == "bell[0m here" # control characters are gone
99
+ menu = sections[2].lines
100
+ assert menu[0] == ("00:00:00.000", "Intro") and ("", "Plain line without a separator") in menu and ("Empty value", "") in menu
101
+
102
+
103
+ def test_windows_line_endings_and_blank_runs_are_fine():
104
+ sections = mi.parse_text("General\r\nFormat : MPEG-4\r\n\r\n\r\nAudio\r\nFormat : AAC\r\n")
105
+ assert [(s.title, s.lines) for s in sections] == [("General", [("Format", "MPEG-4")]), ("Audio", [("Format", "AAC")])]
106
+
107
+
108
+ # ---------------------------------------------------------------------------
109
+ # the General section of a file we only have the start of
110
+ # ---------------------------------------------------------------------------
111
+
112
+
113
+ def test_a_cut_short_file_gets_its_real_name_and_size_and_no_false_alarms():
114
+ report = report_of(*mi.parse_text(TEXT), truncated=True)
115
+ mi.fix_general(report, "Show 01.mkv", 700 * 1024 ** 2, 1_440_000.0)
116
+ general = report.section("General")
117
+ assert [label for label, _ in general.lines] == ["Complete name", "Format", "Writing library", "File size", "Overall bit rate"]
118
+ values = dict(general.lines)
119
+ assert values["Complete name"] == "Show 01.mkv" and values["File size"] == "700 MiB"
120
+ assert values["Overall bit rate"] == "4 078 kb/s" # 700 MiB over 24 minutes
121
+
122
+
123
+ def test_a_cut_short_file_with_no_duration_loses_a_bit_rate_that_would_be_wrong():
124
+ report = report_of(*mi.parse_text("General\nFormat : X\nOverall bit rate : 11.9 kb/s\nOverall bit rate mode : Constant\n"), truncated=True)
125
+ mi.fix_general(report, "a.bin", 5000, None)
126
+ assert [label for label, _ in report.section("General").lines] == ["Complete name", "Format", "Overall bit rate mode", "File size"]
127
+
128
+
129
+ def test_a_whole_file_keeps_everything_mediainfo_says_about_it():
130
+ report = report_of(*mi.parse_text(TEXT))
131
+ mi.fix_general(report, "x.mkv", 1000, 1000.0)
132
+ labels = [label for label, _ in report.section("General").lines]
133
+ assert "Conformance errors" in labels and "CRC_Error_Pos" in labels and " 0x8538067" in labels # real problems stay visible
134
+ assert dict(report.section("General").lines)["File size"] == "1000 Bytes" and "Overall bit rate" not in labels
135
+
136
+
137
+ def test_a_report_with_no_general_section_gets_one():
138
+ report = report_of(truncated=False, engine="")
139
+ mi.fix_general(report, "mystery.bin", 2048, None)
140
+ assert report.sections[0].title == "General" and report.sections[0].lines == [("Complete name", "mystery.bin"), ("File size", "2.00 KiB")]
141
+
142
+
143
+ # ---------------------------------------------------------------------------
144
+ # the report
145
+ # ---------------------------------------------------------------------------
146
+
147
+
148
+ def test_the_summary_line_counts_the_streams():
149
+ general = mi.Section("General", [("Format", "Matroska"), ("Duration", "24 min 1 s")])
150
+ streams = [mi.Section("Video"), mi.Section("Audio #1"), mi.Section("Audio #2"), mi.Section("Text #1"), mi.Section("Menu")]
151
+ assert report_of(general, *streams).summary() == "Matroska · 24 min 1 s · 1 video · 2 audio · 1 subtitle"
152
+ assert report_of(general).summary() == "Matroska · 24 min 1 s"
153
+ assert report_of().summary() == ""
154
+
155
+
156
+ def test_only_real_streams_count_as_streams():
157
+ assert not report_of(mi.Section("General"), mi.Section("Menu"), mi.Section("Telegram")).has_streams
158
+ assert report_of(mi.Section("General"), mi.Section("Image")).has_streams
159
+ assert report_of(mi.Section("General"), mi.Section("Text #12")).has_streams
160
+
161
+
162
+ def test_the_telegram_section_lists_what_telegram_knows_and_skips_the_rest():
163
+ full = mi.telegram_section("Show 01.mkv", 93550, mime="video/x-matroska", sent_as="Video", sent_on="2026-10-21 09:00:00 UTC",
164
+ dc="4", caption="[HDR] Dual")
165
+ assert full.title == "Telegram" and full.lines == [
166
+ ("File name", "Show 01.mkv"), ("File size", "91.4 KiB (93 550 bytes)"), ("MIME type", "video/x-matroska"),
167
+ ("Sent as", "Video"), ("Sent on", "2026-10-21 09:00:00 UTC"), ("Data center", "4"), ("Caption", "[HDR] Dual"),
168
+ ]
169
+ assert mi.telegram_section("x", 0).lines == [("File name", "x")]
170
+
171
+
172
+ # ---------------------------------------------------------------------------
173
+ # laying a section out
174
+ # ---------------------------------------------------------------------------
175
+
176
+
177
+ def test_values_line_up_after_the_labels():
178
+ section = mi.Section("Video", [("Format", "AVC"), ("Duration", "2 s"), ("ID", "1")])
179
+ assert mi.render_section(section) == "Format : AVC\nDuration : 2 s\nID : 1"
180
+
181
+
182
+ def test_long_values_wrap_under_the_start_of_the_value():
183
+ section = mi.Section("Video", [("Encoding settings", " / ".join(f"option{i}={i}" for i in range(40))), ("ID", "1")])
184
+ lines = mi.render_section(section, width=64).splitlines()
185
+ assert lines[0].startswith("Encoding settings : option0=0 / ") and len(lines) > 3
186
+ indent = " " * len("Encoding settings : ")
187
+ assert all(line.startswith(indent) for line in lines[1:-1]) and lines[-1].startswith("ID")
188
+ assert all(len(line) <= 64 + 8 for line in lines) # a wrapped value gets at least MIN_VALUE columns
189
+
190
+
191
+ def test_a_very_long_label_does_not_push_every_value_to_the_right():
192
+ section = mi.Section("Video", [("ID", "1"), ("Format settings, Reference frames", "1 frame"), ("Format", "AVC")])
193
+ lines = mi.render_section(section).splitlines()
194
+ assert lines == ["ID : 1", "Format settings, Reference frames : 1 frame", "Format : AVC"]
195
+
196
+
197
+ def test_unbroken_text_is_still_wrapped_and_lines_end_cleanly():
198
+ section = mi.Section("Menu", [("", "x" * 150), ("Blank", ""), ("Name", "y" * 200)])
199
+ lines = mi.render_section(section, width=64).splitlines()
200
+ assert max(len(line) for line in lines) <= 64 + 8 and all(line == line.rstrip() for line in lines)
201
+ assert "".join(line.strip() for line in lines).count("y") == 200
202
+
203
+
204
+ def test_values_can_be_shortened_to_fit():
205
+ section = mi.Section("General", [("Comment", "z" * 500)])
206
+ line = mi.render_section(section, max_value=50)
207
+ assert line.endswith("…") and len(line.split(" : ")[1]) == 50
208
+
209
+
210
+ # ---------------------------------------------------------------------------
211
+ # the Telegraph page
212
+ # ---------------------------------------------------------------------------
213
+
214
+
215
+ def test_every_section_becomes_a_heading_and_a_block_and_the_notes_come_last():
216
+ report = report_of(mi.Section("General", [("Format", "Matroska")]), mi.Section("Telegram", [("File name", "a.mkv")]), truncated=True)
217
+ nodes = mi.to_nodes(report)
218
+ assert [n["tag"] for n in nodes] == ["h4", "pre", "h4", "pre", "p", "p"]
219
+ assert nodes[0]["children"] == ["General"] and nodes[1]["children"] == ["Format : Matroska"]
220
+ notes = [n["children"][0]["children"][0] for n in nodes if n["tag"] == "p"]
221
+ assert "Only the start of the file was read" in notes[0] and notes[1] == "Made by the bot with MediaInfo."
222
+
223
+
224
+ def test_extra_notes_are_kept_and_an_unknown_engine_is_not_named():
225
+ report = report_of(mi.Section("General", [("Format", "x")]), engine="", notes=["No tracks could be read."])
226
+ notes = [n["children"][0]["children"][0] for n in mi.to_nodes(report) if n["tag"] == "p"]
227
+ assert notes == ["No tracks could be read.", "Made by the bot."]
228
+
229
+
230
+ def test_a_page_that_is_too_big_has_its_long_values_shortened_first():
231
+ big = mi.Section("Video", [(f"Setting {i}", "v" * 600) for i in range(60)])
232
+ report = report_of(mi.Section("General", [("Format", "x")]), big, mi.Section("Telegram", [("File name", "a")]))
233
+ assert telegraph.content_size(mi.to_nodes(report, limit=10 ** 9)) > 30_000
234
+ nodes = mi.to_nodes(report, limit=30_000)
235
+ assert telegraph.content_size(nodes) <= 30_000
236
+ titles = [n["children"][0] for n in nodes if n["tag"] == "h4"]
237
+ assert titles == ["General", "Video", "Telegram"] # nothing dropped, only shortened
238
+ assert "…" in nodes[3]["children"][0]
239
+
240
+
241
+ def test_a_file_with_hundreds_of_tracks_drops_the_last_sections_but_keeps_the_ends():
242
+ sections = [mi.Section("General", [("Format", "Matroska")])]
243
+ sections += [mi.Section(f"Text #{i}", [("Format", "UTF-8"), ("Title", "t" * 80), ("Language", "English")]) for i in range(1, 301)]
244
+ sections.append(mi.Section("Telegram", [("File name", "big.mkv")]))
245
+ nodes = mi.to_nodes(report_of(*sections), limit=12_000)
246
+ assert telegraph.content_size(nodes) <= 12_000
247
+ titles = [n["children"][0] for n in nodes if n["tag"] == "h4"]
248
+ assert titles[0] == "General" and titles[-1] == "Telegram" and 3 < len(titles) < 302
249
+ notes = [n["children"][0]["children"][0] for n in nodes if n["tag"] == "p"]
250
+ assert any("page size limit" in note for note in notes)
251
+
252
+
253
+ def test_the_default_limit_is_what_telegraph_accepts():
254
+ report = report_of(mi.Section("General", [("Format", "x")]))
255
+ assert telegraph.content_size(mi.to_nodes(report)) <= telegraph.CONTENT_LIMIT < 64 * 1024
256
+
257
+
258
+ def test_the_plain_text_version_has_everything_the_page_has():
259
+ report = report_of(mi.Section("General", [("Format", "Matroska"), ("Duration", "2 s")]), mi.Section("Telegram", [("File name", "a.mkv")]), truncated=True)
260
+ text = mi.render_text(report)
261
+ assert text.startswith("General\nFormat : Matroska\nDuration : 2 s\n\nTelegram\nFile name : a.mkv\n")
262
+ assert "Only the start of the file was read" in text and text.rstrip().endswith("Made by the bot with MediaInfo.") and text.endswith("\n")
263
+
264
+
265
+ # ---------------------------------------------------------------------------
266
+ # the ffprobe fallback
267
+ # ---------------------------------------------------------------------------
268
+
269
+
270
+ def test_ffprobe_output_becomes_mediainfo_style_sections():
271
+ sections = mi.ffprobe_sections(FFPROBE, "Show 01.mkv", 700 * 1024 ** 2)
272
+ assert [s.title for s in sections] == ["General", "Video", "Audio #1", "Audio #2", "Text", "Menu"]
273
+ report = report_of(*sections)
274
+ general = lines_of(report, "General")
275
+ assert general["Complete name"] == "Show 01.mkv" and general["Format"] == "Matroska / WebM" and general["File size"] == "700 MiB"
276
+ assert general["Duration"] == "24 min 1 s" and general["Overall bit rate"] == "4 074 kb/s"
277
+ assert general["Title"] == "Show" and general["Writing application"] == "libebml" and general["Encoded date"] == "2026-01-01T00:00:00Z"
278
+ assert general["Weird Key"] == "x" and general["Attachments"] == "font.ttf"
279
+ video = lines_of(report, "Video")
280
+ assert video["Format"] == "H.265 / HEVC" and video["Format profile"] == "Main 10@L4.1" and video["Frame rate"] == "23.976 FPS"
281
+ assert video["Width"] == "1920 pixels" and video["Height"] == "1080 pixels" and video["Bit depth"] == "10 bits"
282
+ assert video["Bit rate"] == "3 200 kb/s" and video["Duration"] == "24 min 1 s" and video["Default"] == "Yes" and video["Forced"] == "No"
283
+ first, second = lines_of(report, "Audio #1"), lines_of(report, "Audio #2")
284
+ assert first["Language"] == "Japanese" and first["Title"] == "Japanese" and first["Channel(s)"] == "2 channels"
285
+ assert first["Sampling rate"] == "48 kHz" and first["Format profile"] == "LC" and first["Default"] == "Yes"
286
+ assert second["Language"] == "Hindi" and second["Channel(s)"] == "1 channel" and second["Sampling rate"] == "44.1 kHz"
287
+ text = lines_of(report, "Text")
288
+ assert text["Language"] == "English" and text["Forced"] == "Yes" and text["Default"] == "No"
289
+ assert report.section("Menu").lines == [("00:00:00.000", "Intro"), ("00:01:30.500", "Main")]
290
+
291
+
292
+ @pytest.mark.parametrize("codec, level, shown", [
293
+ ("h264", 41, "L4.1"), ("h264", 40, "L4"), ("h264", 12, "L1.2"), ("hevc", 120, "L4"), ("hevc", 153, "L5.1"),
294
+ ("h264", -99, ""), ("vp9", 41, ""), ("h264", None, ""),
295
+ ])
296
+ def test_codec_levels_are_shown_the_way_each_codec_counts_them(codec, level, shown):
297
+ assert mi._level({"codec_name": codec, "level": level}) == shown
298
+
299
+
300
+ def test_ffprobe_with_nothing_to_show_gives_just_a_general_section():
301
+ sections = mi.ffprobe_sections({"format": {"format_name": "mp3"}}, "a.mp3", 0)
302
+ assert [s.title for s in sections] == ["General"] and sections[0].lines == [("Complete name", "a.mp3"), ("Format", "mp3")]
303
+
304
+
305
+ # ---------------------------------------------------------------------------
306
+ # real files
307
+ # ---------------------------------------------------------------------------
308
+
309
+
310
+ @needs_ffmpeg
311
+ async def test_a_real_mkv_is_read_by_mediainfo(media_dir):
312
+ pytest.importorskip("pymediainfo")
313
+ path = media_dir / "sample.mkv"
314
+ report = await mi.read_file(path, "Show 01.mkv", path.stat().st_size, extra=mi.telegram_section("Show 01.mkv", path.stat().st_size))
315
+ assert report.engine == "MediaInfo" and report.has_streams and not report.truncated
316
+ assert [s.title for s in report.sections] == ["General", "Video", "Audio #1", "Audio #2", "Audio #3", "Text #1", "Text #2", "Menu", "Telegram"]
317
+ general = lines_of(report, "General")
318
+ assert general["Complete name"] == "Show 01.mkv" and general["Format"] == "Matroska" # not the path it was read from
319
+ assert lines_of(report, "Audio #1")["Language"] == "Japanese" and lines_of(report, "Text #2")["Language"] == "English"
320
+ assert "AVC" in lines_of(report, "Video")["Format"] and lines_of(report, "Video")["Width"] == "320 pixels"
321
+ assert report.summary().startswith("Matroska · 2 s") and report.summary().endswith("1 video · 3 audio · 2 subtitle")
322
+
323
+
324
+ @needs_ffmpeg
325
+ async def test_without_mediainfo_ffprobe_gives_the_same_layout(media_dir, monkeypatch):
326
+ monkeypatch.setattr(mi, "_mediainfo", lambda path: None)
327
+ path = media_dir / "sample.mkv"
328
+ report = await mi.read_file(path, "Show 01.mkv", path.stat().st_size)
329
+ assert report.engine == "ffprobe" and report.has_streams
330
+ assert [s.title for s in report.sections] == ["General", "Video", "Audio #1", "Audio #2", "Audio #3", "Text #1", "Text #2", "Menu"]
331
+ assert lines_of(report, "General")["Complete name"] == "Show 01.mkv" and lines_of(report, "Audio #3")["Language"] == "Hindi"
332
+ assert report.section("Menu").lines == [("00:00:00.000", "Intro"), ("00:00:01.000", "Main")]
333
+
334
+
335
+ @needs_ffmpeg
336
+ async def test_a_failing_mediainfo_falls_back_to_ffprobe(media_dir, monkeypatch):
337
+ def boom(path):
338
+ raise RuntimeError("libmediainfo crashed")
339
+
340
+ monkeypatch.setattr(mi, "_mediainfo", boom)
341
+ report = await mi.read_file(media_dir / "sample.mp4", "Movie.mp4", (media_dir / "sample.mp4").stat().st_size)
342
+ assert report.engine == "ffprobe" and report.has_streams and lines_of(report, "Audio #2")["Language"] == "English"
343
+
344
+
345
+ @needs_ffmpeg
346
+ async def test_an_audio_file_shows_its_tags(media_dir):
347
+ path = media_dir / "sample.mp3"
348
+ report = await mi.read_file(path, "Song.mp3", path.stat().st_size)
349
+ assert report.has_streams and report.section("Audio") is not None
350
+ assert "Old" in lines_of(report, "General").values() # the title tag, however the engine labels it
351
+
352
+
353
+ @needs_ffmpeg
354
+ async def test_only_the_start_of_an_mkv_still_gets_the_real_size_and_bit_rate(media_dir, tmp_path):
355
+ pytest.importorskip("pymediainfo")
356
+ whole = media_dir / "sample.mkv"
357
+ cut = tmp_path / "probe.mkv"
358
+ cut.write_bytes(whole.read_bytes()[:3000])
359
+ real = whole.stat().st_size
360
+ expected = await mi.read_file(whole, "Show 01.mkv", real)
361
+ report = await mi.read_file(cut, "Show 01.mkv", real)
362
+ assert report.truncated and report.has_streams
363
+ general, reference = lines_of(report, "General"), lines_of(expected, "General")
364
+ assert general["File size"] == reference["File size"] and general["Overall bit rate"] == reference["Overall bit rate"]
365
+ assert not any("Conformance" in label or label.strip() == "CRC_Error_Pos" for label, _ in report.section("General").lines)
366
+
367
+
368
+ @needs_ffmpeg
369
+ async def test_an_mp4_cut_before_its_index_has_no_streams_but_still_a_report(media_dir, tmp_path):
370
+ cut = tmp_path / "probe.mp4"
371
+ cut.write_bytes((media_dir / "sample.mp4").read_bytes()[:3000])
372
+ report = await mi.read_file(cut, "Movie.mp4", 40 * 1024 * 1024)
373
+ assert report.truncated and not report.has_streams
374
+ assert lines_of(report, "General")["Complete name"] == "Movie.mp4" and lines_of(report, "General")["File size"] == "40.0 MiB"
375
+
376
+
377
+ async def test_a_file_that_is_not_media_still_gets_a_general_section(tmp_path):
378
+ path = tmp_path / "notes.txt"
379
+ path.write_text("hello\n" * 100)
380
+ report = await mi.read_file(path, "notes.txt", path.stat().st_size)
381
+ assert not report.has_streams and not report.truncated
382
+ general = lines_of(report, "General")
383
+ assert general["Complete name"] == "notes.txt" and general["File size"] == "600 Bytes"
tests/test_mediainfo_app.py ADDED
@@ -0,0 +1,276 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """/mediainfo in the chat: reply to a file, get a Telegraph link."""
2
+ import io
3
+ import shutil
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+ from bot import app as appmod
10
+ from bot.telegraph import Telegraph, TelegraphError
11
+
12
+ from .appkit import OWNER, STRANGER, say, send_doc
13
+ from .conftest import needs_ffmpeg
14
+ from .faketelegraph import serve_fake_telegraph
15
+ from .tgfakes import FakeMessage, document_message
16
+
17
+ URL = "https://telegra.ph/Show-05-10-21"
18
+ HINT = "Reply to a file or video with /mediainfo"
19
+
20
+
21
+ class StubTelegraph:
22
+ """What the bot talks to instead of Telegraph: remembers the pages it was asked for."""
23
+
24
+ def __init__(self, url=URL, error=None):
25
+ self.url, self.error, self.calls = url, error, []
26
+
27
+ async def create_page(self, title, nodes):
28
+ self.calls.append((title, nodes))
29
+ if self.error is not None:
30
+ raise self.error
31
+ return self.url
32
+
33
+
34
+ def blocks(nodes):
35
+ """{heading: text of its block} for a page's nodes."""
36
+ return {n["children"][0]: nodes[i + 1]["children"][0] for i, n in enumerate(nodes) if n["tag"] == "h4"}
37
+
38
+
39
+ def notes(nodes):
40
+ return [n["children"][0]["children"][0] for n in nodes if n["tag"] == "p"]
41
+
42
+
43
+ def serve(monkeypatch, media_dir, name="sample.mkv"):
44
+ """Make transfer.download copy a real sample file; returns the list of calls."""
45
+ calls = []
46
+
47
+ async def fake_download(client, message, dest, **kw):
48
+ calls.append(kw)
49
+ shutil.copy(media_dir / name, dest)
50
+
51
+ monkeypatch.setattr(appmod.transfer, "download", fake_download)
52
+ return calls
53
+
54
+
55
+ def serve_junk(monkeypatch, content=b"\x00" * 4096):
56
+ calls = []
57
+
58
+ async def fake_download(client, message, dest, **kw):
59
+ calls.append(kw)
60
+ Path(dest).write_bytes(content)
61
+
62
+ monkeypatch.setattr(appmod.transfer, "download", fake_download)
63
+ return calls
64
+
65
+
66
+ def leftovers(ctx):
67
+ root = Path(ctx.cfg.temp_dir)
68
+ return list(root.glob("minfo*")) if root.exists() else []
69
+
70
+
71
+ def use_stub(ctx, **kw):
72
+ ctx.app.telegraph = StubTelegraph(**kw)
73
+ return ctx.app.telegraph
74
+
75
+
76
+ def real_size(media_dir, name="sample.mkv"):
77
+ return (media_dir / name).stat().st_size
78
+
79
+
80
+ # ---------------------------------------------------------------------------
81
+ # the flow
82
+ # ---------------------------------------------------------------------------
83
+
84
+
85
+ @needs_ffmpeg
86
+ async def test_reply_to_a_video_and_the_telegraph_link_comes_back(ctx, media_dir, monkeypatch):
87
+ calls = serve(monkeypatch, media_dir)
88
+ tg = use_stub(ctx)
89
+ video = document_message("Show - 05.mkv", real_size(media_dir), mid=70, caption="Dual Audio")
90
+ video.date = datetime(2026, 10, 19, 21, 30, 12, tzinfo=timezone.utc)
91
+ video.document.dc_id = 4
92
+ ev = await say(ctx, OWNER, "/mediainfo", reply=video)
93
+ assert ev.texts == ["🔎 Reading the file…"]
94
+
95
+ entity, text, kw = ctx.client.edited[-1]
96
+ assert entity.id == 1001 # the "Reading…" message became the answer
97
+ assert "🔎 <b>MediaInfo</b> · Show - 05.mkv" in text and URL in text and "Matroska" in text and "1 video · 3 audio · 2 subtitle" in text
98
+ assert kw["parse_mode"] == "html" and kw["link_preview"] is False
99
+ (button,), = kw["buttons"]
100
+ assert button.type.url == URL and button.text.startswith("📄")
101
+
102
+ (title, nodes), = tg.calls
103
+ assert title == "MediaInfo – Show - 05.mkv"
104
+ page = blocks(nodes)
105
+ assert list(page) == ["General", "Video", "Audio #1", "Audio #2", "Audio #3", "Text #1", "Text #2", "Menu", "Telegram"]
106
+ assert "Show - 05.mkv" in page["General"] and "probe.mkv" not in page["General"] # the file's own name, not the temp copy
107
+ telegram = page["Telegram"]
108
+ for line in ("File name : Show - 05.mkv", "MIME type : video/x-matroska", "Sent as : Video",
109
+ "Sent on : 2026-10-19 21:30:12 UTC", "Data center : 4", "Caption : Dual Audio"):
110
+ assert line in telegram, line
111
+ assert len(calls) == 1 and calls[0]["max_bytes"] == appmod.TRACKS_PROBE_BYTES # the start of the file was enough
112
+ assert leftovers(ctx) == []
113
+
114
+
115
+ async def test_without_a_file_there_is_a_hint_and_nothing_is_downloaded(ctx, monkeypatch):
116
+ async def no_download(*a, **kw):
117
+ raise AssertionError("there is nothing to read")
118
+
119
+ monkeypatch.setattr(appmod.transfer, "download", no_download)
120
+ tg = use_stub(ctx)
121
+ for reply in (None, FakeMessage("just some text")):
122
+ ev = await say(ctx, OWNER, "/mediainfo", reply=reply)
123
+ assert HINT in ev.texts[0] and "Telegraph" in ev.texts[0]
124
+ assert tg.calls == [] and ctx.client.edited == []
125
+
126
+
127
+ @needs_ffmpeg
128
+ async def test_a_file_sent_with_the_command_as_its_caption_is_read_not_renamed(ctx, media_dir, monkeypatch):
129
+ serve(monkeypatch, media_dir)
130
+ tg = use_stub(ctx)
131
+ for mid, caption in ((71, "/mediainfo"), (72, "/mediainfo@my_rename_bot")):
132
+ ev = await send_doc(ctx, OWNER, "Show.mkv", size=real_size(media_dir), caption=caption, mid=mid)
133
+ assert ev.texts == ["🔎 Reading the file…"] # no "Queued" message from the auto-renamer
134
+ assert ctx.app.queue.waiting == 0 and len(tg.calls) == 2
135
+ assert "Caption" not in blocks(tg.calls[0][1])["Telegram"] # the caption was only the command
136
+
137
+
138
+ async def test_other_captions_still_rename_the_file(ctx):
139
+ ev = await send_doc(ctx, OWNER, "Show.mkv", size=5 * 1024 * 1024, caption="/mediainfoX", mid=73)
140
+ assert ev.texts[0].startswith("📥 <b>Queued</b>") and ctx.app.queue.waiting == 1
141
+
142
+
143
+ @needs_ffmpeg
144
+ async def test_a_video_whose_index_is_at_the_end_is_read_from_the_whole_file(ctx, media_dir, monkeypatch):
145
+ calls = []
146
+
147
+ async def fake_download(client, message, dest, **kw):
148
+ calls.append(kw)
149
+ if "max_bytes" in kw:
150
+ Path(dest).write_bytes((media_dir / "sample.mp4").read_bytes()[:3000]) # an mp4 cut before its index
151
+ else:
152
+ shutil.copy(media_dir / "sample.mp4", dest)
153
+
154
+ monkeypatch.setattr(appmod.transfer, "download", fake_download)
155
+ tg = use_stub(ctx)
156
+ await say(ctx, OWNER, "/mediainfo", reply=document_message("Movie.mp4", 40 * 1024 * 1024, mid=74, mime="video/mp4"))
157
+ assert len(calls) == 2 and "max_bytes" in calls[0] and "max_bytes" not in calls[1]
158
+ assert any("needs the whole file" in text for _e, text, _k in ctx.client.edited)
159
+ (_title, nodes), = tg.calls
160
+ assert list(blocks(nodes))[:2] == ["General", "Video"] and "No tracks" not in " ".join(notes(nodes))
161
+ assert URL in ctx.client.edited[-1][1] and leftovers(ctx) == []
162
+
163
+
164
+ @pytest.mark.parametrize("size, why", [(2 * 1024 * 1024, "may be damaged"), (900 * 1024 * 1024, "too big for me to download completely")])
165
+ async def test_a_video_with_no_readable_tracks_still_gets_a_page_that_says_so(ctx, monkeypatch, size, why):
166
+ calls = serve_junk(monkeypatch)
167
+ tg = use_stub(ctx)
168
+ await say(ctx, OWNER, "/mediainfo", reply=document_message("Broken.mkv", size, mid=75))
169
+ assert len(calls) == 1 # too small to need more, or too big to fetch whole
170
+ (_title, nodes), = tg.calls
171
+ assert any(why in note for note in notes(nodes))
172
+ assert list(blocks(nodes)) == ["General", "Telegram"] and URL in ctx.client.edited[-1][1]
173
+ assert leftovers(ctx) == []
174
+
175
+
176
+ async def test_a_file_that_is_not_media_is_read_from_its_start_only(ctx, monkeypatch):
177
+ calls = serve_junk(monkeypatch, b"PK\x03\x04" + b"\x00" * 4000)
178
+ tg = use_stub(ctx)
179
+ await say(ctx, OWNER, "/mediainfo", reply=document_message("archive.zip", 50 * 1024 * 1024, mid=76, video=False, mime="application/zip"))
180
+ assert len(calls) == 1 and "max_bytes" in calls[0]
181
+ (_title, nodes), = tg.calls
182
+ page = blocks(nodes)
183
+ assert list(page) == ["General", "Telegram"] and "archive.zip" in page["General"] and "50.0 MiB" in page["General"]
184
+ assert "Sent as : Document" in page["Telegram"] and "No tracks" not in " ".join(notes(nodes))
185
+ assert any("Only the start of the file was read" in note for note in notes(nodes))
186
+
187
+
188
+ async def test_a_download_that_fails_is_reported_and_cleaned_up(ctx, monkeypatch):
189
+ async def fail(client, message, dest, **kw):
190
+ Path(dest).write_bytes(b"partial")
191
+ raise ConnectionError("connection reset")
192
+
193
+ monkeypatch.setattr(appmod.transfer, "download", fail)
194
+ tg = use_stub(ctx)
195
+ await say(ctx, OWNER, "/mediainfo", reply=document_message("Show.mkv", 5 * 1024 * 1024, mid=77))
196
+ assert ctx.client.edited[-1][1] == "❌ I couldn't read that file." and tg.calls == [] and leftovers(ctx) == []
197
+
198
+
199
+ # ---------------------------------------------------------------------------
200
+ # when Telegraph does not work
201
+ # ---------------------------------------------------------------------------
202
+
203
+
204
+ @needs_ffmpeg
205
+ async def test_when_telegraph_fails_the_report_comes_as_a_text_file(ctx, media_dir, monkeypatch):
206
+ serve(monkeypatch, media_dir)
207
+ use_stub(ctx, error=TelegraphError("Telegraph asks to wait 5 seconds", "FLOOD_WAIT_5"))
208
+ await say(ctx, OWNER, "/mediainfo", reply=document_message("Show: 05?.mkv", real_size(media_dir), mid=78))
209
+ (entity, file, kw), = [f for f in ctx.client.files if isinstance(f[1], io.BytesIO)]
210
+ assert entity == OWNER and file.name == "Show_ 05_ - MediaInfo.txt" and kw["force_document"] is True
211
+ assert "Telegraph asks to wait 5 seconds, so here it is as a text file." in kw["caption"] and kw["parse_mode"] == "html"
212
+ text = file.getvalue().decode()
213
+ assert text.startswith("General\n") and "\nVideo\n" in text and "\nTelegram\nFile name" in text and "Show: 05?.mkv" in text
214
+ assert (OWNER, [1001]) in ctx.client.deleted # the "Reading…" message goes
215
+ assert leftovers(ctx) == []
216
+
217
+
218
+ async def test_another_telegraph_failure_does_not_show_its_details(ctx, monkeypatch):
219
+ serve_junk(monkeypatch)
220
+ use_stub(ctx, error=RuntimeError("secret token abc123 rejected"))
221
+ await say(ctx, OWNER, "/mediainfo", reply=document_message("Show.mkv", 2 * 1024 * 1024, mid=79))
222
+ (_e, _file, kw), = [f for f in ctx.client.files if isinstance(f[1], io.BytesIO)]
223
+ assert "Telegraph is not available, so here it is as a text file." in kw["caption"] and "abc123" not in kw["caption"]
224
+
225
+
226
+ async def test_an_unexpected_error_does_not_leave_the_reading_message_hanging(ctx, monkeypatch):
227
+ def broken(message, caption=True):
228
+ raise RuntimeError("boom")
229
+
230
+ monkeypatch.setattr(appmod, "telegram_facts", broken)
231
+ ev = await say(ctx, OWNER, "/mediainfo", reply=document_message("Show.mkv", 2 * 1024 * 1024, mid=80))
232
+ assert ev.texts == ["🔎 Reading the file…"]
233
+ assert ctx.client.edited[-1][1] == "⚠️ Something went wrong while reading that file."
234
+
235
+
236
+ # ---------------------------------------------------------------------------
237
+ # access and plumbing
238
+ # ---------------------------------------------------------------------------
239
+
240
+
241
+ async def test_only_allowed_users_can_use_it(ctx, monkeypatch):
242
+ tg = use_stub(ctx)
243
+ ev = await say(ctx, STRANGER, "/mediainfo", reply=document_message("Show.mkv", 1024, mid=81))
244
+ assert ev.out == [] and tg.calls == []
245
+
246
+
247
+ async def test_the_command_is_in_the_menu():
248
+ commands = dict(appmod.COMMANDS)
249
+ assert "mediainfo" in commands and 3 <= len(commands["mediainfo"]) <= 256
250
+
251
+
252
+ async def test_the_telegraph_token_lives_in_the_database(ctx):
253
+ assert await ctx.app._load_telegraph_token() is None
254
+ await ctx.app._save_telegraph_token("abc")
255
+ assert await ctx.app._load_telegraph_token() == "abc" and await ctx.db.kv_get("telegraph_token") == "abc"
256
+ await ctx.app._save_telegraph_token("") # what the client does when Telegraph rejects the token
257
+ assert await ctx.db.kv_get("telegraph_token") is None
258
+
259
+
260
+ @needs_ffmpeg
261
+ async def test_the_whole_chain_with_the_real_client_and_a_fake_telegraph(ctx, media_dir, monkeypatch):
262
+ serve(monkeypatch, media_dir, "sample.mp4")
263
+ fake = await serve_fake_telegraph()
264
+ try:
265
+ ctx.app.telegraph = Telegraph(ctx.app._load_telegraph_token, ctx.app._save_telegraph_token, api=fake.url)
266
+ ctx.app.telegraph.set_author("Rename Bot", "https://t.me/rename_bot")
267
+ await say(ctx, OWNER, "/mediainfo", reply=document_message("Movie.mp4", real_size(media_dir, "sample.mp4"), mid=82, mime="video/mp4"))
268
+ await say(ctx, OWNER, "/mediainfo", reply=document_message("Movie 2.mp4", real_size(media_dir, "sample.mp4"), mid=83, mime="video/mp4"))
269
+ assert fake.accounts == 1 and len(fake.pages) == 2 # one account, kept for the second page
270
+ assert await ctx.db.kv_get("telegraph_token") == "token1"
271
+ page = fake.pages[0]
272
+ assert page["title"] == "MediaInfo – Movie.mp4" and page["author_name"] == "Rename Bot" and page["author_url"] == "https://t.me/rename_bot"
273
+ assert "Audio #2" in page["content"] and "Movie.mp4" in page["content"]
274
+ assert "https://telegra.ph/Page-1" in ctx.client.edited[-2][1] and "https://telegra.ph/Page-2" in ctx.client.edited[-1][1]
275
+ finally:
276
+ await fake.server.close()
tests/test_parser.py CHANGED
@@ -3,7 +3,6 @@ import pytest
3
  from bot.parser import (
4
  DEFAULT_TEMPLATE,
5
  apply_remove_words,
6
- build_manual_name,
7
  compute_new_name,
8
  finalize_name,
9
  missing_variables,
@@ -186,18 +185,6 @@ def test_finalize_truncates_by_bytes_and_keeps_ext():
186
  assert len(out.encode("utf-8")) <= 200
187
 
188
 
189
- def test_manual_name_keeps_original_ext():
190
- assert build_manual_name("My Movie", ".mkv") == "My Movie.mkv"
191
- assert build_manual_name("My Movie.mkv", ".mkv") == "My Movie.mkv"
192
- assert build_manual_name("My Movie.mp4", ".mkv") == "My Movie.mkv"
193
- assert build_manual_name("Season 1.5", ".mkv") == "Season 1.5.mkv"
194
- assert build_manual_name("Fixed.mkv", "") == "Fixed.mkv"
195
-
196
-
197
- def test_manual_name_sanitised():
198
- assert build_manual_name('a/b:c*d?.mkv', ".mkv") == "a b c d.mkv"
199
-
200
-
201
  def test_sanitize_and_remove_words():
202
  assert sanitize(" ..hidden:name ") == "hidden name"
203
  assert apply_remove_words("Show [Group] 720p", ["[group]"]) == "Show 720p"
 
3
  from bot.parser import (
4
  DEFAULT_TEMPLATE,
5
  apply_remove_words,
 
6
  compute_new_name,
7
  finalize_name,
8
  missing_variables,
 
185
  assert len(out.encode("utf-8")) <= 200
186
 
187
 
 
 
 
 
 
 
 
 
 
 
 
 
188
  def test_sanitize_and_remove_words():
189
  assert sanitize(" ..hidden:name ") == "hidden name"
190
  assert apply_remove_words("Show [Group] 720p", ["[group]"]) == "Show 720p"
tests/test_pipeline.py CHANGED
@@ -286,3 +286,84 @@ def test_helpers():
286
  assert original_name_of(nameless) == "video_55.mp4"
287
  assert source_ext(nameless, "video_55.mp4") == ".mp4"
288
  assert source_ext(FakeMessage("noext", 1, ext=".mkv"), "noext") == ".mkv"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
286
  assert original_name_of(nameless) == "video_55.mp4"
287
  assert source_ext(nameless, "video_55.mp4") == ".mp4"
288
  assert source_ext(FakeMessage("noext", 1, ext=".mkv"), "noext") == ".mkv"
289
+
290
+
291
+ # ---------------------------------------------------------------------------
292
+ # saved /rename formats
293
+ # ---------------------------------------------------------------------------
294
+
295
+
296
+ async def make_fmt(env, name="One Piece", **changes):
297
+ fmt = await env.db.create_format(42, name, template="One Piece - E{episode}")
298
+ if changes:
299
+ await env.db.update_format(42, fmt["id"], **changes)
300
+ return await env.db.get_format(42, fmt["id"])
301
+
302
+
303
+ @needs_ffmpeg
304
+ async def test_a_format_brings_its_own_metadata_tracks_and_thumbnail(env):
305
+ # the global metadata is off and has a different title: the format must not depend on either
306
+ await env.db.update_settings(42, metadata_on=False, meta={"title": "GLOBAL"}, keep_audio=["hindi"])
307
+ await env.db.set_thumb(42, b"\xff\xd8global")
308
+ fmt = await make_fmt(env, meta={"title": "One Piece", "audio": "{lang}"}, keep_audio=["english", "japanese"], keep_subtitle=["2"])
309
+ await env.db.set_format_thumb(42, fmt["id"], b"\xff\xd8format")
310
+ fmt = await env.db.get_format(42, fmt["id"])
311
+
312
+ client, renamer, job = make(env)
313
+ job.fmt = fmt
314
+ await renamer.process(job)
315
+
316
+ out = await probe(client.uploads[0])
317
+ assert [s.lang for s in out.audio] == ["eng", "jpn"] and [s.lang for s in out.subtitles] == ["eng"]
318
+ assert out.tags.get("title") == "One Piece" and [s.title for s in out.audio] == ["English", "Japanese"]
319
+ (_t, _f, kw), = client.sent
320
+ assert kw["thumb"] == b"\xff\xd8format"
321
+ assert job.state == "done"
322
+
323
+
324
+ @needs_ffmpeg
325
+ async def test_a_format_without_a_thumbnail_uses_the_users_default_one(env):
326
+ await env.db.set_thumb(42, b"\xff\xd8global")
327
+ fmt = await make_fmt(env)
328
+ client, renamer, job = make(env)
329
+ job.fmt = fmt
330
+ await renamer.process(job)
331
+ (_t, _f, kw), = client.sent
332
+ assert kw["thumb"] == b"\xff\xd8global"
333
+
334
+
335
+ @needs_ffmpeg
336
+ async def test_a_format_that_sets_no_metadata_leaves_the_file_untouched_even_if_global_metadata_is_on(env):
337
+ await env.db.update_settings(42, metadata_on=True, meta={"title": "GLOBAL"}, keep_audio=["english"])
338
+ fmt = await make_fmt(env)
339
+ client, renamer, job = make(env)
340
+ job.fmt = fmt
341
+ await renamer.process(job)
342
+ assert client.uploads[0].read_bytes() == (env.media_dir / "sample.mkv").read_bytes() # no ffmpeg pass
343
+ assert job.state == "done"
344
+
345
+
346
+ @needs_ffmpeg
347
+ async def test_everything_else_still_comes_from_the_users_settings(env):
348
+ await env.db.update_settings(42, file_type="document", caption="<b>{filename}</b> by me", target_channel=-100777)
349
+ fmt = await make_fmt(env, meta={"title": "T"})
350
+ client, renamer, job = make(env)
351
+ job.fmt = fmt
352
+ job.new_name = "One Piece - E5.mkv"
353
+ await renamer.process(job)
354
+ (target, _f, kw), = client.sent
355
+ assert target == -100777 and kw["force_document"] is True and kw["caption"] == "<b>One Piece - E5</b> by me"
356
+
357
+
358
+ @needs_ffmpeg
359
+ async def test_a_format_deleted_while_queued_falls_back_to_the_users_thumbnail_and_still_finishes(env):
360
+ await env.db.set_thumb(42, b"\xff\xd8global")
361
+ fmt = await make_fmt(env, meta={"title": "One Piece"})
362
+ await env.db.delete_format(42, fmt["id"])
363
+ client, renamer, job = make(env)
364
+ job.fmt = fmt # the snapshot taken when the file was queued
365
+ await renamer.process(job)
366
+ (_t, _f, kw), = client.sent
367
+ assert kw["thumb"] == b"\xff\xd8global" and job.state == "done"
368
+ out = await probe(client.uploads[0])
369
+ assert out.tags.get("title") == "One Piece" # the snapshot's metadata is still applied
tests/test_resize.py ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """/resize: pick a size, send an image, get it back resized."""
2
+ import io
3
+ import time
4
+
5
+ from PIL import Image
6
+
7
+ from bot import app as appmod
8
+ from bot import panels
9
+
10
+ from .appkit import OWNER, STRANGER, click, data_of, jpeg, say, send_doc
11
+ from .tgfakes import FakeEvent, FakeMessage, dispatch
12
+
13
+ FULL_HD = (1920, 1080)
14
+
15
+
16
+ def results(ctx):
17
+ return [(entity, file, kw) for entity, file, kw in ctx.client.files if isinstance(file, io.BytesIO)]
18
+
19
+
20
+ def size_of(file):
21
+ return Image.open(io.BytesIO(file.getvalue())).size
22
+
23
+
24
+ async def send_photo(ctx, mid=50, data=None, sender=OWNER):
25
+ ctx.client.media[mid] = data if data is not None else jpeg(FULL_HD)
26
+ ev = FakeEvent(sender, message=FakeMessage("", photo=True, mid=mid), photo_bytes=jpeg())
27
+ await dispatch(ctx.client, ev)
28
+ return ev
29
+
30
+
31
+ async def send_image_file(ctx, name="poster.png", mid=51, data=None, size=200 * 1024):
32
+ ctx.client.media[mid] = data if data is not None else jpeg(FULL_HD)
33
+ return await send_doc(ctx, OWNER, name, size=size, video=False, mime="image/png", mid=mid)
34
+
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # choosing the size
38
+ # ---------------------------------------------------------------------------
39
+
40
+
41
+ async def test_resize_offers_the_sizes(ctx):
42
+ ev = await say(ctx, OWNER, "/resize")
43
+ text, buttons = ev.texts[0], data_of(ev.out[0][1])
44
+ assert "RESIZE IMAGE" in text and "200 KB" in text and "/resize 320x180" in text
45
+ assert buttons == ["rz:s:320", "rz:s:640", "rz:s:1280", "rz:custom", "x:close"]
46
+ assert OWNER not in ctx.app.modes # nothing is waiting for an image until a size is chosen
47
+
48
+
49
+ async def test_a_preset_button_makes_the_bot_wait_for_the_image(ctx):
50
+ cb = await click(ctx, OWNER, "rz:s:320")
51
+ assert "Size: <b>320 px</b>" in cb.edits[-1][0] and "one image at a time" in cb.edits[-1][0]
52
+ assert data_of(cb.edits[-1][1]) == ["rz:menu", "rz:stop"]
53
+ mode = ctx.app.modes[OWNER]
54
+ assert mode.kind == "resize" and mode.data == {"box": (320, 320)} and mode.expires > time.time() + 500
55
+ await click(ctx, OWNER, "rz:s:1280")
56
+ assert ctx.app.modes[OWNER].data["box"] == (1280, 1280)
57
+
58
+
59
+ async def test_unknown_presets_are_ignored(ctx):
60
+ cb = await click(ctx, OWNER, "rz:s:999")
61
+ assert cb.edits == [] and OWNER not in ctx.app.modes
62
+
63
+
64
+ async def test_the_size_can_be_given_with_the_command(ctx):
65
+ ev = await say(ctx, OWNER, "/resize 320x180")
66
+ assert "<b>320×180</b>" in ev.texts[0] and ctx.app.modes[OWNER].data["box"] == (320, 180)
67
+ ev = await say(ctx, OWNER, "/resize 640")
68
+ assert "<b>640 px</b>" in ev.texts[0] and ctx.app.modes[OWNER].data["box"] == (640, 640)
69
+ for bad in ("tiny", "10x10", "99999", "320 px"):
70
+ ev = await say(ctx, OWNER, f"/resize {bad}")
71
+ assert "❌" in ev.texts[0] and "/resize 320x180" in ev.texts[0], bad
72
+ assert ctx.app.modes[OWNER].data["box"] == (640, 640) # a bad size does not disturb the one that was set
73
+
74
+
75
+ async def test_a_custom_size_is_typed_in(ctx):
76
+ cb = await click(ctx, OWNER, "rz:custom", msg_id=500)
77
+ p = ctx.app.pending[OWNER]
78
+ assert p.kind == "resize:size" and p.msg_id == 500 and cb.edits[-1][0] == panels.RESIZE_CUSTOM_PROMPT
79
+ assert data_of(cb.edits[-1][1]) == ["rz:menu"]
80
+
81
+ ev = await say(ctx, OWNER, "nonsense", mid=60)
82
+ assert "couldn't read that size" in ev.texts[0] and "Try again" in ev.texts[0] and OWNER in ctx.app.pending
83
+
84
+ ev = await say(ctx, OWNER, "300 x 200", mid=61)
85
+ assert ev.deleted and OWNER not in ctx.app.pending
86
+ assert ctx.app.modes[OWNER].data["box"] == (300, 200)
87
+ (chat, text, kw), = ctx.client.edited
88
+ assert kw["message"] == 500 and "<b>300×200</b>" in text and data_of(kw["buttons"]) == ["rz:menu", "rz:stop"]
89
+
90
+
91
+ async def test_cancelling_the_custom_prompt_goes_back_to_the_sizes(ctx):
92
+ await click(ctx, OWNER, "rz:custom")
93
+ cb = await click(ctx, OWNER, "rz:menu")
94
+ assert OWNER not in ctx.app.pending and "RESIZE IMAGE" in cb.edits[-1][0]
95
+
96
+
97
+ async def test_change_size_and_stop_buttons(ctx):
98
+ await click(ctx, OWNER, "rz:s:320")
99
+ cb = await click(ctx, OWNER, "rz:menu")
100
+ assert "RESIZE IMAGE" in cb.edits[-1][0] and OWNER not in ctx.app.modes
101
+ await click(ctx, OWNER, "rz:s:320")
102
+ cb = await click(ctx, OWNER, "rz:stop")
103
+ assert "Stopped" in cb.edits[-1][0] and OWNER not in ctx.app.modes
104
+
105
+
106
+ # ---------------------------------------------------------------------------
107
+ # resizing
108
+ # ---------------------------------------------------------------------------
109
+
110
+
111
+ async def test_a_photo_comes_back_resized(ctx):
112
+ await say(ctx, OWNER, "/resize 320")
113
+ await send_photo(ctx)
114
+ (entity, file, kw), = results(ctx)
115
+ assert entity == OWNER and file.name == "resized.jpg" and size_of(file) == (320, 180)
116
+ assert len(file.getvalue()) <= 200 * 1024
117
+ assert "<b>320×180</b>" in kw["caption"] and "was 1920×1080" in kw["caption"] and kw["parse_mode"] == "html"
118
+ assert kw["force_document"] is False
119
+ assert OWNER not in ctx.app.modes
120
+ assert ctx.client.downloads == [(50, bytes, None)]
121
+
122
+
123
+ async def test_an_image_sent_as_a_file_works_too(ctx):
124
+ await say(ctx, OWNER, "/resize 640x360")
125
+ ev = await send_image_file(ctx)
126
+ assert ev.out == [] and ctx.app.queue.waiting == 0 # not renamed
127
+ (_e, file, _kw), = results(ctx)
128
+ assert size_of(file) == (640, 360)
129
+
130
+
131
+ async def test_what_is_not_an_image_does_not_use_up_the_request(ctx):
132
+ await say(ctx, OWNER, "/resize 320")
133
+ ev = await send_doc(ctx, OWNER, "Episode.mkv", mid=52)
134
+ assert "isn't an image" in ev.texts[0] and ctx.app.queue.waiting == 0 and results(ctx) == []
135
+ assert ctx.app.modes[OWNER].kind == "resize"
136
+ await send_photo(ctx)
137
+ assert len(results(ctx)) == 1
138
+
139
+
140
+ async def test_it_handles_one_image_per_command_and_extra_photos_do_not_replace_the_thumbnail(ctx):
141
+ await say(ctx, OWNER, "/resize 320")
142
+ await send_photo(ctx, mid=50)
143
+ extra = await send_photo(ctx, mid=51)
144
+ assert "/resize handles one file at a time" in extra.texts[0]
145
+ assert (await send_photo(ctx, mid=52)).out == []
146
+ assert len(results(ctx)) == 1
147
+ assert not await ctx.db.has_thumb(OWNER) # none of the extra photos became the saved thumbnail
148
+ doc = await send_image_file(ctx, mid=53)
149
+ assert doc.out == [] and ctx.app.queue.waiting == 0 # an image file right after is skipped as well, not auto-renamed
150
+
151
+ ctx.app._recent[OWNER][1] -= appmod.ONE_SHOT_GRACE + 1
152
+ await send_photo(ctx, mid=54)
153
+ assert await ctx.db.has_thumb(OWNER) # after the grace a photo is a thumbnail again
154
+
155
+
156
+ async def test_large_results_are_sent_as_files_so_telegram_does_not_shrink_them(ctx):
157
+ await say(ctx, OWNER, "/resize 2000")
158
+ await send_photo(ctx, data=jpeg((3000, 2000)))
159
+ (_e, file, kw), = results(ctx)
160
+ assert size_of(file) == (2000, 1333) and kw["force_document"] is True
161
+
162
+
163
+ async def test_resize_as_a_reply_works_at_once(ctx):
164
+ ctx.client.media[40] = jpeg(FULL_HD)
165
+ reply = FakeMessage("", photo=True, mid=40)
166
+ await say(ctx, OWNER, "/resize", reply=reply, mid=41)
167
+ (_e, file, _kw), = results(ctx)
168
+ assert size_of(file) == (320, 180) and OWNER not in ctx.app.modes # thumbnail size unless told otherwise
169
+ await say(ctx, OWNER, "/resize 640x640", reply=reply, mid=42)
170
+ assert size_of(results(ctx)[-1][1]) == (640, 360)
171
+
172
+
173
+ async def test_a_picture_that_cannot_be_read_gets_an_honest_answer(ctx):
174
+ await say(ctx, OWNER, "/resize 320")
175
+ ev = await send_photo(ctx, data=b"not a picture at all")
176
+ assert "couldn't read that image" in ev.texts[0] and results(ctx) == []
177
+ ctx.client.media[50] = None
178
+ await say(ctx, OWNER, "/resize 320")
179
+ ev = await send_photo(ctx, data=b"")
180
+ assert "couldn't download" in ev.texts[0]
181
+
182
+
183
+ async def test_very_large_files_are_refused_before_downloading(ctx):
184
+ await say(ctx, OWNER, "/resize 320")
185
+ ev = await send_image_file(ctx, size=appmod.RESIZE_MAX_BYTES + 1)
186
+ assert "I can resize images up to 20.00 MB" in ev.texts[0] and ctx.client.downloads == []
187
+
188
+
189
+ async def test_it_expires_and_cancel_ends_it(ctx):
190
+ await say(ctx, OWNER, "/resize 320")
191
+ ctx.app.modes[OWNER].expires = time.time() - 1
192
+ ev = await send_doc(ctx, OWNER, "A.mkv", mid=60)
193
+ assert "Queued" in ev.texts[0]
194
+
195
+ await say(ctx, OWNER, "/resize 320")
196
+ ev = await say(ctx, OWNER, "/cancel")
197
+ assert "Cancelled" in ev.texts[0] and OWNER not in ctx.app.modes
198
+ await send_photo(ctx)
199
+ assert results(ctx) == [] and await ctx.db.has_thumb(OWNER)
200
+
201
+
202
+ async def test_a_format_thumbnail_prompt_takes_priority_over_a_waiting_resize(ctx):
203
+ fmt = await ctx.db.create_format(OWNER, "One Piece")
204
+ await say(ctx, OWNER, "/resize 320")
205
+ await click(ctx, OWNER, f"fm:th:{fmt['id']}") # opened later: the user is setting the format's thumbnail now
206
+ await dispatch(ctx.client, FakeEvent(OWNER, message=FakeMessage("", photo=True, mid=70), photo_bytes=jpeg()))
207
+ assert (await ctx.db.get_format(OWNER, fmt["id"]))["has_thumb"] and results(ctx) == []
208
+
209
+
210
+ async def test_strangers_cannot_use_it(ctx):
211
+ ev = await say(ctx, STRANGER, "/resize 320")
212
+ assert ev.out == [] and STRANGER not in ctx.app.modes
213
+
214
+
215
+ async def test_starting_it_ends_a_sequence_and_says_so(ctx):
216
+ await say(ctx, OWNER, "/sequence")
217
+ await send_doc(ctx, OWNER, "Show - 01.mkv", mid=92)
218
+ ev = await say(ctx, OWNER, "/resize 320")
219
+ assert "1 file(s) was discarded" in ev.texts[0] and ctx.app.modes[OWNER].kind == "resize"
220
+ cb = await click(ctx, OWNER, "rz:s:640") # via the menu too
221
+ assert "discarded" not in cb.edits[-1][0] # the sequence was already dropped by the first command
222
+
223
+
224
+ async def test_both_commands_are_in_the_command_menu(ctx):
225
+ assert "resize" in dict(appmod.COMMANDS) and "ethumb" in dict(appmod.COMMANDS)
tests/test_sequence.py ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+
3
+ from bot.sequence import MAX_LISTED, SeqItem, analyse, detect, format_report, ranges
4
+
5
+
6
+ def item(mid, name, caption=""):
7
+ return SeqItem(msg_id=mid, name=name, caption=caption)
8
+
9
+
10
+ def names(items):
11
+ return [i.name for i in items]
12
+
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # reading the episode
16
+ # ---------------------------------------------------------------------------
17
+
18
+
19
+ @pytest.mark.parametrize(
20
+ "name,expected",
21
+ [
22
+ ("1147 - One Piece [Dual] 720p @Anime_X_Universe.mkv", (1, 1147)),
23
+ ("[SubsPlease] Tokyo Revengers - 05 (1080p) [ABCD1234].mkv", (1, 5)),
24
+ ("Show.S02E14.720p.mkv", (2, 14)),
25
+ ("Show 1x07.mkv", (1, 7)),
26
+ ("One Piece [1146] 720p.mkv", (1, 1146)),
27
+ ("Some Movie 2023 1080p.mkv", (None, None)),
28
+ ("holiday video.mp4", (None, None)),
29
+ ],
30
+ )
31
+ def test_detect_from_the_file_name(name, expected):
32
+ assert detect(name, "", "filename") == expected
33
+
34
+
35
+ def test_the_rename_mode_decides_which_source_is_read_first_and_the_other_is_the_fallback():
36
+ assert detect("Show S01E02.mkv", "Show S01E09", "filename") == (1, 2)
37
+ assert detect("Show S01E02.mkv", "Show S01E09", "caption") == (1, 9)
38
+ assert detect("random name.mkv", "One Piece - 1147", "filename") == (1, 1147) # nothing in the name: use the caption
39
+ assert detect("Show S01E02.mkv", "just words", "caption") == (1, 2) # nothing in the caption: use the name
40
+ assert detect("random.mkv", "", "caption") == (None, None)
41
+
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # ordering
45
+ # ---------------------------------------------------------------------------
46
+
47
+
48
+ def test_files_come_out_in_ascending_episode_order_whatever_order_they_were_sent():
49
+ sent = [item(5, "1147 - One Piece.mkv"), item(6, "1145 - One Piece.mkv"), item(7, "1146 - One Piece.mkv"), item(8, "1148 - One Piece.mkv")]
50
+ a = analyse(sent, "filename")
51
+ assert names(a.ordered) == ["1145 - One Piece.mkv", "1146 - One Piece.mkv", "1147 - One Piece.mkv", "1148 - One Piece.mkv"]
52
+ assert a.missing == {} and a.repeated == {} and a.unknown == []
53
+ assert a.first == (1, 1145) and a.last == (1, 1148)
54
+
55
+
56
+ def test_episode_ten_sorts_after_nine_not_after_one():
57
+ a = analyse([item(1, "Show - 10.mkv"), item(2, "Show - 9.mkv"), item(3, "Show - 1.mkv"), item(4, "Show - 100.mkv")], "filename")
58
+ assert [i.episode for i in a.ordered] == [1, 9, 10, 100]
59
+
60
+
61
+ def test_seasons_sort_before_episodes():
62
+ sent = [item(1, "Show S02E01.mkv"), item(2, "Show S01E03.mkv"), item(3, "Show S01E01.mkv"), item(4, "Show S02E02.mkv")]
63
+ a = analyse(sent, "filename")
64
+ assert [(i.season, i.episode) for i in a.ordered] == [(1, 1), (1, 3), (2, 1), (2, 2)]
65
+ assert a.missing == {1: [2]} # S01E02 is missing; the jump from S01 to S02 is not a gap
66
+ assert a.first == (1, 1) and a.last == (2, 2)
67
+
68
+
69
+ def test_files_without_an_episode_number_go_last_in_the_order_they_were_sent():
70
+ sent = [item(9, "zzz.mkv"), item(3, "Show - 02.mkv"), item(4, "aaa.mkv"), item(2, "Show - 01.mkv")]
71
+ a = analyse(sent, "filename")
72
+ assert names(a.ordered) == ["Show - 01.mkv", "Show - 02.mkv", "aaa.mkv", "zzz.mkv"]
73
+ assert names(a.unknown) == ["aaa.mkv", "zzz.mkv"]
74
+
75
+
76
+ def test_nothing_is_numbered():
77
+ a = analyse([item(1, "b.mkv"), item(2, "a.mkv")], "filename")
78
+ assert names(a.ordered) == ["b.mkv", "a.mkv"] and a.first is None and a.last is None and a.missing == {}
79
+
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # gaps and repeats
83
+ # ---------------------------------------------------------------------------
84
+
85
+
86
+ def test_missing_episodes_are_the_gaps_between_the_first_and_the_last():
87
+ sent = [item(i, f"One Piece - {n}.mkv") for i, n in enumerate((1145, 1146, 1148, 1151, 1152, 1156), start=1)]
88
+ a = analyse(sent, "filename")
89
+ assert a.missing == {1: [1147, 1149, 1150, 1153, 1154, 1155]}
90
+ # an episode before the first one you sent is not "missing"
91
+ assert analyse([item(1, "S - 5.mkv"), item(2, "S - 6.mkv")], "filename").missing == {}
92
+ assert analyse([item(1, "S - 5.mkv")], "filename").missing == {}
93
+
94
+
95
+ def test_repeated_episodes_list_every_file_that_shares_the_number():
96
+ sent = [item(1, "Show - 05 [A].mkv"), item(2, "Show - 05 [B].mkv"), item(3, "Show - 06.mkv"), item(4, "Show - 05 [C].mkv")]
97
+ a = analyse(sent, "filename")
98
+ assert list(a.repeated) == [(1, 5)] and names(a.repeated[(1, 5)]) == ["Show - 05 [A].mkv", "Show - 05 [B].mkv", "Show - 05 [C].mkv"]
99
+ assert names(a.ordered)[:3] == ["Show - 05 [A].mkv", "Show - 05 [B].mkv", "Show - 05 [C].mkv"] # both copies are kept, in send order
100
+ assert a.missing == {}
101
+
102
+
103
+ def test_the_same_episode_number_in_different_seasons_is_not_a_repeat():
104
+ a = analyse([item(1, "Show S01E01.mkv"), item(2, "Show S02E01.mkv")], "filename")
105
+ assert a.repeated == {}
106
+
107
+
108
+ def test_ranges():
109
+ assert ranges([3, 7, 8, 9]) == "E03, E07–E09"
110
+ assert ranges([1147]) == "E1147"
111
+ assert ranges([9, 3, 4]) == "E03–E04, E09"
112
+ assert ranges([]) == ""
113
+
114
+
115
+ # ---------------------------------------------------------------------------
116
+ # the report
117
+ # ---------------------------------------------------------------------------
118
+
119
+
120
+ def reports(files, mode="filename", failed=()):
121
+ sent = [item(i, n) for i, n in enumerate(files, start=1)]
122
+ a = analyse(sent, mode)
123
+ return format_report(a, len(sent), len(sent) - len(failed), [item(0, n) for n in failed])
124
+
125
+
126
+ def report(files, mode="filename", failed=()):
127
+ messages = reports(files, mode, failed)
128
+ assert len(messages) == 1
129
+ return messages[0]
130
+
131
+
132
+ def test_a_clean_sequence_says_so():
133
+ text = report(["One Piece - 1145.mkv", "One Piece - 1146.mkv", "One Piece - 1147.mkv"])
134
+ assert "Sorted 3 files" in text and "E1145 → E1147" in text and "No missing or repeated episodes." in text
135
+ assert "Missing" not in text and "Repeated" not in text
136
+
137
+
138
+ def test_files_without_a_number_do_not_hide_that_the_rest_is_complete():
139
+ text = report(["S - 01.mkv", "S - 02.mkv", "notes.mkv"])
140
+ assert "No missing or repeated episodes." in text and "No episode number found" in text
141
+
142
+
143
+ def test_nothing_numbered_means_nothing_to_say_about_gaps():
144
+ text = report(["a.mkv", "b.mkv"])
145
+ assert "No missing or repeated" not in text and "No episode number found" in text and "→" not in text
146
+
147
+
148
+ def test_one_file_is_not_plural():
149
+ assert "Sorted 1 file</b>" in report(["One Piece - 1145.mkv"])
150
+
151
+
152
+ def test_the_report_lists_missing_repeated_and_unnumbered_files():
153
+ text = report(["S - 01.mkv", "S - 02 a.mkv", "S - 02 b.mkv", "S - 05.mkv", "S - 08.mkv", "notes.mkv"])
154
+ assert "Sorted 6 files" in text and "E01 → E08" in text
155
+ assert "Missing episodes</b>\nE03–E04, E06–E07" in text
156
+ assert "Repeated episodes" in text and "E02 ×2" in text and "S - 02 a.mkv" in text and "S - 02 b.mkv" in text
157
+ assert "No episode number found" in text and "notes.mkv" in text
158
+ assert "No missing or repeated" not in text
159
+
160
+
161
+ def test_the_report_labels_seasons_only_when_there_are_several():
162
+ one = report(["S S01E01.mkv", "S S01E03.mkv"])
163
+ assert "E02" in one and "S01E02" not in one
164
+ two = report(["S S01E01.mkv", "S S01E03.mkv", "S S02E01.mkv", "S S02E02.mkv", "S S02E02 x.mkv"])
165
+ assert "S01: E02" in two and "S02E02 ×2" in two and "S01E01 → S02E02" in two
166
+
167
+
168
+ def test_the_report_mentions_files_that_could_not_be_sent():
169
+ text = report(["S - 01.mkv", "S - 02.mkv"], failed=["S - 02.mkv"])
170
+ assert "Sorted 1 of 2 files" in text and "Couldn't be sent" in text and "S - 02.mkv" in text
171
+
172
+
173
+ def test_names_are_escaped_and_long_lists_are_cut():
174
+ files = ["a&b <x>.mkv"] + [f"Dup {n}.mkv" for n in range(40)]
175
+ text = report(files)
176
+ assert "a&amp;b &lt;x&gt;.mkv" in text and "<x>" not in text
177
+ assert "… and 29 more" in text and text.count("• ") == MAX_LISTED
178
+
179
+
180
+ def test_ranges_are_cut_when_there_are_too_many_gaps():
181
+ text = ranges(range(2, 2000, 2), limit=300)
182
+ assert len(text) <= 300 and text.endswith(" more") and text.startswith("E02, E04")
183
+ shown = text.count("E")
184
+ assert shown > 10
185
+ assert ranges([3, 7], limit=300) == "E03, E07"
186
+
187
+
188
+ def test_a_huge_report_is_split_into_messages_telegram_accepts():
189
+ files = []
190
+ files += [f"Long Running Show Name With A Rather Long Title - {n}.mkv" for n in range(1, 400, 2)] # 200 gaps
191
+ for n in range(1, 30): # many repeated numbers
192
+ files += [f"Another Show S02E{n:02d} [v{k}] with a very long name indeed.mkv" for k in range(5)]
193
+ files += [f"unknown file number {n} with quite a long name for a file.mkv" for n in range(50)]
194
+ messages = reports(files, failed=[f"deleted file {n}.mkv" for n in range(30)])
195
+ assert len(messages) > 1
196
+ assert all(len(m) <= 4000 for m in messages)
197
+ joined = "\n\n".join(messages)
198
+ assert "Missing episodes" in joined and "Repeated episodes" in joined and "No episode number found" in joined and "Couldn't be sent" in joined
199
+ assert messages[0].startswith("✅ <b>Sorted")
tests/test_sequence_app.py ADDED
@@ -0,0 +1,442 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """/sequence (collect files) and /sort (send them back in episode order, then report gaps and repeats)."""
2
+ import asyncio
3
+ import time
4
+
5
+ import pytest
6
+ from telethon import errors
7
+
8
+ from bot import app as appmod
9
+
10
+ from .appkit import ADMIN_CFG, OWNER, STRANGER, click, data_of, say, send_doc
11
+
12
+ OP = "{n} - One Piece [Dual] 720p @Anime_X_Universe.mkv"
13
+
14
+
15
+ @pytest.fixture(autouse=True)
16
+ def quick(monkeypatch):
17
+ monkeypatch.setattr(appmod, "SEQUENCE_STATUS_DELAY", 0.02)
18
+ monkeypatch.setattr(appmod, "SORT_GAP", 0)
19
+
20
+
21
+ async def settle(delay=0.12):
22
+ await asyncio.sleep(delay)
23
+
24
+
25
+ async def feed(ctx, numbers, uid=OWNER, first_id=100):
26
+ """Send One Piece episodes in this order; returns {episode: message id} (the last id wins for repeats)."""
27
+ ids = {}
28
+ for k, n in enumerate(numbers):
29
+ await send_doc(ctx, uid, OP.format(n=n), mid=first_id + k)
30
+ ids[n] = first_id + k
31
+ return ids
32
+
33
+
34
+ def sent_ids(ctx, uid=OWNER):
35
+ return [f.id for entity, f, _kw in ctx.client.files if entity == uid]
36
+
37
+
38
+ def texts(ctx):
39
+ return [t for _e, t, _k in ctx.client.sent]
40
+
41
+
42
+ def last_text(ctx):
43
+ return ctx.client.sent[-1][1]
44
+
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # collecting
48
+ # ---------------------------------------------------------------------------
49
+
50
+
51
+ async def test_sequence_asks_for_files_and_collects_them_instead_of_renaming(ctx):
52
+ ev = await say(ctx, OWNER, "/sequence")
53
+ assert "SEQUENCE" in ev.texts[0] and "/sort" in ev.texts[0] and data_of(ev.out[0][1]) == ["sq:cx"]
54
+ assert ctx.app.modes[OWNER].kind == "sequence"
55
+
56
+ for k, n in enumerate((1147, 1145, 1146)):
57
+ got = await send_doc(ctx, OWNER, OP.format(n=n), mid=100 + k)
58
+ assert got.out == [] # nothing is queued, nothing is said per file
59
+ assert ctx.app.queue.waiting == 0 and ctx.app.queue.running == 0
60
+ assert [i.msg_id for i in ctx.app.modes[OWNER].data.items] == [100, 101, 102]
61
+
62
+
63
+ async def test_one_status_message_follows_each_burst_of_files_and_replaces_the_last(ctx):
64
+ await say(ctx, OWNER, "/sequence")
65
+ await feed(ctx, (1, 2, 3))
66
+ await settle()
67
+ (status,) = [(t, k) for _e, t, k in ctx.client.sent if "collected" in t]
68
+ assert "3 files collected" in status[0] and "/sort" in status[0]
69
+ assert data_of(status[1]["buttons"]) == ["sq:sort", "sq:cx"]
70
+ assert ctx.client.deleted == []
71
+
72
+ await send_doc(ctx, OWNER, OP.format(n=4), mid=200)
73
+ await settle()
74
+ assert [t for t in texts(ctx) if "collected" in t][-1].startswith("📥 <b>4 files collected")
75
+ assert ctx.client.deleted == [(OWNER, [2001])] # the old status goes, so the newest one is at the bottom
76
+
77
+
78
+ async def test_a_second_sequence_command_keeps_the_files_already_collected(ctx):
79
+ await say(ctx, OWNER, "/sequence")
80
+ await feed(ctx, (1, 2))
81
+ ev = await say(ctx, OWNER, "/sequence")
82
+ assert "already collecting" in ev.texts[0] and "2 files collected" in ev.texts[0] and data_of(ev.out[0][1]) == ["sq:sort", "sq:cx"]
83
+ assert len(ctx.app.modes[OWNER].data.items) == 2
84
+
85
+
86
+ async def test_the_limit_is_announced_once_and_extra_files_are_ignored(ctx, monkeypatch):
87
+ monkeypatch.setattr(appmod, "SEQUENCE_MAX", 3)
88
+ await say(ctx, OWNER, "/sequence")
89
+ replies = []
90
+ for k in range(6):
91
+ replies += (await send_doc(ctx, OWNER, OP.format(n=k + 1), mid=100 + k)).texts
92
+ assert len(ctx.app.modes[OWNER].data.items) == 3
93
+ assert len(replies) == 1 and "limit: 3 files" in replies[0]
94
+ await settle()
95
+ assert "3 files collected" in texts(ctx)[-1] and "most I can sort" in texts(ctx)[-1]
96
+
97
+
98
+ async def test_every_user_has_their_own_sequence(ctx):
99
+ await say(ctx, OWNER, "/sequence")
100
+ await say(ctx, ADMIN_CFG, "/sequence")
101
+ await feed(ctx, (3, 1, 2), uid=OWNER, first_id=100)
102
+ await feed(ctx, (9, 8), uid=ADMIN_CFG, first_id=500)
103
+ await say(ctx, ADMIN_CFG, "/sort")
104
+ assert sent_ids(ctx, ADMIN_CFG) == [501, 500] and sent_ids(ctx, OWNER) == []
105
+ await say(ctx, OWNER, "/sort")
106
+ assert sent_ids(ctx, OWNER) == [101, 102, 100]
107
+
108
+
109
+ async def test_strangers_cannot_start_a_sequence(ctx):
110
+ ev = await say(ctx, STRANGER, "/sequence")
111
+ assert ev.out == [] and STRANGER not in ctx.app.modes
112
+
113
+
114
+ # ---------------------------------------------------------------------------
115
+ # /sort
116
+ # ---------------------------------------------------------------------------
117
+
118
+
119
+ async def test_sort_sends_the_files_back_in_ascending_episode_order(ctx):
120
+ await say(ctx, OWNER, "/sequence")
121
+ ids = await feed(ctx, (1147, 1145, 1148, 1146))
122
+ await say(ctx, OWNER, "/sort")
123
+ assert sent_ids(ctx) == [ids[1145], ids[1146], ids[1147], ids[1148]]
124
+ assert OWNER not in ctx.app.modes
125
+ assert "Sorted 4 files" in last_text(ctx) and "E1145 → E1148" in last_text(ctx) and "No missing or repeated episodes." in last_text(ctx)
126
+
127
+
128
+ async def test_sort_reports_missing_and_repeated_episodes_after_the_files(ctx):
129
+ await say(ctx, OWNER, "/sequence")
130
+ await feed(ctx, (1147, 1145, 1149, 1146, 1146, 1152))
131
+ await say(ctx, OWNER, "/sort")
132
+ assert len(sent_ids(ctx)) == 6
133
+ report = last_text(ctx)
134
+ assert "Sorted 6 files" in report and "E1145 → E1152" in report
135
+ assert "Missing episodes</b>\nE1148, E1150–E1151" in report
136
+ assert "Repeated episodes" in report and "E1146 ×2" in report
137
+ assert "No missing or repeated" not in report
138
+ assert ctx.client.sent[-1][2]["parse_mode"] == "html"
139
+
140
+
141
+ async def test_files_without_an_episode_number_are_sent_last_and_listed(ctx):
142
+ await say(ctx, OWNER, "/sequence")
143
+ await send_doc(ctx, OWNER, "notes.mkv", mid=100)
144
+ await send_doc(ctx, OWNER, OP.format(n=2), mid=101)
145
+ await send_doc(ctx, OWNER, OP.format(n=1), mid=102)
146
+ await say(ctx, OWNER, "/sort")
147
+ assert sent_ids(ctx) == [102, 101, 100]
148
+ assert "No episode number found" in last_text(ctx) and "notes.mkv" in last_text(ctx)
149
+
150
+
151
+ async def test_captions_and_their_formatting_travel_with_each_file(ctx):
152
+ await say(ctx, OWNER, "/sequence")
153
+ await send_doc(ctx, OWNER, OP.format(n=2), mid=100, caption="Episode two <b>bold</b>")
154
+ await send_doc(ctx, OWNER, OP.format(n=1), mid=101)
155
+ ctx.client.messages[100].entities = ["BOLD-ENTITY"]
156
+ await say(ctx, OWNER, "/sort")
157
+ (_e1, first, kw1), (_e2, second, kw2) = ctx.client.files
158
+ assert (first.id, second.id) == (101, 100)
159
+ assert kw1["caption"] == "" and kw2["caption"] == "Episode two <b>bold</b>"
160
+ assert kw2["formatting_entities"] == ["BOLD-ENTITY"] and kw2["parse_mode"] is None # the text is not parsed a second time
161
+
162
+
163
+ async def test_sort_fetches_the_messages_again_so_old_file_references_still_work(ctx):
164
+ await say(ctx, OWNER, "/sequence")
165
+ await feed(ctx, (2, 1, 3))
166
+ await say(ctx, OWNER, "/sort")
167
+ (chat, ids), = ctx.client.get_calls
168
+ assert chat == OWNER and sorted(ids) == [100, 101, 102] and ids == [101, 100, 102]
169
+
170
+
171
+ async def test_the_rename_mode_decides_where_the_episode_is_read_from(ctx):
172
+ await say(ctx, OWNER, "/mode caption")
173
+ await say(ctx, OWNER, "/sequence")
174
+ await send_doc(ctx, OWNER, "video.mkv", mid=100, caption="One Piece - 1147")
175
+ await send_doc(ctx, OWNER, "other.mkv", mid=101, caption="One Piece - 1145")
176
+ await send_doc(ctx, OWNER, "clip.mkv", mid=102, caption="One Piece - 1146")
177
+ await say(ctx, OWNER, "/sort")
178
+ assert sent_ids(ctx) == [101, 102, 100]
179
+ assert "E1145 → E1147" in last_text(ctx)
180
+
181
+
182
+ async def test_sort_with_nothing_collected_explains_how_it_works(ctx):
183
+ for _ in range(2):
184
+ await say(ctx, OWNER, "/sort")
185
+ assert "Nothing to sort yet" in last_text(ctx) and "/sequence" in last_text(ctx)
186
+ await say(ctx, OWNER, "/sequence") # started but empty: still nothing to sort
187
+ assert ctx.client.files == []
188
+
189
+
190
+ async def test_after_a_sort_files_are_renamed_as_usual_again(ctx):
191
+ await say(ctx, OWNER, "/sequence")
192
+ await feed(ctx, (1, 2))
193
+ await say(ctx, OWNER, "/sort")
194
+ ev = await send_doc(ctx, OWNER, OP.format(n=3), mid=300)
195
+ assert "Queued" in ev.texts[0] and ctx.app.queue.waiting == 1
196
+ # and a new sequence starts from scratch
197
+ await say(ctx, OWNER, "/sequence")
198
+ await feed(ctx, (7,), first_id=400)
199
+ await say(ctx, OWNER, "/sort")
200
+ assert sent_ids(ctx)[-1] == 400 and "Sorted 1 file" in last_text(ctx)
201
+
202
+
203
+ async def test_a_sequence_that_nobody_finished_is_forgotten_after_its_time(ctx):
204
+ await say(ctx, OWNER, "/sequence")
205
+ await feed(ctx, (1, 2))
206
+ ctx.app.modes[OWNER].expires = time.time() - 1
207
+ await say(ctx, OWNER, "/sort")
208
+ assert "Nothing to sort yet" in last_text(ctx) and ctx.client.files == []
209
+ ev = await send_doc(ctx, OWNER, OP.format(n=3), mid=300)
210
+ assert "Queued" in ev.texts[0]
211
+
212
+
213
+ async def test_every_new_file_gives_the_sequence_more_time(ctx):
214
+ await say(ctx, OWNER, "/sequence")
215
+ mode = ctx.app.modes[OWNER]
216
+ mode.expires = time.time() + 5
217
+ await feed(ctx, (1,))
218
+ assert mode.expires > time.time() + appmod.SEQUENCE_WAIT - 60
219
+
220
+
221
+ async def test_the_status_message_is_removed_and_a_progress_message_replaces_it(ctx, monkeypatch):
222
+ monkeypatch.setattr(appmod, "SORT_PROGRESS_EVERY", 2)
223
+ await say(ctx, OWNER, "/sequence")
224
+ await feed(ctx, (5, 4, 3, 2, 1))
225
+ await settle()
226
+ status_id = [2000 + i + 1 for i, (_e, t, _k) in enumerate(ctx.client.sent) if "collected" in t][-1]
227
+ await say(ctx, OWNER, "/sort")
228
+ assert (OWNER, [status_id]) in ctx.client.deleted
229
+ progress = [t for _c, t, _k in ctx.client.edited]
230
+ assert progress == ["🔢 <b>Sorting…</b> 2 of 5 sent", "🔢 <b>Sorting…</b> 4 of 5 sent"]
231
+ sorting_id = [2000 + i + 1 for i, (_e, t, _k) in enumerate(ctx.client.sent) if "Sorting 5 files" in t][0]
232
+ assert (OWNER, [sorting_id]) in ctx.client.deleted # the progress message goes before the report
233
+
234
+
235
+ # ---------------------------------------------------------------------------
236
+ # buttons
237
+ # ---------------------------------------------------------------------------
238
+
239
+
240
+ async def test_the_sort_button_does_the_same_as_the_command(ctx):
241
+ await say(ctx, OWNER, "/sequence")
242
+ await feed(ctx, (3, 1, 2))
243
+ cb = await click(ctx, OWNER, "sq:sort")
244
+ assert cb.answers and sent_ids(ctx) == [101, 102, 100] and "Sorted 3 files" in last_text(ctx)
245
+
246
+
247
+ async def test_the_discard_button_throws_the_files_away(ctx):
248
+ await say(ctx, OWNER, "/sequence")
249
+ await feed(ctx, (3, 1))
250
+ cb = await click(ctx, OWNER, "sq:cx")
251
+ assert "2 files discarded" in cb.edits[-1][0] and cb.edits[-1][1] == []
252
+ assert OWNER not in ctx.app.modes
253
+ await say(ctx, OWNER, "/sort")
254
+ assert "Nothing to sort yet" in last_text(ctx)
255
+
256
+
257
+ async def test_old_buttons_say_the_sequence_is_over(ctx):
258
+ for data in ("sq:sort", "sq:cx"):
259
+ cb = await click(ctx, OWNER, data)
260
+ assert cb.alerts == ["That sequence is over."]
261
+ assert ctx.client.files == [] and ctx.client.sent == []
262
+
263
+
264
+ # ---------------------------------------------------------------------------
265
+ # /cancel
266
+ # ---------------------------------------------------------------------------
267
+
268
+
269
+ async def test_cancel_discards_the_collected_files(ctx):
270
+ await say(ctx, OWNER, "/sequence")
271
+ await feed(ctx, (2, 1))
272
+ await settle()
273
+ status_id = 2001 # the "2 files collected" message
274
+ before = len([t for t in texts(ctx) if "collected" in t])
275
+ ev = await say(ctx, OWNER, "/cancel")
276
+ assert "Sequence cancelled" in ev.texts[0] and "2 files discarded" in ev.texts[0]
277
+ assert (OWNER, [status_id]) in ctx.client.deleted # its buttons would be dead, so it goes
278
+ await say(ctx, OWNER, "/sort")
279
+ assert "Nothing to sort yet" in last_text(ctx)
280
+ await settle()
281
+ assert len([t for t in texts(ctx) if "collected" in t]) == before == 1 # no new status after the cancel
282
+ ev = await send_doc(ctx, OWNER, OP.format(n=1), mid=300)
283
+ assert "Queued" in ev.texts[0]
284
+
285
+
286
+ async def test_cancel_leaves_queued_jobs_for_the_next_cancel(ctx):
287
+ await send_doc(ctx, OWNER, OP.format(n=9), mid=50)
288
+ assert ctx.app.queue.waiting == 1
289
+ await say(ctx, OWNER, "/sequence")
290
+ await feed(ctx, (2,))
291
+ ev = await say(ctx, OWNER, "/cancel")
292
+ assert "Sequence cancelled" in ev.texts[0] and "/cancel again" in ev.texts[0] and ctx.app.queue.waiting == 1
293
+ ev = await say(ctx, OWNER, "/cancel")
294
+ assert "Cancelling 1 job" in ev.texts[0]
295
+
296
+
297
+ async def test_cancel_stops_a_sort_that_is_sending(ctx):
298
+ await say(ctx, OWNER, "/sequence")
299
+ await feed(ctx, (5, 4, 3, 2, 1))
300
+ gate = asyncio.Event()
301
+ original = ctx.client.send_file
302
+ calls = []
303
+
304
+ async def slow(entity, file, **kw):
305
+ calls.append(file.id)
306
+ if len(calls) == 2:
307
+ await gate.wait()
308
+ return await original(entity, file, **kw)
309
+
310
+ ctx.client.send_file = slow
311
+ sorting = asyncio.ensure_future(say(ctx, OWNER, "/sort"))
312
+ await settle(0.05)
313
+ assert calls == [104, 103] # episode 1, then 2 is in flight
314
+
315
+ again = await say(ctx, OWNER, "/sort") # a second /sort while one is running
316
+ assert again.out == [] and "still sorting" in last_text(ctx)
317
+ ev = await say(ctx, OWNER, "/cancel")
318
+ assert "Stopping the sort" in ev.texts[0]
319
+ gate.set()
320
+ await sorting
321
+ assert sent_ids(ctx) == [104, 103] and "Stopped after 2 of 5 files" in last_text(ctx)
322
+ assert "Sorted" not in last_text(ctx) and OWNER not in ctx.app._sorting
323
+
324
+
325
+ # ---------------------------------------------------------------------------
326
+ # things going wrong while sending
327
+ # ---------------------------------------------------------------------------
328
+
329
+
330
+ async def test_a_short_flood_wait_is_waited_out_and_the_file_is_sent_again(ctx):
331
+ slept = []
332
+
333
+ async def pause(seconds):
334
+ slept.append(seconds)
335
+
336
+ ctx.app._pause = pause
337
+ await say(ctx, OWNER, "/sequence")
338
+ await feed(ctx, (2, 1, 3))
339
+ ctx.client.file_errors[100] = [errors.FloodWaitError(None, 90)]
340
+ await say(ctx, OWNER, "/sort")
341
+ assert sent_ids(ctx) == [101, 100, 102] and slept == [91]
342
+ assert "Sorted 3 files" in last_text(ctx) and "Couldn't be sent" not in last_text(ctx)
343
+
344
+
345
+ async def test_a_very_long_flood_wait_stops_the_sort_and_lists_what_was_not_sent(ctx):
346
+ await say(ctx, OWNER, "/sequence")
347
+ await feed(ctx, (4, 3, 2, 1))
348
+ ctx.client.file_errors[102] = [errors.FloodWaitError(None, 3600)] # episode 2
349
+ await say(ctx, OWNER, "/sort")
350
+ assert sent_ids(ctx) == [103] # episode 1 went out, the wait hit at episode 2
351
+ report = last_text(ctx)
352
+ assert "Sorted 1 of 4 files" in report and "wait 1h 0m 0s" in report and "Couldn't be sent" in report
353
+ assert report.count(OP.format(n=2)[:20]) >= 1 and OP.format(n=4)[:4] in report
354
+
355
+
356
+ async def test_a_status_that_is_still_being_sent_when_sort_starts_is_cleaned_up(ctx):
357
+ original = ctx.client.send_message
358
+ release = asyncio.Event()
359
+
360
+ async def slow_status(entity, text="", **kw):
361
+ if "collected" in text:
362
+ await release.wait()
363
+ return await original(entity, text, **kw)
364
+
365
+ ctx.client.send_message = slow_status
366
+ await say(ctx, OWNER, "/sequence")
367
+ await feed(ctx, (2, 1))
368
+ await settle() # the status message is on its way, not sent yet
369
+ await say(ctx, OWNER, "/sort")
370
+ release.set()
371
+ await settle()
372
+ collected = [i + 1 + 2000 for i, (_e, t, _k) in enumerate(ctx.client.sent) if "collected" in t]
373
+ assert len(collected) == 1 and (OWNER, collected) in ctx.client.deleted
374
+ assert "Sorted 2 files" in last_text(ctx) or sent_ids(ctx) == [101, 100]
375
+
376
+
377
+ async def test_a_file_that_fails_is_listed_and_the_others_still_arrive(ctx):
378
+ await say(ctx, OWNER, "/sequence")
379
+ await feed(ctx, (3, 2, 1))
380
+ ctx.client.file_errors[101] = [RuntimeError("MEDIA_EMPTY")] # episode 2
381
+ await say(ctx, OWNER, "/sort")
382
+ assert sent_ids(ctx) == [102, 100]
383
+ report = last_text(ctx)
384
+ assert "Sorted 2 of 3 files" in report and "Couldn't be sent" in report and "2 - One Piece" in report
385
+
386
+
387
+ async def test_a_file_deleted_before_sorting_is_listed_not_fatal(ctx):
388
+ await say(ctx, OWNER, "/sequence")
389
+ await feed(ctx, (3, 2, 1))
390
+ del ctx.client.messages[101]
391
+ await say(ctx, OWNER, "/sort")
392
+ assert sent_ids(ctx) == [102, 100]
393
+ assert "Sorted 2 of 3 files" in last_text(ctx) and "Couldn't be sent" in last_text(ctx)
394
+ assert OWNER not in ctx.app._sorting
395
+
396
+
397
+ async def test_sort_that_blows_up_does_not_leave_the_user_locked_out(ctx):
398
+ await say(ctx, OWNER, "/sequence")
399
+ await feed(ctx, (2, 1))
400
+
401
+ async def boom(*a, **kw):
402
+ raise RuntimeError("telegram is down")
403
+
404
+ original, ctx.client.get_messages = ctx.client.get_messages, boom
405
+ ev = await say(ctx, OWNER, "/sort")
406
+ assert "Something went wrong" in ev.texts[-1] and OWNER not in ctx.app._sorting
407
+ ctx.client.get_messages = original
408
+ await say(ctx, OWNER, "/sequence")
409
+ await feed(ctx, (1,), first_id=300)
410
+ await say(ctx, OWNER, "/sort")
411
+ assert sent_ids(ctx) == [300]
412
+
413
+
414
+ # ---------------------------------------------------------------------------
415
+ # other commands that wait for a file
416
+ # ---------------------------------------------------------------------------
417
+
418
+
419
+ async def test_starting_a_rename_ends_the_sequence_and_says_so(ctx):
420
+ fmt = await ctx.db.create_format(OWNER, "One Piece")
421
+ await say(ctx, OWNER, "/sequence")
422
+ await feed(ctx, (1, 2))
423
+ cb = await click(ctx, OWNER, f"rn:p:{fmt['id']}")
424
+ assert "One Piece" in cb.edits[-1][0] and "2 file(s) was discarded" in cb.edits[-1][0]
425
+ assert ctx.app.modes[OWNER].kind == "rename"
426
+ await settle()
427
+ assert [t for t in texts(ctx) if "collected" in t] == []
428
+
429
+
430
+ async def test_a_rename_with_no_sequence_has_no_such_note(ctx):
431
+ fmt = await ctx.db.create_format(OWNER, "One Piece")
432
+ cb = await click(ctx, OWNER, f"rn:p:{fmt['id']}")
433
+ assert "discarded" not in cb.edits[-1][0]
434
+
435
+
436
+ async def test_starting_a_sequence_over_a_rename_session_replaces_it(ctx):
437
+ fmt = await ctx.db.create_format(OWNER, "One Piece")
438
+ await click(ctx, OWNER, f"rn:p:{fmt['id']}")
439
+ ev = await say(ctx, OWNER, "/sequence")
440
+ assert "discarded" not in ev.texts[0] and ctx.app.modes[OWNER].kind == "sequence"
441
+ await send_doc(ctx, OWNER, OP.format(n=1), mid=100)
442
+ assert ctx.app.queue.waiting == 0 # collected, not renamed
tests/test_smedia.py ADDED
@@ -0,0 +1,337 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """/smedia: the owner picks an image or a GIF, and /start shows it above the menu."""
2
+ import base64
3
+ import io
4
+ import json
5
+ import time
6
+ from types import SimpleNamespace
7
+
8
+ from PIL import Image
9
+ from telethon import types
10
+
11
+ from bot import app as appmod
12
+
13
+ from .appkit import ADMIN_CFG, OWNER, STRANGER, click, data_of, jpeg, say, send_doc
14
+ from .tgfakes import FakeEvent, FakeMessage, dispatch, make_file
15
+
16
+ GIF_BYTES = b"\x00\x00\x00\x18ftypmp42-fake-animation-bytes"
17
+
18
+
19
+ def uploads(ctx):
20
+ """The files the bot uploaded (bytes), as (chat, file, kwargs)."""
21
+ return [(entity, file, kw) for entity, file, kw in ctx.client.files if isinstance(file, io.BytesIO)]
22
+
23
+
24
+ async def send_photo(ctx, mid=50, data=None, sender=OWNER):
25
+ ctx.client.media[mid] = data if data is not None else jpeg((800, 600))
26
+ ev = FakeEvent(sender, message=FakeMessage("", photo=True, mid=mid), photo_bytes=jpeg()) # photo_bytes: what a thumbnail is made of
27
+ await dispatch(ctx.client, ev)
28
+ return ev
29
+
30
+
31
+ async def send_image_file(ctx, name="banner.png", mid=51, data=None, size=200 * 1024, sender=OWNER):
32
+ ctx.client.media[mid] = data if data is not None else jpeg((800, 600))
33
+ return await send_doc(ctx, sender, name, size=size, video=False, mime="image/png", mid=mid)
34
+
35
+
36
+ async def send_gif(ctx, mid=60, data=GIF_BYTES, size=1024 * 1024, sender=OWNER):
37
+ ctx.client.media[mid] = data
38
+ file = make_file("anim.mp4", size, "video/mp4", width=480, height=270, duration=3.5)
39
+ ev = FakeEvent(sender, message=FakeMessage("", mid=mid, file=file, video=True, gif=True))
40
+ await dispatch(ctx.client, ev)
41
+ return ev
42
+
43
+
44
+ async def choose(ctx, kind):
45
+ return await click(ctx, OWNER, f"sm:{kind}")
46
+
47
+
48
+ async def stored(ctx):
49
+ """(description, file bytes) of the saved /start media, or (None, None)."""
50
+ meta = await ctx.db.kv_get("start_media")
51
+ data = await ctx.db.kv_get("start_media_data")
52
+ return (json.loads(meta) if meta else None), (base64.b64decode(data) if data else None)
53
+
54
+
55
+ def telegram_gives_ids(ctx, *ids):
56
+ """Make send_file answer like Telegram does: with a message whose file has an id."""
57
+ original, wanted = ctx.client.send_file, iter(ids)
58
+
59
+ async def send_file(entity, file, **kw):
60
+ sent = await original(entity, file, **kw)
61
+ sent.file = SimpleNamespace(id=next(wanted, "FILEID-LATER"))
62
+ return sent
63
+
64
+ ctx.client.send_file = send_file
65
+
66
+
67
+ def file_ids_go_stale(ctx):
68
+ """Sending by file id fails (an expired reference); uploading still works."""
69
+ original = ctx.client.send_file
70
+
71
+ async def send_file(entity, file, **kw):
72
+ if isinstance(file, str):
73
+ raise RuntimeError("FILE_REFERENCE_EXPIRED")
74
+ return await original(entity, file, **kw)
75
+
76
+ ctx.client.send_file = send_file
77
+
78
+
79
+ # ---------------------------------------------------------------------------
80
+ # the menu
81
+ # ---------------------------------------------------------------------------
82
+
83
+
84
+ async def test_the_menu_offers_image_and_gif(ctx):
85
+ ev = await say(ctx, OWNER, "/smedia")
86
+ assert "START MEDIA" in ev.texts[0] and "/start shows: <b>nothing, just the menu</b>" in ev.texts[0]
87
+ assert data_of(ev.out[0][1]) == ["sm:image", "sm:gif", "x:close"] # nothing to remove yet
88
+ assert OWNER not in ctx.app.modes
89
+
90
+
91
+ async def test_only_the_owner_can_use_it(ctx):
92
+ assert "smedia" in appmod.OWNER_COMMANDS and "smedia" not in dict(appmod.COMMANDS) # not in the public command menu
93
+ ev = await say(ctx, ADMIN_CFG, "/smedia")
94
+ assert ev.texts == ["⛔ Only the owner can do that."]
95
+ assert (await say(ctx, STRANGER, "/smedia")).out == []
96
+ cb = await click(ctx, ADMIN_CFG, "sm:image") # an allowed user who got hold of the buttons
97
+ assert cb.alerts == ["Owner only."] and ADMIN_CFG not in ctx.app.modes
98
+ assert not hasattr(ctx.cfg, "start_image") # the START_IMAGE setting is gone
99
+
100
+
101
+ async def test_choosing_makes_the_bot_wait_for_that_kind_of_media(ctx):
102
+ cb = await choose(ctx, "image")
103
+ assert "Send the image" in cb.edits[-1][0] and "/cancel to stop" in cb.edits[-1][0]
104
+ assert data_of(cb.edits[-1][1]) == ["sm:menu", "sm:stop"]
105
+ mode = ctx.app.modes[OWNER]
106
+ assert mode.kind == "smedia" and mode.data == {"kind": "image"} and mode.expires > time.time() + 500
107
+ cb = await choose(ctx, "gif")
108
+ assert "Send the GIF" in cb.edits[-1][0] and ctx.app.modes[OWNER].data == {"kind": "gif"}
109
+
110
+
111
+ async def test_back_stop_and_cancel_leave_nothing_waiting(ctx):
112
+ await choose(ctx, "image")
113
+ cb = await click(ctx, OWNER, "sm:menu")
114
+ assert "START MEDIA" in cb.edits[-1][0] and OWNER not in ctx.app.modes
115
+ await choose(ctx, "gif")
116
+ cb = await click(ctx, OWNER, "sm:stop")
117
+ assert "Stopped" in cb.edits[-1][0] and OWNER not in ctx.app.modes
118
+ await choose(ctx, "gif")
119
+ ev = await say(ctx, OWNER, "/cancel")
120
+ assert "Send /smedia again" in ev.texts[0] and OWNER not in ctx.app.modes
121
+
122
+
123
+ # ---------------------------------------------------------------------------
124
+ # an image
125
+ # ---------------------------------------------------------------------------
126
+
127
+
128
+ async def test_a_photo_becomes_the_start_image_and_is_shown_right_away(ctx):
129
+ await choose(ctx, "image")
130
+ photo = jpeg((800, 600))
131
+ ev = await send_photo(ctx, data=photo)
132
+ (entity, file, kw), = uploads(ctx)
133
+ assert entity == OWNER and file.name == "start.jpg" and file.getvalue() == photo
134
+ assert "Auto Rename Bot" in kw["caption"] and "m:rename" in data_of(kw["buttons"]) and kw["parse_mode"] == "html"
135
+ assert ev.texts == ["✅ Done: /start shows this image from now on."]
136
+ assert OWNER not in ctx.app.modes and ctx.client.downloads == [(50, bytes, None)]
137
+ meta, data = await stored(ctx)
138
+ assert meta == {"kind": "image", "file_id": ""} and data == photo
139
+
140
+
141
+ async def test_the_menu_then_shows_what_is_set_and_offers_to_remove_it(ctx):
142
+ await choose(ctx, "image")
143
+ await send_photo(ctx)
144
+ ev = await say(ctx, OWNER, "/smedia")
145
+ assert "/start shows: <b>an image</b>" in ev.texts[0] and data_of(ev.out[0][1]) == ["sm:image", "sm:gif", "sm:rm", "x:close"]
146
+
147
+
148
+ async def test_an_image_sent_as_a_file_is_made_to_fit_without_being_enlarged(ctx):
149
+ await choose(ctx, "image")
150
+ ev = await send_image_file(ctx, data=jpeg((3000, 2000)))
151
+ assert ev.texts == ["✅ Done: /start shows this image from now on."] and ctx.app.queue.waiting == 0 # not renamed
152
+ _meta, data = await stored(ctx)
153
+ img = Image.open(io.BytesIO(data))
154
+ assert img.format == "JPEG" and img.size == (1280, 853)
155
+ await choose(ctx, "image")
156
+ await send_image_file(ctx, mid=52, data=jpeg((400, 300)))
157
+ assert Image.open(io.BytesIO((await stored(ctx))[1])).size == (400, 300) # smaller pictures stay as they are
158
+
159
+
160
+ async def test_what_is_not_an_image_does_not_use_up_the_request(ctx):
161
+ await choose(ctx, "image")
162
+ ev = await send_doc(ctx, OWNER, "Episode.mkv", mid=53)
163
+ assert "isn't an image" in ev.texts[0] and ctx.app.queue.waiting == 0 and uploads(ctx) == [] # not renamed either
164
+ ev = await send_gif(ctx)
165
+ assert "isn't an image" in ev.texts[0]
166
+ assert ctx.app.modes[OWNER].kind == "smedia"
167
+ await send_photo(ctx)
168
+ assert len(uploads(ctx)) == 1 and (await stored(ctx))[0]["kind"] == "image"
169
+
170
+
171
+ async def test_a_picture_that_cannot_be_read_is_refused(ctx):
172
+ await choose(ctx, "image")
173
+ ev = await send_image_file(ctx, data=b"this is not a picture")
174
+ assert "couldn't read that image" in ev.texts[0] and await stored(ctx) == (None, None) and uploads(ctx) == []
175
+
176
+
177
+ async def test_a_download_that_gives_nothing_is_reported(ctx):
178
+ await choose(ctx, "image")
179
+ ev = await send_photo(ctx, data=b"")
180
+ assert "couldn't download that" in ev.texts[0] and "/smedia" in ev.texts[0]
181
+ assert await stored(ctx) == (None, None) and OWNER not in ctx.app.modes
182
+
183
+
184
+ async def test_a_file_that_is_too_big_is_refused_and_the_request_stays(ctx):
185
+ await choose(ctx, "image")
186
+ ev = await send_image_file(ctx, size=appmod.START_MEDIA_MAX_BYTES + 1)
187
+ assert "the biggest I can keep is 10.00 MB" in ev.texts[0] and await stored(ctx) == (None, None)
188
+ assert ctx.app.modes[OWNER].kind == "smedia" and ctx.client.downloads == [] # refused before downloading
189
+
190
+
191
+ # ---------------------------------------------------------------------------
192
+ # a GIF
193
+ # ---------------------------------------------------------------------------
194
+
195
+
196
+ async def test_a_gif_becomes_the_start_gif_and_is_sent_as_an_animation(ctx):
197
+ await choose(ctx, "gif")
198
+ ev = await send_gif(ctx)
199
+ assert ev.texts == ["✅ Done: /start shows this GIF from now on."]
200
+ (entity, file, kw), = uploads(ctx)
201
+ assert entity == OWNER and file.name == "start.mp4" and file.getvalue() == GIF_BYTES and kw["mime_type"] == "video/mp4"
202
+ video = next(a for a in kw["attributes"] if isinstance(a, types.DocumentAttributeVideo))
203
+ assert (video.duration, video.w, video.h, video.supports_streaming) == (3.5, 480, 270, True)
204
+ assert any(isinstance(a, types.DocumentAttributeAnimated) for a in kw["attributes"])
205
+ assert "Auto Rename Bot" in kw["caption"] and kw["buttons"]
206
+ meta, data = await stored(ctx)
207
+ assert meta == {"kind": "gif", "mime": "video/mp4", "width": 480, "height": 270, "duration": 3.5, "file_id": ""} and data == GIF_BYTES
208
+ assert "/start shows: <b>a GIF</b>" in (await say(ctx, OWNER, "/smedia")).texts[0]
209
+
210
+
211
+ async def test_what_is_not_a_gif_does_not_use_up_the_request(ctx):
212
+ await choose(ctx, "gif")
213
+ for ev in (await send_photo(ctx), await send_doc(ctx, OWNER, "Episode.mkv", mid=54)):
214
+ assert "isn't a GIF" in ev.texts[0]
215
+ assert ctx.app.queue.waiting == 0 and ctx.app.modes[OWNER].data == {"kind": "gif"} and await stored(ctx) == (None, None)
216
+ await send_gif(ctx)
217
+ assert (await stored(ctx))[0]["kind"] == "gif"
218
+
219
+
220
+ async def test_a_gif_nobody_asked_for_is_ignored(ctx):
221
+ ev = await send_gif(ctx)
222
+ assert ev.out == [] and await stored(ctx) == (None, None)
223
+ assert (await send_gif(ctx, sender=STRANGER)).out == []
224
+
225
+
226
+ # ---------------------------------------------------------------------------
227
+ # one file per request
228
+ # ---------------------------------------------------------------------------
229
+
230
+
231
+ async def test_extra_files_right_after_are_skipped_and_do_not_become_the_thumbnail(ctx):
232
+ await choose(ctx, "image")
233
+ await send_photo(ctx, mid=50)
234
+ extra = await send_photo(ctx, mid=51)
235
+ assert "/smedia handles one file at a time" in extra.texts[0]
236
+ assert (await send_photo(ctx, mid=52)).out == [] # the hint is given once
237
+ assert (await send_gif(ctx)).out == [] and (await send_image_file(ctx, mid=53)).out == []
238
+ assert len(uploads(ctx)) == 1 and not await ctx.db.has_thumb(OWNER) and ctx.app.queue.waiting == 0
239
+ ctx.app._recent[OWNER][1] -= appmod.ONE_SHOT_GRACE + 1
240
+ await send_photo(ctx, mid=54)
241
+ assert await ctx.db.has_thumb(OWNER) # after the grace a photo is a thumbnail again
242
+
243
+
244
+ # ---------------------------------------------------------------------------
245
+ # /start
246
+ # ---------------------------------------------------------------------------
247
+
248
+
249
+ async def test_start_without_media_is_just_the_menu(ctx):
250
+ ev = await say(ctx, OWNER, "/start")
251
+ assert "Auto Rename Bot" in ev.texts[0] and ctx.client.files == []
252
+
253
+
254
+ async def test_start_reuses_the_telegram_file_instead_of_uploading_again(ctx):
255
+ telegram_gives_ids(ctx, "FILEID-1")
256
+ await choose(ctx, "image")
257
+ await send_photo(ctx)
258
+ assert (await stored(ctx))[0]["file_id"] == "FILEID-1" # remembered from the preview
259
+ ev = await say(ctx, ADMIN_CFG, "/start") # every allowed user sees it
260
+ assert ev.out == [] and len(uploads(ctx)) == 1
261
+ (entity, file, kw) = ctx.client.files[-1]
262
+ assert entity == ADMIN_CFG and file == "FILEID-1" and "Auto Rename Bot" in kw["caption"] and "m:rename" in data_of(kw["buttons"])
263
+ assert "m:admin" not in data_of(kw["buttons"])
264
+
265
+
266
+ async def test_a_stale_file_id_falls_back_to_the_stored_copy_and_is_replaced(ctx):
267
+ telegram_gives_ids(ctx, "FILEID-1", "FILEID-2")
268
+ await choose(ctx, "gif")
269
+ await send_gif(ctx)
270
+ file_ids_go_stale(ctx)
271
+ ev = await say(ctx, OWNER, "/start")
272
+ assert ev.out == [] # still shown with its picture
273
+ entity, file, kw = ctx.client.files[-1]
274
+ assert isinstance(file, io.BytesIO) and file.getvalue() == GIF_BYTES and kw["mime_type"] == "video/mp4"
275
+ assert (await stored(ctx))[0]["file_id"] == "FILEID-2" # the next /start can use the fresh one
276
+
277
+
278
+ async def test_when_nothing_can_be_sent_start_still_shows_the_menu(ctx):
279
+ await choose(ctx, "image")
280
+ await send_photo(ctx)
281
+ ctx.client.fail_send_file = True
282
+ ev = await say(ctx, OWNER, "/start")
283
+ assert "Auto Rename Bot" in ev.texts[0] and data_of(ev.out[0][1])[0] == "m:rename"
284
+
285
+
286
+ async def test_damaged_saved_media_never_breaks_start(ctx):
287
+ for meta, data in (
288
+ ("not json", "AAAA"), (json.dumps({"kind": "video", "file_id": ""}), "AAAA"), (json.dumps(["kind"]), "AAAA"),
289
+ (json.dumps({"kind": "image", "file_id": ""}), "!!! not base64 !!!"), (json.dumps({"kind": "image", "file_id": ""}), ""),
290
+ ):
291
+ await ctx.app._store_start_media(meta, data)
292
+ ev = await say(ctx, OWNER, "/start")
293
+ assert "Auto Rename Bot" in ev.texts[0], meta
294
+ assert ctx.client.files == []
295
+
296
+
297
+ async def test_the_menu_fits_in_a_caption(ctx):
298
+ await choose(ctx, "image")
299
+ await send_photo(ctx)
300
+ await say(ctx, OWNER, "/template " + "{title} " * 30)
301
+ await say(ctx, OWNER, "/start")
302
+ assert appmod.visible_len(ctx.client.files[-1][2]["caption"]) < 1024
303
+
304
+
305
+ # ---------------------------------------------------------------------------
306
+ # when Telegram refuses it, and removing it
307
+ # ---------------------------------------------------------------------------
308
+
309
+
310
+ async def test_a_picture_telegram_refuses_leaves_the_old_one_in_place(ctx):
311
+ await choose(ctx, "image")
312
+ first = jpeg((640, 480))
313
+ await send_photo(ctx, data=first)
314
+ before = await stored(ctx)
315
+ ctx.client.fail_send_file = True
316
+ await choose(ctx, "image")
317
+ ev = await send_photo(ctx, mid=55, data=jpeg((300, 300)))
318
+ assert "wouldn't take that" in ev.texts[0] and "nothing was changed" in ev.texts[0]
319
+ assert await stored(ctx) == before == ({"kind": "image", "file_id": ""}, first)
320
+
321
+
322
+ async def test_a_picture_telegram_refuses_is_not_kept_when_there_was_none(ctx):
323
+ ctx.client.fail_send_file = True
324
+ await choose(ctx, "gif")
325
+ ev = await send_gif(ctx)
326
+ assert "wouldn't take that" in ev.texts[0] and await stored(ctx) == (None, None)
327
+
328
+
329
+ async def test_remove_goes_back_to_the_plain_menu(ctx):
330
+ await choose(ctx, "image")
331
+ await send_photo(ctx)
332
+ cb = await click(ctx, OWNER, "sm:rm")
333
+ assert "nothing, just the menu" in cb.edits[-1][0] and data_of(cb.edits[-1][1]) == ["sm:image", "sm:gif", "x:close"]
334
+ assert cb.toasts == ["Start media removed."] and await stored(ctx) == (None, None)
335
+ count = len(ctx.client.files)
336
+ ev = await say(ctx, OWNER, "/start")
337
+ assert "Auto Rename Bot" in ev.texts[0] and len(ctx.client.files) == count
tests/test_speed.py ADDED
@@ -0,0 +1,255 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import json
3
+ import os
4
+ import shutil
5
+ import stat
6
+ import sys
7
+ import time
8
+
9
+ import pytest
10
+
11
+ from bot import speed
12
+ from bot.speed import SpeedError, SpeedResult
13
+
14
+ CLI_RESULT = {
15
+ "type": "result", "timestamp": "2026-10-21T04:00:00Z",
16
+ "ping": {"jitter": 0.52, "latency": 4.21, "low": 3.9, "high": 5.0},
17
+ "download": {"bandwidth": 31_250_000, "bytes": 400_000_000, "elapsed": 12000},
18
+ "upload": {"bandwidth": 12_500_000, "bytes": 150_000_000, "elapsed": 11000},
19
+ "packetLoss": 0, "isp": "Hetzner Online GmbH",
20
+ "server": {"id": 1234, "host": "speed.example.net", "name": "Example Telecom", "location": "Frankfurt", "country": "Germany"},
21
+ "result": {"id": "abc", "url": "https://www.speedtest.net/result/c/abc"},
22
+ }
23
+ PY_RESULT = {
24
+ "download": 245_310_000.0, "upload": 118_200_000.0, "ping": 11.5, "share": "http://www.speedtest.net/result/1.png",
25
+ "server": {"sponsor": "Example Telecom", "name": "Frankfurt", "country": "Germany"},
26
+ "client": {"isp": "Hetzner Online GmbH", "ip": "1.2.3.4"},
27
+ }
28
+
29
+
30
+ def fake_binary(directory, name, version_text, body):
31
+ """An executable `name` that answers --version with ``version_text`` and anything else by running ``body`` (sh)."""
32
+ path = directory / name
33
+ path.write_text(f'#!/bin/sh\nif [ "$1" = "--version" ]; then\n echo "{version_text}"\n exit 0\nfi\n{body}\n')
34
+ path.chmod(path.stat().st_mode | stat.S_IEXEC)
35
+ return path
36
+
37
+
38
+ SLEEP = shutil.which("sleep") or "/bin/sleep" # PATH is replaced in these tests, so scripts need absolute paths
39
+ CAT = shutil.which("cat") or "/bin/cat"
40
+
41
+
42
+ def emit(bindir, obj):
43
+ """Shell line that prints ``obj`` as one JSON line (it lives in a file, so quotes inside cannot break the script)."""
44
+ path = bindir.parent / f"out{len(list(bindir.parent.glob('out*.json')))}.json"
45
+ path.write_text(json.dumps(obj) + "\n")
46
+ return f"{CAT} {path}"
47
+
48
+
49
+ @pytest.fixture
50
+ def bindir(tmp_path, monkeypatch):
51
+ d = tmp_path / "bin"
52
+ d.mkdir()
53
+ monkeypatch.setenv("PATH", str(d)) # only our fakes can be found
54
+ monkeypatch.delenv("SPEEDTEST_BIN", raising=False)
55
+ return d
56
+
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # parsing
60
+ # ---------------------------------------------------------------------------
61
+
62
+
63
+ def test_parse_cli_converts_bytes_per_second_to_megabits():
64
+ r = speed.parse_cli(CLI_RESULT)
65
+ assert round(r.download_mbps, 2) == 250.0 and round(r.upload_mbps, 2) == 100.0
66
+ assert r.ping_ms == 4.21 and r.jitter_ms == 0.52 and r.packet_loss == 0
67
+ assert r.isp == "Hetzner Online GmbH" and r.server == "Example Telecom" and r.location == "Frankfurt, Germany"
68
+ assert r.url == "https://www.speedtest.net/result/c/abc" and r.source == "Ookla Speedtest CLI"
69
+
70
+
71
+ def test_parse_cli_copes_with_missing_details_but_not_missing_speeds():
72
+ r = speed.parse_cli({"download": {"bandwidth": 1_000_000}, "upload": {"bandwidth": 500_000}})
73
+ assert r.ping_ms == 0.0 and r.jitter_ms is None and r.packet_loss is None and r.isp == "" and r.url == "" and r.location == ""
74
+ for broken in ({}, {"download": {"bandwidth": 1}}, {"download": {"bandwidth": "x"}, "upload": {"bandwidth": 1}}):
75
+ with pytest.raises(SpeedError):
76
+ speed.parse_cli(broken)
77
+
78
+
79
+ def test_parse_python_converts_bits_per_second_to_megabits():
80
+ r = speed.parse_python(PY_RESULT)
81
+ assert round(r.download_mbps, 2) == 245.31 and round(r.upload_mbps, 2) == 118.2 and r.ping_ms == 11.5
82
+ assert r.isp == "Hetzner Online GmbH" and r.server == "Example Telecom" and r.location == "Frankfurt, Germany"
83
+ assert r.url.endswith("1.png") and "speedtest-cli" in r.source
84
+ with pytest.raises(SpeedError):
85
+ speed.parse_python({"ping": 3})
86
+
87
+
88
+ def test_the_result_line_is_picked_out_of_the_cli_output():
89
+ out = "\n".join(["noise", json.dumps({"type": "log", "message": "x"}), json.dumps(CLI_RESULT), "", "{broken json"])
90
+ assert speed._last_result(out)["isp"] == "Hetzner Online GmbH"
91
+ assert speed._last_result("nothing useful") is None
92
+ assert speed._last_result(json.dumps({"type": "log", "message": "x"})) is None
93
+
94
+
95
+ def test_the_reason_for_a_failure_comes_from_the_log_line():
96
+ out = json.dumps({"type": "log", "level": "error", "message": "Configuration - Couldn't resolve host name"})
97
+ assert speed._log_message(out, "") == "Configuration - Couldn't resolve host name"
98
+ assert speed._log_message("", "segmentation fault") == "segmentation fault"
99
+ assert speed._log_message("", "") == "no output"
100
+
101
+
102
+ # ---------------------------------------------------------------------------
103
+ # finding and running the CLI
104
+ # ---------------------------------------------------------------------------
105
+
106
+
107
+ async def test_the_official_cli_is_found_by_its_version_text(bindir):
108
+ assert await speed.find_ookla_cli() is None
109
+ fake_binary(bindir, "speedtest", "speedtest-cli 2.1.3\nPython 3.12", "exit 1") # the pip package's script is not the CLI
110
+ assert await speed.find_ookla_cli() is None
111
+ ookla = fake_binary(bindir, "ookla-speedtest", "Speedtest by Ookla 1.2.0.84 (5ae238b) Linux/x86_64", "exit 1")
112
+ assert await speed.find_ookla_cli() == str(ookla)
113
+
114
+
115
+ async def test_a_plain_speedtest_binary_is_accepted_when_it_is_the_ookla_one(bindir):
116
+ path = fake_binary(bindir, "speedtest", "Speedtest by Ookla 1.2.0.84", "exit 1")
117
+ assert await speed.find_ookla_cli() == str(path)
118
+
119
+
120
+ async def test_the_speedtest_bin_variable_wins(bindir, tmp_path, monkeypatch):
121
+ other = tmp_path / "elsewhere"
122
+ other.mkdir()
123
+ chosen = fake_binary(other, "mytest", "Speedtest by Ookla 1.2.0.84", "exit 1")
124
+ fake_binary(bindir, "ookla-speedtest", "Speedtest by Ookla 1.2.0.84", "exit 1")
125
+ monkeypatch.setenv("SPEEDTEST_BIN", str(chosen))
126
+ assert await speed.find_ookla_cli() == str(chosen)
127
+
128
+
129
+ async def test_run_cli_returns_the_parsed_result(bindir):
130
+ path = fake_binary(bindir, "ookla-speedtest", "Speedtest by Ookla 1.2.0.84", emit(bindir, CLI_RESULT))
131
+ r = await speed.run_cli(str(path))
132
+ assert round(r.download_mbps) == 250 and r.server == "Example Telecom"
133
+
134
+
135
+ async def test_run_cli_passes_the_flags_that_make_it_non_interactive(bindir, tmp_path):
136
+ args_file = tmp_path / "args.txt"
137
+ path = fake_binary(bindir, "ookla-speedtest", "Speedtest by Ookla 1.2.0.84", f'echo "$@" > {args_file}\n{emit(bindir, CLI_RESULT)}')
138
+ await speed.run_cli(str(path))
139
+ args = args_file.read_text().split()
140
+ assert "--accept-license" in args and "--accept-gdpr" in args and "--format=json" in args
141
+
142
+
143
+ async def test_run_cli_reports_why_it_failed(bindir):
144
+ log = {"type": "log", "level": "error", "message": "Configuration - Couldn't resolve host name"}
145
+ path = fake_binary(bindir, "ookla-speedtest", "Speedtest by Ookla 1.2.0.84", f"{emit(bindir, log)}\nexit 1")
146
+ with pytest.raises(SpeedError, match="Couldn't resolve host name"):
147
+ await speed.run_cli(str(path))
148
+
149
+
150
+ async def test_run_cli_gives_up_after_the_timeout_and_kills_the_process(bindir, tmp_path):
151
+ pid_file = tmp_path / "pid"
152
+ path = fake_binary(bindir, "ookla-speedtest", "Speedtest by Ookla 1.2.0.84", f"echo $$ > {pid_file}\nexec {SLEEP} 30")
153
+ started = time.monotonic()
154
+ with pytest.raises(SpeedError, match="took too long"):
155
+ await speed.run_cli(str(path), timeout=0.5)
156
+ assert time.monotonic() - started < 5
157
+ pid = int(pid_file.read_text())
158
+ await asyncio.sleep(0.2)
159
+ with pytest.raises(ProcessLookupError):
160
+ os.kill(pid, 0)
161
+
162
+
163
+ async def test_a_binary_that_cannot_start_is_a_speed_error(tmp_path):
164
+ broken = tmp_path / "broken"
165
+ broken.write_text("not executable")
166
+ with pytest.raises(SpeedError):
167
+ await speed.run_cli(str(broken))
168
+
169
+
170
+ # ---------------------------------------------------------------------------
171
+ # run_speedtest: CLI first, Python client second
172
+ # ---------------------------------------------------------------------------
173
+
174
+
175
+ async def test_the_cli_is_used_when_it_works(bindir, monkeypatch):
176
+ fake_binary(bindir, "ookla-speedtest", "Speedtest by Ookla 1.2.0.84", emit(bindir, CLI_RESULT))
177
+
178
+ def boom():
179
+ raise AssertionError("the Python client is not needed")
180
+
181
+ monkeypatch.setattr(speed, "_run_python", boom)
182
+ r = await speed.run_speedtest()
183
+ assert r.source == "Ookla Speedtest CLI"
184
+
185
+
186
+ async def test_the_python_client_takes_over_when_the_cli_is_missing(bindir, monkeypatch):
187
+ monkeypatch.setattr(speed, "_run_python", lambda: PY_RESULT)
188
+ r = await speed.run_speedtest()
189
+ assert "speedtest-cli" in r.source and round(r.download_mbps) == 245
190
+
191
+
192
+ async def test_the_python_client_takes_over_when_the_cli_fails(bindir, monkeypatch):
193
+ fake_binary(bindir, "ookla-speedtest", "Speedtest by Ookla 1.2.0.84", "exit 1")
194
+ monkeypatch.setattr(speed, "_run_python", lambda: PY_RESULT)
195
+ assert "speedtest-cli" in (await speed.run_speedtest()).source
196
+
197
+
198
+ async def test_when_both_fail_the_reasons_are_all_given(bindir, monkeypatch):
199
+ fake_binary(bindir, "ookla-speedtest", "Speedtest by Ookla 1.2.0.84", "echo 'server unreachable'\nexit 1")
200
+
201
+ def failing():
202
+ raise SpeedError("speedtest-cli failed: HTTP Error 403: Forbidden")
203
+
204
+ monkeypatch.setattr(speed, "_run_python", failing)
205
+ with pytest.raises(SpeedError) as exc:
206
+ await speed.run_speedtest()
207
+ assert "server unreachable" in str(exc.value) and "403" in str(exc.value)
208
+
209
+
210
+ async def test_a_missing_python_client_is_explained(bindir, monkeypatch):
211
+ monkeypatch.setitem(sys.modules, "speedtest", None) # makes `import speedtest` fail, installed or not
212
+ with pytest.raises(SpeedError) as exc:
213
+ await speed.run_speedtest()
214
+ assert "Speedtest CLI is not installed" in str(exc.value) and "speedtest-cli package is not installed" in str(exc.value)
215
+
216
+
217
+ async def test_errors_from_the_python_client_are_wrapped(bindir, monkeypatch):
218
+ class FakeSpeedtest:
219
+ def __init__(self, *a, **kw):
220
+ raise RuntimeError("HTTP Error 403: Forbidden")
221
+
222
+ monkeypatch.setitem(sys.modules, "speedtest", type(sys)("speedtest"))
223
+ sys.modules["speedtest"].Speedtest = FakeSpeedtest
224
+ with pytest.raises(SpeedError, match="speedtest-cli failed: HTTP Error 403"):
225
+ await speed.run_speedtest()
226
+
227
+
228
+ async def test_a_python_client_that_hangs_is_given_up_on(bindir, monkeypatch):
229
+ monkeypatch.setattr(speed, "CLI_TIMEOUT", 0.1)
230
+ monkeypatch.setattr(speed, "_run_python", lambda: time.sleep(0.4) or PY_RESULT)
231
+ with pytest.raises(SpeedError, match="took too long"):
232
+ await speed.run_speedtest()
233
+
234
+
235
+ # ---------------------------------------------------------------------------
236
+ # the message
237
+ # ---------------------------------------------------------------------------
238
+
239
+
240
+ def test_format_result_lists_every_detail():
241
+ text = speed.format_result(speed.parse_cli(CLI_RESULT))
242
+ assert "SPEEDTEST" in text and "Ookla" in text
243
+ assert "<code>250.00 Mbps</code>" in text and "<code>100.00 Mbps</code>" in text
244
+ assert "<code>4.21 ms</code>" in text and "jitter <code>0.52 ms</code>" in text and "<code>0%</code>" in text
245
+ assert "Hetzner Online GmbH" in text and "Example Telecom · Frankfurt, Germany" in text
246
+ assert '<a href="https://www.speedtest.net/result/c/abc">speedtest.net</a>' in text
247
+
248
+
249
+ def test_format_result_skips_what_is_unknown_and_escapes_the_rest():
250
+ r = SpeedResult(download_mbps=1234.5, upload_mbps=2.0, ping_ms=30, isp="A&B <ISP>", server="", location="", url="javascript:alert(1)")
251
+ text = speed.format_result(r)
252
+ assert "<code>1,234.50 Mbps</code>" in text and "A&amp;B &lt;ISP&gt;" in text
253
+ assert "Server" not in text and "Packet loss" not in text and "jitter" not in text and "<a " not in text
254
+ quoted = SpeedResult(download_mbps=1, upload_mbps=1, ping_ms=1, url='https://x.test/a"b')
255
+ assert 'href="https://x.test/a&quot;b"' in speed.format_result(quoted)
tests/test_speed_app.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """/speed: the command around the speed test."""
2
+ import asyncio
3
+
4
+ from bot import app as appmod
5
+ from bot import speed
6
+ from bot.speed import SpeedResult
7
+
8
+ from .appkit import ADMIN_CFG, OWNER, STRANGER, say
9
+
10
+ RESULT = SpeedResult(
11
+ download_mbps=250.0, upload_mbps=100.0, ping_ms=4.2, jitter_ms=0.5, packet_loss=0.0, isp="Hetzner",
12
+ server="Example Telecom", location="Frankfurt, Germany", url="https://www.speedtest.net/result/c/abc", source="Ookla Speedtest CLI",
13
+ )
14
+
15
+
16
+ def serve(monkeypatch, result=RESULT, error=None, gate=None):
17
+ calls = []
18
+
19
+ async def fake():
20
+ calls.append(1)
21
+ if gate is not None:
22
+ await gate.wait()
23
+ if error is not None:
24
+ raise error
25
+ return result
26
+
27
+ monkeypatch.setattr(speed, "run_speedtest", fake)
28
+ return calls
29
+
30
+
31
+ async def test_speed_shows_a_status_and_then_the_figures(ctx, monkeypatch):
32
+ calls = serve(monkeypatch)
33
+ ev = await say(ctx, OWNER, "/speed")
34
+ assert ev.texts == ["⚡ Running the speed test… this takes about half a minute."]
35
+ (status, text, kw), = ctx.client.edited
36
+ assert status.id == 1001 and kw["parse_mode"] == "html" and kw["link_preview"] is False
37
+ assert "SPEEDTEST" in text and "250.00 Mbps" in text and "100.00 Mbps" in text and "speedtest.net/result/c/abc" in text
38
+ assert len(calls) == 1 and "may be lower" not in text
39
+
40
+
41
+ async def test_a_failed_test_says_why(ctx, monkeypatch):
42
+ serve(monkeypatch, error=speed.SpeedError("the Speedtest CLI is not installed; the speedtest-cli package is not installed <x>"))
43
+ await say(ctx, OWNER, "/speed")
44
+ text = ctx.client.edited[-1][1]
45
+ assert text.startswith("❌ I couldn't run the speed test: the Speedtest CLI is not installed") and "&lt;x&gt;" in text
46
+
47
+
48
+ async def test_an_unexpected_crash_is_reported_and_does_not_leave_the_lock_taken(ctx, monkeypatch):
49
+ serve(monkeypatch, error=RuntimeError("boom"))
50
+ await say(ctx, OWNER, "/speed")
51
+ assert "failed unexpectedly" in ctx.client.edited[-1][1]
52
+ serve(monkeypatch)
53
+ await say(ctx, OWNER, "/speed")
54
+ assert "250.00 Mbps" in ctx.client.edited[-1][1]
55
+
56
+
57
+ async def test_only_one_test_runs_at_a_time(ctx, monkeypatch):
58
+ gate = asyncio.Event()
59
+ calls = serve(monkeypatch, gate=gate)
60
+ first = asyncio.ensure_future(say(ctx, OWNER, "/speed"))
61
+ await asyncio.sleep(0.05)
62
+ second = await say(ctx, ADMIN_CFG, "/speed")
63
+ assert "already running" in second.texts[0] and len(calls) == 1
64
+ gate.set()
65
+ await first
66
+ assert "250.00 Mbps" in ctx.client.edited[-1][1]
67
+ again = await say(ctx, ADMIN_CFG, "/speed") # free again
68
+ assert again.texts[0].startswith("⚡")
69
+
70
+
71
+ async def test_the_result_mentions_a_running_job(ctx, monkeypatch):
72
+ serve(monkeypatch)
73
+ await say(ctx, OWNER, "/speed")
74
+ assert "A rename job was running" not in ctx.client.edited[-1][1]
75
+ monkeypatch.setattr(type(ctx.app.queue), "running", property(lambda self: 1))
76
+ await say(ctx, OWNER, "/speed")
77
+ assert "A rename job was running during the test" in ctx.client.edited[-1][1]
78
+
79
+
80
+ async def test_strangers_cannot_run_it_and_it_is_in_the_menu(ctx, monkeypatch):
81
+ calls = serve(monkeypatch)
82
+ assert (await say(ctx, STRANGER, "/speed")).out == [] and calls == []
83
+ assert "speed" in dict(appmod.COMMANDS)
tests/test_telegraph.py ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The Telegraph client, against a local server that answers like api.telegra.ph."""
2
+ import asyncio
3
+ import json
4
+
5
+ import pytest
6
+
7
+ from bot import telegraph
8
+ from bot.telegraph import Telegraph, TelegraphError
9
+
10
+ from .faketelegraph import serve_fake_telegraph
11
+
12
+ NODES = [{"tag": "h4", "children": ["General"]}, {"tag": "pre", "children": ["Format : Matroska"]}]
13
+
14
+
15
+ @pytest.fixture
16
+ async def server():
17
+ fake = await serve_fake_telegraph()
18
+ yield fake
19
+ await fake.server.close()
20
+
21
+
22
+ def make(server, store=None, **kw):
23
+ store = {} if store is None else store
24
+
25
+ async def load():
26
+ return store.get("token")
27
+
28
+ async def save(token):
29
+ store["token"] = token
30
+
31
+ return Telegraph(load, save, api=server.url, **kw), store
32
+
33
+
34
+ # ---------------------------------------------------------------------------
35
+
36
+
37
+ async def test_the_first_page_makes_an_account_and_remembers_its_token(server):
38
+ tg, store = make(server)
39
+ url = await tg.create_page("MediaInfo – Episode 01.mkv", NODES)
40
+ assert url == "https://telegra.ph/Page-1"
41
+ assert server.accounts == 1 and store == {"token": "token1"}
42
+ form = server.pages[0]
43
+ assert form["access_token"] == "token1" and form["title"] == "MediaInfo – Episode 01.mkv"
44
+ assert form["author_name"] == "Auto Rename Bot" and form["return_content"] == "false" and "author_url" not in form
45
+ assert json.loads(form["content"]) == NODES
46
+
47
+
48
+ async def test_later_pages_use_the_same_account(server):
49
+ tg, store = make(server)
50
+ await tg.create_page("one", NODES)
51
+ await tg.create_page("two", NODES)
52
+ assert server.accounts == 1 and [p["access_token"] for p in server.pages] == ["token1", "token1"]
53
+
54
+
55
+ async def test_a_token_saved_by_an_earlier_run_is_used_without_a_new_account(server):
56
+ server.valid.add("kept")
57
+ tg, store = make(server, {"token": "kept"})
58
+ await tg.create_page("x", NODES)
59
+ assert server.accounts == 0 and server.pages[0]["access_token"] == "kept"
60
+
61
+
62
+ async def test_a_rejected_token_is_replaced_once_and_the_page_still_gets_made(server):
63
+ tg, store = make(server, {"token": "stale"})
64
+ url = await tg.create_page("x", NODES)
65
+ assert url == "https://telegra.ph/Page-1"
66
+ assert server.accounts == 1 and store == {"token": "token1"}
67
+ assert [a["access_token"] for a in server.attempts] == ["stale", "token1"]
68
+
69
+
70
+ async def test_a_token_that_is_rejected_again_is_an_error(server):
71
+ server.reject_all = True
72
+ tg, store = make(server, {"token": "stale"})
73
+ with pytest.raises(TelegraphError) as err:
74
+ await tg.create_page("x", NODES)
75
+ assert err.value.code == "ACCESS_TOKEN_INVALID" and server.accounts == 1 and len(server.attempts) == 2
76
+
77
+
78
+ async def test_the_first_use_from_several_places_makes_one_account(server):
79
+ tg, _store = make(server)
80
+ urls = await asyncio.gather(*(tg.create_page(f"p{i}", NODES) for i in range(6)))
81
+ assert server.accounts == 1 and len(set(urls)) == 6
82
+
83
+
84
+ async def test_a_flood_wait_says_how_long(server):
85
+ server.error = "FLOOD_WAIT_7"
86
+ tg, _store = make(server)
87
+ with pytest.raises(TelegraphError) as err:
88
+ await tg.create_page("x", NODES)
89
+ assert str(err.value) == "Telegraph asks to wait 7 seconds" and err.value.code == "FLOOD_WAIT_7"
90
+
91
+
92
+ async def test_other_errors_keep_their_code(server):
93
+ server.error = "PAGE_SAVE_FAILED"
94
+ tg, _store = make(server)
95
+ with pytest.raises(TelegraphError) as err:
96
+ await tg.create_page("x", NODES)
97
+ assert str(err.value) == "Telegraph said PAGE_SAVE_FAILED" and err.value.code == "PAGE_SAVE_FAILED"
98
+
99
+
100
+ async def test_a_page_over_the_size_limit_is_not_even_sent(server):
101
+ tg, _store = make(server)
102
+ big = [{"tag": "pre", "children": ["x" * 70_000]}]
103
+ with pytest.raises(TelegraphError) as err:
104
+ await tg.create_page("x", big)
105
+ assert err.value.code == "CONTENT_TOO_BIG" and server.attempts == [] and server.accounts == 0
106
+
107
+
108
+ async def test_an_answer_that_is_not_json_is_an_error(server):
109
+ server.raw = (502, "<html>Bad gateway</html>")
110
+ tg, _store = make(server)
111
+ with pytest.raises(TelegraphError) as err:
112
+ await tg.create_page("x", NODES)
113
+ assert "couldn't reach Telegraph" in str(err.value)
114
+
115
+
116
+ async def test_a_slow_server_times_out(server):
117
+ server.delay = 1.0
118
+ tg, _store = make(server, timeout=0.2)
119
+ with pytest.raises(TelegraphError) as err:
120
+ await tg.create_page("x", NODES)
121
+ assert "too long" in str(err.value)
122
+
123
+
124
+ async def test_an_unreachable_server_is_an_error():
125
+ async def load():
126
+ return "token"
127
+
128
+ async def save(token):
129
+ pass
130
+
131
+ tg = Telegraph(load, save, api="http://127.0.0.1:9", timeout=2) # nothing listens on the discard port
132
+ with pytest.raises(TelegraphError) as err:
133
+ await tg.create_page("x", NODES)
134
+ assert "couldn't reach Telegraph" in str(err.value)
135
+
136
+
137
+ async def test_the_author_and_its_link_are_sent_and_kept_within_telegraphs_limits(server):
138
+ tg, _store = make(server)
139
+ tg.set_author("Anime Rename Bot", "https://t.me/rename_bot")
140
+ await tg.create_page("x", NODES)
141
+ assert server.pages[0]["author_name"] == "Anime Rename Bot" and server.pages[0]["author_url"] == "https://t.me/rename_bot"
142
+ tg.set_author("N" * 300)
143
+ tg.set_author(" ") # a blank name changes nothing
144
+ await tg.create_page("y", NODES)
145
+ assert server.pages[1]["author_name"] == "N" * 128 and "author_url" not in server.pages[1] # no link was given this time
146
+
147
+
148
+ async def test_titles_are_never_empty_or_too_long(server):
149
+ tg, _store = make(server)
150
+ await tg.create_page(" ", NODES)
151
+ await tg.create_page("T" * 400, NODES)
152
+ assert server.pages[0]["title"] == "MediaInfo" and len(server.pages[1]["title"]) == 256
153
+
154
+
155
+ def test_content_size_counts_bytes_not_characters():
156
+ nodes = [{"tag": "p", "children": ["日本語"]}]
157
+ assert telegraph.content_size(nodes) == len(json.dumps(nodes, ensure_ascii=False, separators=(",", ":")).encode()) > len("日本語")
tests/tgfakes.py CHANGED
@@ -14,24 +14,27 @@ from telethon import events
14
 
15
 
16
  def make_file(name: Optional[str] = "Movie.mkv", size: int = 5 * 1024 * 1024, mime: str = "video/x-matroska",
17
- ext: Optional[str] = None) -> SimpleNamespace:
 
18
  if ext is None:
19
  ext = name[name.rfind("."):] if name and "." in name else ""
20
- return SimpleNamespace(name=name, size=size, mime_type=mime, ext=ext)
21
 
22
 
23
  class FakeMessage:
24
  def __init__(self, text: str = "", *, mid: int = 1, sender: int = 1, file: Any = None, video: bool = False,
25
  audio: bool = False, photo: bool = False, sticker: bool = False, gif: bool = False,
26
- voice: bool = False, video_note: bool = False, fwd: bool = False, out: bool = False):
 
27
  self.id = mid
28
  self.message = text
29
  self.raw_text = text
 
30
  self.out = out
31
  self.fwd_from = object() if fwd else None
32
  self.sender_id = sender
33
  self.file = file
34
- self.document = object() if file is not None else None
35
  self.video = object() if video else None
36
  self.audio = object() if audio else None
37
  self.photo = object() if photo else None
@@ -43,8 +46,8 @@ class FakeMessage:
43
 
44
 
45
  def document_message(name: str = "Movie.mkv", size: int = 5 * 1024 * 1024, *, mid: int = 10, caption: str = "",
46
- video: bool = True, mime: str = "video/x-matroska") -> FakeMessage:
47
- return FakeMessage(caption, mid=mid, file=make_file(name, size, mime), video=video)
48
 
49
 
50
  class FakeEvent:
@@ -135,6 +138,13 @@ class FakeClient:
135
  self.requests: List[Any] = [] # raw MTProto requests
136
  self.bad_entities: set = set()
137
  self.fail_send_file = False
 
 
 
 
 
 
 
138
 
139
  def add_event_handler(self, callback: Any, event: Any = None) -> None:
140
  self.handlers.append((callback, event))
@@ -152,9 +162,26 @@ class FakeClient:
152
  self.sent.append((entity, text, kw))
153
  return SimpleNamespace(id=2000 + len(self.sent))
154
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
155
  async def send_file(self, entity: Any, file: Any, **kw: Any) -> SimpleNamespace:
156
  if self.fail_send_file:
157
  raise RuntimeError("cannot send file")
 
 
 
158
  self.files.append((entity, file, kw))
159
  return SimpleNamespace(id=3000 + len(self.files))
160
 
 
14
 
15
 
16
  def make_file(name: Optional[str] = "Movie.mkv", size: int = 5 * 1024 * 1024, mime: str = "video/x-matroska",
17
+ ext: Optional[str] = None, *, width: Optional[int] = None, height: Optional[int] = None,
18
+ duration: Optional[float] = None) -> SimpleNamespace:
19
  if ext is None:
20
  ext = name[name.rfind("."):] if name and "." in name else ""
21
+ return SimpleNamespace(name=name, size=size, mime_type=mime, ext=ext, width=width, height=height, duration=duration)
22
 
23
 
24
  class FakeMessage:
25
  def __init__(self, text: str = "", *, mid: int = 1, sender: int = 1, file: Any = None, video: bool = False,
26
  audio: bool = False, photo: bool = False, sticker: bool = False, gif: bool = False,
27
+ voice: bool = False, video_note: bool = False, fwd: bool = False, out: bool = False,
28
+ entities: Any = None, thumbs: Any = None):
29
  self.id = mid
30
  self.message = text
31
  self.raw_text = text
32
+ self.entities = entities
33
  self.out = out
34
  self.fwd_from = object() if fwd else None
35
  self.sender_id = sender
36
  self.file = file
37
+ self.document = SimpleNamespace(thumbs=thumbs) if file is not None else None
38
  self.video = object() if video else None
39
  self.audio = object() if audio else None
40
  self.photo = object() if photo else None
 
46
 
47
 
48
  def document_message(name: str = "Movie.mkv", size: int = 5 * 1024 * 1024, *, mid: int = 10, caption: str = "",
49
+ video: bool = True, mime: str = "video/x-matroska", thumbs: Any = None) -> FakeMessage:
50
+ return FakeMessage(caption, mid=mid, file=make_file(name, size, mime), video=video, thumbs=thumbs)
51
 
52
 
53
  class FakeEvent:
 
138
  self.requests: List[Any] = [] # raw MTProto requests
139
  self.bad_entities: set = set()
140
  self.fail_send_file = False
141
+ self.messages: dict = {} # id -> message that get_messages() can find (a missing id comes back as None)
142
+ self.deleted: List[tuple] = [] # delete_messages(entity, ids)
143
+ self.file_errors: dict = {} # message id -> exceptions send_file() raises for it, one per call
144
+ self.get_calls: List[tuple] = []
145
+ self.media: dict = {} # message id -> bytes that download_media() returns
146
+ self.thumb_data: dict = {} # message id -> bytes that download_media(thumb=...) returns
147
+ self.downloads: List[tuple] = []
148
 
149
  def add_event_handler(self, callback: Any, event: Any = None) -> None:
150
  self.handlers.append((callback, event))
 
162
  self.sent.append((entity, text, kw))
163
  return SimpleNamespace(id=2000 + len(self.sent))
164
 
165
+ async def get_messages(self, entity: Any, ids: Any = None, **kw: Any) -> Any:
166
+ self.get_calls.append((entity, ids))
167
+ if isinstance(ids, (list, tuple)):
168
+ return [self.messages.get(i) for i in ids]
169
+ return self.messages.get(ids)
170
+
171
+ async def download_media(self, message: Any, file: Any = None, thumb: Any = None, **kw: Any) -> Any:
172
+ self.downloads.append((getattr(message, "id", None), file, thumb))
173
+ source = self.thumb_data if thumb is not None else self.media
174
+ return source.get(getattr(message, "id", None))
175
+
176
+ async def delete_messages(self, entity: Any, ids: Any, **kw: Any) -> None:
177
+ self.deleted.append((entity, list(ids) if isinstance(ids, (list, tuple)) else [ids]))
178
+
179
  async def send_file(self, entity: Any, file: Any, **kw: Any) -> SimpleNamespace:
180
  if self.fail_send_file:
181
  raise RuntimeError("cannot send file")
182
+ errors_for = self.file_errors.get(getattr(file, "id", None))
183
+ if errors_for:
184
+ raise errors_for.pop(0)
185
  self.files.append((entity, file, kw))
186
  return SimpleNamespace(id=3000 + len(self.files))
187