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

Automated sync from GitHub

Browse files
.env.example CHANGED
@@ -15,7 +15,8 @@ DATABASE_URL=postgresql://user:password@host/dbname?sslmode=require
15
  ADMIN_IDS=
16
  # Numeric chat id of a channel/group where the bot posts start-up, job and error notices.
17
  LOG_CHANNEL=
18
- # The banner shown on /start is set with the owner's /smedia command.
 
19
  # Optional Telethon StringSession to log in with (otherwise stored in the database).
20
  SESSION_STRING=
21
 
 
15
  ADMIN_IDS=
16
  # Numeric chat id of a channel/group where the bot posts start-up, job and error notices.
17
  LOG_CHANNEL=
18
+ # Banner for /start: an https image URL or a file path.
19
+ START_IMAGE=
20
  # Optional Telethon StringSession to log in with (otherwise stored in the database).
21
  SESSION_STRING=
22
 
.gitattributes CHANGED
@@ -1,8 +1,8 @@
1
- *.mp4 filter=lfs diff=lfs merge=lfs -text
2
- *.mp3 filter=lfs diff=lfs merge=lfs -text
3
- *.jpg filter=lfs diff=lfs merge=lfs -text
4
- *.jpeg filter=lfs diff=lfs merge=lfs -text
5
  *.png filter=lfs diff=lfs merge=lfs -text
6
  *.ttf filter=lfs diff=lfs merge=lfs -text
7
  *.otf filter=lfs diff=lfs merge=lfs -text
8
  *.webp filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
1
  *.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
Dockerfile CHANGED
@@ -2,23 +2,9 @@ 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 curl \
6
  && rm -rf /var/lib/apt/lists/*
7
 
8
- # Ookla speedtest CLI: /speed needs it. The version is pinned so a rebuild
9
- # cannot silently change the tool. The tarball unpacks to a binary called
10
- # "speedtest"; it is installed as "ookla-speedtest" to match the bot.
11
- RUN set -eux; \
12
- case "$(uname -m)" in \
13
- x86_64) arch=x86_64 ;; \
14
- aarch64|arm64) arch=aarch64 ;; \
15
- *) echo "unsupported architecture: $(uname -m)" >&2; exit 1 ;; \
16
- esac; \
17
- curl -fsSL --retry 3 "https://install.speedtest.net/app/cli/ookla-speedtest-1.2.0-linux-${arch}.tgz" -o /tmp/ookla.tgz; \
18
- tar -xzf /tmp/ookla.tgz -C /tmp; \
19
- install -m 0755 /tmp/speedtest /usr/local/bin/ookla-speedtest; \
20
- rm -rf /tmp/ookla.tgz /tmp/speedtest
21
-
22
  # Hugging Face Spaces run Docker containers as user 1000
23
  RUN useradd -m -u 1000 user
24
  USER user
 
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
bot/app.py CHANGED
@@ -17,16 +17,13 @@ import telethon
17
  from telethon import errors, events, functions, types
18
 
19
  from . import fasttransfer as transfer
20
- from . import media, panels, sysinfo, telegraph
21
  from .config import Config
22
- from .db import MAX_FORMATS, Database
23
- from .formats import apply_format_field, resolve_format, resolve_thumbnail
24
- from .parser import compute_new_name, effective_info, missing_variables, parse_info, split_ext
25
  from .pipeline import Renamer, original_name_of, source_ext
26
  from .queue import Job, JobQueue
27
- from .sequence import detect_episodes, format_report, plan_sort
28
  from .settings_ops import META_COMMANDS, apply_setting, parse_channel, preview_template
29
- from .speedtest import format_speed, run_speedtest
30
  from .utils import clip, esc, human_size
31
 
32
  log = logging.getLogger(__name__)
@@ -34,16 +31,7 @@ log = logging.getLogger(__name__)
34
  COMMANDS = [
35
  ("start", "Open the main menu"),
36
  ("help", "How to use the bot"),
37
- ("rename", "Pick a saved format and rename a file"),
38
- ("formats", "Manage your saved rename formats"),
39
- ("sequence", "Collect files to sort by episode"),
40
- ("sort", "Sort the collected files and send them back"),
41
- ("check", "See what the bot reads from a file"),
42
- ("ethumb", "Extract a thumbnail from a video"),
43
- ("resize", "Resize an image (reply to it)"),
44
- ("speed", "Check the bot's internet speed"),
45
- ("mediainfo", "Full media info of a file, published to telegra.ph"),
46
- ("smedia", "Set the start image or GIF (owner only)"),
47
  ("mode", "Choose Filename or Caption mode"),
48
  ("template", "Set the filename template"),
49
  ("caption", "Set the caption template"),
@@ -59,18 +47,17 @@ COMMANDS = [
59
  ("status", "Show basic bot uptime, ping, and version information."),
60
  ("stats", "Show detailed system stats such as users, CPU, RAM, and disk usage."),
61
  ]
62
- OWNER_COMMANDS = {"addadmin", "deladmin", "admins", "smedia"}
63
  BACK = {
64
  "template": "rename", "remove": "rename", "caption": "cap",
65
  "mt_title": "meta", "mt_author": "meta", "mt_artist": "meta",
66
  "mt_audio": "meta", "mt_subtitle": "meta", "mt_video": "meta",
67
  "keep_audio": "tool", "keep_subtitle": "tool",
68
  }
69
- PANEL_NAMES = {"home", "rename", "cap", "meta", "tool", "ftype", "help", "admin", "formats"}
70
  QUEUE_SETTLE = 1.5 # seconds an idle worker waits so files sent together are queued in message order
71
  TRACKS_PROBE_BYTES = 12 * 1024 * 1024
72
  TRACKS_FULL_LIMIT = 700 * 1024 * 1024
73
- COPY_DELAY = 0.4 # a short pause so Telegram keeps the copies in order
74
 
75
 
76
  @dataclass
@@ -81,7 +68,6 @@ class Pending:
81
  back: str
82
  is_photo: bool
83
  expires: float
84
- format_id: Optional[int] = None
85
 
86
 
87
  def cmd_pattern(name: str) -> "re.Pattern[str]":
@@ -103,14 +89,6 @@ def is_renameable(message: Any) -> bool:
103
  )
104
 
105
 
106
- def is_image(message: Any) -> bool:
107
- """A photo, or a document with an image mime type."""
108
- if getattr(message, "photo", None) is not None:
109
- return True
110
- mime = getattr(getattr(message, "file", None), "mime_type", "") or ""
111
- return mime.startswith("image/")
112
-
113
-
114
  class App:
115
  PENDING_TTL = 300
116
 
@@ -123,9 +101,6 @@ class App:
123
  self.renamer = Renamer(client, cfg, db, self._log_send)
124
  self.queue = JobQueue(self.renamer.process, workers=cfg.max_jobs, on_cancelled=self._on_job_cancelled, settle=QUEUE_SETTLE)
125
  self._warned: Set[int] = set()
126
- self._seq_status: Dict[int, Any] = {}
127
- self._ethumbs: Dict[int, bytes] = {}
128
- self._resize_source: Dict[int, bytes] = {}
129
 
130
  # ------------------------------------------------------------------ auth
131
  def is_owner(self, uid: Optional[int]) -> bool:
@@ -220,7 +195,6 @@ class App:
220
  )
221
  add(self._guard(self.on_media), events.NewMessage(incoming=True, func=lambda e: e.is_private and is_renameable(e.message)))
222
  add(self._guard(self.on_photo), events.NewMessage(incoming=True, func=lambda e: e.is_private and e.message.photo is not None))
223
- add(self._guard(self.on_animation), events.NewMessage(incoming=True, func=lambda e: e.is_private and e.message.gif is not None))
224
  add(self._guard(self.on_text), events.NewMessage(incoming=True, func=self._wants_text))
225
  add(self._guard(self.on_callback), events.CallbackQuery())
226
  add(self._on_stranger, events.NewMessage(incoming=True, func=lambda e: e.is_private and not self.is_allowed(e.sender_id)))
@@ -241,12 +215,6 @@ class App:
241
  "tracks": self.cmd_tracks, "thumb": self.cmd_thumb, "delthumb": self.cmd_delthumb,
242
  "removewords": self.cmd_remove, "setchannel": self.cmd_setchannel, "queue": self.cmd_queue,
243
  "cancel": self.cmd_cancel, "status": self.cmd_status, "stats": self.cmd_stats,
244
- "formats": self.cmd_formats,
245
- "sequence": self.cmd_sequence, "sort": self.cmd_sort,
246
- "check": self.cmd_check,
247
- "ethumb": self.cmd_ethumb, "resize": self.cmd_resize,
248
- "speed": self.cmd_speed,
249
- "mediainfo": self.cmd_mediainfo, "smedia": self.cmd_smedia,
250
  "addadmin": self.cmd_addadmin, "deladmin": self.cmd_deladmin, "admins": self.cmd_admins,
251
  }
252
  for command, kind in META_COMMANDS.items():
@@ -278,8 +246,6 @@ class App:
278
  return panels.filetype(s)
279
  if name == "help":
280
  return panels.help_panel()
281
- if name == "formats":
282
- return panels.formats_list(await self.db.list_formats(uid), for_rename=False)
283
  if name == "admin":
284
  return panels.admin(
285
  self.cfg.owner_id, sorted(self.admins), self.queue.running, self.queue.waiting, self.cfg.log_channel
@@ -306,31 +272,16 @@ class App:
306
  async def cmd_start(self, event: Any) -> None:
307
  self.pending.pop(event.sender_id, None)
308
  text, rows = await self.panel(event.sender_id, "home")
309
- banner = await self.db.get_start_media()
310
- if banner:
311
  try:
312
- await self._send_banner(event.chat_id, banner, text, rows)
 
 
313
  return
314
  except Exception as exc: # noqa: BLE001
315
- log.warning("could not send the start banner (%s); sending text only", exc)
316
  await self._reply(event, text, rows)
317
 
318
- async def _send_banner(self, chat_id: int, banner: Dict[str, Any], text: str, rows: List[panels.Row]) -> None:
319
- is_gif = banner["kind"] == "gif"
320
- bio = io.BytesIO(banner["data"])
321
- bio.name = "start.gif" if is_gif else "start.jpg"
322
- kwargs: Dict[str, Any] = dict(
323
- caption=text, buttons=panels.to_buttons(rows), parse_mode="html",
324
- )
325
- if is_gif:
326
- # a GIF only animates as a document carrying the animated attribute
327
- kwargs["force_document"] = True
328
- kwargs["attributes"] = [
329
- types.DocumentAttributeFilename("start.gif"),
330
- types.DocumentAttributeAnimated(),
331
- ]
332
- await self.client.send_file(chat_id, bio, **kwargs)
333
-
334
  async def cmd_help(self, event: Any) -> None:
335
  text, _ = panels.help_panel()
336
  await self._reply(event, text, [[("✖ Close", "x:close")]])
@@ -488,39 +439,6 @@ class App:
488
  await self._reply(event, "You have no thumbnail.")
489
 
490
  async def on_photo(self, event: Any) -> None:
491
- uid = event.sender_id
492
- p = self.pending.get(uid)
493
- if p is not None and p.kind == "smedia_image":
494
- raw = await event.download_media(bytes)
495
- if not raw:
496
- await self._reply(event, "❌ I couldn't download that photo.")
497
- return
498
- try:
499
- data = await asyncio.to_thread(media.prepare_thumb, raw)
500
- except Exception: # noqa: BLE001
501
- await self._reply(event, "❌ I couldn't read that image.")
502
- return
503
- await self.db.set_start_media("image", data)
504
- self.pending.pop(uid, None)
505
- await self._reply(event, "✅ Start image saved.")
506
- return
507
- if p is not None and p.kind == "fmt_thumb" and p.format_id is not None:
508
- raw = await event.download_media(bytes)
509
- if not raw:
510
- await self._reply(event, "❌ I couldn't download that photo.")
511
- return
512
- try:
513
- thumb = await asyncio.to_thread(media.prepare_thumb, raw)
514
- except Exception: # noqa: BLE001
515
- await self._reply(event, "❌ I couldn't read that image.")
516
- return
517
- await self.db.update_format(uid, p.format_id, thumb=thumb)
518
- self.pending.pop(uid, None)
519
- text, rows = await self.format_panel(uid, p.format_id)
520
- await self.client.edit_message(
521
- p.chat_id, p.msg_id, text, buttons=panels.to_buttons(rows), parse_mode="html"
522
- )
523
- return
524
  raw = await event.download_media(bytes)
525
  if not raw:
526
  await self._reply(event, "❌ I couldn't download that photo.")
@@ -533,162 +451,45 @@ class App:
533
  await self.db.set_thumb(event.sender_id, thumb)
534
  await self._reply(event, "✅ Thumbnail saved. It will be used for the files I send back.", [[("🗑 Delete", "th:rm"), ("✖ Close", "x:close")]])
535
 
536
- # --------------------------------------------------------------- sequence
537
- async def cmd_sequence(self, event: Any) -> None:
538
- uid = event.sender_id
539
- seq = await self.db.get_sequence(uid)
540
- if seq is not None:
541
- await self._reply(
542
- event,
543
- f"🗂 You already have <b>{len(seq['items'])}</b> file(s) collected.\n"
544
- "Send <code>/sort</code> to sort them, or tap Cancel.",
545
- [[("✖ Cancel", "sq:cancel")]],
546
- )
547
- return
548
- await self.db.set_sequence(uid, [])
549
- status = await self._reply(
550
- event,
551
- "🗂 <b>Send me the files to sort.</b>\n\n"
552
- "I'll work out the episode of each one and send them back in order.\n"
553
- "When you're done, send <code>/sort</code>.",
554
- [[("✖ Cancel", "sq:cancel")]],
555
- )
556
- self._seq_status[uid] = status
557
-
558
- async def cmd_sort(self, event: Any) -> None:
559
- uid = event.sender_id
560
- seq = await self.db.get_sequence(uid)
561
- if seq is None or not seq["items"]:
562
- await self.db.clear_sequence(uid)
563
- self._seq_status.pop(uid, None)
564
- await self._reply(event, "You have no files to sort. Send <code>/sequence</code> first.")
565
- return
566
- status = await self._reply(event, "🔀 Sorting…")
567
- settings = await self.db.get_settings(uid)
568
- result = plan_sort(detect_episodes(seq["items"], settings["mode"]))
569
- copied = int(seq.get("copied") or 0)
570
- for index, item in enumerate(result.ordered):
571
- if index < copied:
572
- continue
573
- try:
574
- await self._copy_one(event.chat_id, item["msg_id"])
575
- except Exception as exc: # noqa: BLE001
576
- await self.client.edit_message(
577
- status,
578
- f"❌ <b>Stopped after {copied} file(s).</b>\n"
579
- f"{esc(clip(str(exc), 200))}\n\nSend <code>/sort</code> to try the rest.",
580
- parse_mode="html",
581
- )
582
- return
583
- copied = index + 1
584
- await self.db.set_sequence(uid, seq["items"], copied=copied)
585
- await asyncio.sleep(COPY_DELAY)
586
- await self.db.clear_sequence(uid)
587
- self._seq_status.pop(uid, None)
588
- await self.client.edit_message(status, format_report(result, len(result.ordered)), parse_mode="html")
589
-
590
- async def _copy_one(self, chat_id: int, msg_id: int) -> None:
591
- """Send one message back into the chat, without a "forwarded from" header.
592
-
593
- Telethon has no true copy (that is Bot-API only), so this forwards the
594
- message with ``drop_author`` — the attribution line is dropped, so the
595
- file reads as if the bot had posted it. Waiting out a flood wait.
596
- """
597
- try:
598
- await self.client.forward_messages(chat_id, [msg_id], chat_id, drop_author=True)
599
- except errors.FloodWaitError as exc:
600
- await asyncio.sleep(exc.seconds)
601
- await self.client.forward_messages(chat_id, [msg_id], chat_id, drop_author=True)
602
-
603
- async def _collect_file(self, event: Any) -> None:
604
- """Add an incoming file to the open sequence instead of renaming it."""
605
- uid = event.sender_id
606
- msg = event.message
607
- seq = await self.db.get_sequence(uid) or {"items": [], "copied": 0}
608
- items = list(seq["items"])
609
- items.append({"msg_id": msg.id, "name": original_name_of(msg), "caption": msg.raw_text or ""})
610
- await self.db.set_sequence(uid, items, copied=int(seq.get("copied") or 0))
611
- text = (
612
- f"🗂 <b>{len(items)}</b> file(s) collected.\n"
613
- "Send <code>/sort</code> when you're done."
614
- )
615
- status = self._seq_status.get(uid)
616
- if status is not None:
617
- try:
618
- await self.client.edit_message(status, text, parse_mode="html")
619
- return
620
- except Exception: # noqa: BLE001
621
- self._seq_status.pop(uid, None)
622
- self._seq_status[uid] = await self._reply(event, text)
623
-
624
  # ------------------------------------------------------------ rename flow
625
  async def cmd_rename(self, event: Any) -> None:
626
- uid = event.sender_id
627
- if self._args(event):
628
- await self._reply(
629
- event,
630
- "❌ /rename doesn't take a name any more.\nPick a format, then send me the file.",
631
- )
632
  return
633
- formats = await self.db.list_formats(uid)
634
- text, rows = panels.formats_list(formats, for_rename=True)
635
- await self._reply(event, text, rows)
636
-
637
- async def cmd_formats(self, event: Any) -> None:
638
- formats = await self.db.list_formats(event.sender_id)
639
- text, rows = panels.formats_list(formats, for_rename=False)
640
- await self._reply(event, text, rows)
641
-
642
- async def format_panel(self, uid: int, fmt_id: int) -> panels.Panel:
643
- fmt = await self.db.get_format(uid, fmt_id)
644
- if fmt is None:
645
- return panels.formats_list(await self.db.list_formats(uid), for_rename=False)
646
- return panels.format_editor(fmt)
647
 
648
  async def on_media(self, event: Any) -> None:
649
- uid = event.sender_id
650
- p = self.pending.get(uid)
651
- fmt = None
652
- if p is not None and p.kind == "rename_format" and p.format_id is not None:
653
- fmt = await self.db.get_format(uid, p.format_id)
654
- self.pending.pop(uid, None)
655
- if fmt is None:
656
- await self._reply(event, "❌ That format is gone — pick another one with /rename.")
657
- return
658
- elif await self.db.get_sequence(uid) is not None:
659
- await self._collect_file(event)
660
- return
661
- await self.enqueue(event, event.message, fmt=fmt)
662
 
663
- async def enqueue(self, event: Any, msg: Any, fmt: Optional[Dict[str, Any]] = None) -> None:
664
  uid = event.sender_id
665
  size = int(msg.file.size or 0) if msg.file is not None else 0
666
  if size > self.cfg.max_file_bytes:
667
  await self._reply(event, f"❌ That file is {human_size(size)}; the limit is {human_size(self.cfg.max_file_bytes)}.")
668
  return
669
  s = await self.db.get_settings(uid)
670
- thumb_mode, thumb = "auto", None
671
- if fmt is not None:
672
- s = resolve_format(fmt, s)
673
- thumb_mode, thumb = resolve_thumbnail(fmt, await self.db.get_thumb(uid))
674
  orig = original_name_of(msg)
675
  ext = source_ext(msg, orig)
676
  notes: List[str] = []
677
- new_name, info = compute_new_name(
678
- template=s["template"], mode=s["mode"], original_name=orig, original_ext=ext,
679
- caption=msg.raw_text or "", remove_words=s["remove_words"],
680
- )
681
- gaps = missing_variables(s["template"], info)
682
- if gaps:
683
- where_from = "caption" if s["mode"] == "caption" else "file name"
684
- notes.append(
685
- "Couldn't find " + ", ".join("{%s}" % g for g in gaps) + f" in the {where_from}, so it is left empty. "
686
- "Cancel and use /rename, or change the template."
687
  )
 
 
 
 
 
 
 
688
  job = Job(
689
  id=self.queue.new_id(), user_id=uid, chat_id=event.chat_id, msg_id=msg.id,
690
- file_name=orig, size=size, new_name=new_name, notes=notes, order=event.id,
691
- settings_override=s, thumbnail_mode=thumb_mode, thumbnail=thumb,
692
  )
693
  ahead = self.queue.running + self.queue.waiting
694
  where = "starting soon" if ahead == 0 else f"{ahead} ahead of it"
@@ -735,179 +536,6 @@ class App:
735
  count = self.queue.cancel_all() if (arg == "all" and self.is_owner(uid)) else self.queue.cancel_user(uid)
736
  await self._reply(event, f"🛑 Cancelling {count} job(s)…" if count else "You have no active jobs.")
737
 
738
- # ------------------------------------------------------------------ check
739
- async def cmd_check(self, event: Any) -> None:
740
- reply = await event.get_reply_message()
741
- if reply is None or not is_renameable(reply):
742
- await self._reply(event, "Reply to a file with <code>/check</code> to see what I can read from it.")
743
- return
744
- name = original_name_of(reply)
745
- stem, _ext = split_ext(name)
746
- file_info = parse_info(stem)
747
- caption_info = effective_info(stem, reply.raw_text or "", "caption")
748
- await self._reply(event, panels.check_report(file_info, caption_info))
749
-
750
- # ------------------------------------------------------------- thumbnails
751
- async def cmd_ethumb(self, event: Any) -> None:
752
- reply = await event.get_reply_message()
753
- if reply is None or not is_renameable(reply):
754
- await self._reply(event, "Reply to a video or file with <code>/ethumb</code> to take a thumbnail from it.")
755
- return
756
- status = await self._reply(event, "🔎 Looking for a frame…")
757
- name = original_name_of(reply)
758
- ext = source_ext(reply, name)
759
- tmp_dir = Path(self.cfg.temp_dir) / f"ethumb{event.chat_id}_{event.id}"
760
- tmp_dir.mkdir(parents=True, exist_ok=True)
761
- try:
762
- path = tmp_dir / f"src{ext}"
763
- await transfer.download(self.client, reply, path, fast=self.cfg.fast_transfer, connections=2)
764
- try:
765
- info = await media.probe(path)
766
- except media.MediaError:
767
- info = None
768
- if info is None or not info.of("video"):
769
- await self.client.edit_message(
770
- status, "❌ That file has no video — I can't take a thumbnail from it.", parse_mode="html"
771
- )
772
- return
773
- frame = tmp_dir / "thumb.jpg"
774
- if not await media.extract_frame(path, frame, info.duration):
775
- await self.client.edit_message(
776
- status, "❌ I couldn't grab a frame from that file.", parse_mode="html"
777
- )
778
- return
779
- data = frame.read_bytes()
780
- self._ethumbs[event.sender_id] = data
781
- await self.client.edit_message(status, "🖼 Here's the frame:", parse_mode="html")
782
- await self.client.send_file(
783
- event.chat_id, io.BytesIO(data), caption="Extracted frame",
784
- buttons=panels.to_buttons([[("Set as my thumbnail", "eth:set"), ("✖ Close", "x:close")]]),
785
- )
786
- finally:
787
- shutil.rmtree(tmp_dir, ignore_errors=True)
788
-
789
- async def cmd_resize(self, event: Any) -> None:
790
- reply = await event.get_reply_message()
791
- data = None
792
- if reply is not None and is_image(reply):
793
- data = await reply.download_media(bytes)
794
- if not data:
795
- await self._reply(event, "Reply to an image with <code>/resize</code>.")
796
- return
797
- self._resize_source[event.sender_id] = data
798
- await self._reply(
799
- event,
800
- "🖼 How big should it be?",
801
- [
802
- [("320", "rz:320"), ("640", "rz:640"), ("1280", "rz:1280")],
803
- [("Original", "rz:orig"), ("Custom", "rz:custom")],
804
- [("✖ Close", "x:close")],
805
- ],
806
- )
807
-
808
- async def _cb_resize(self, event: Any, uid: int, size: str) -> None:
809
- source = self._resize_source.get(uid)
810
- if source is None:
811
- await event.answer("That image is gone — run /resize again.", alert=True)
812
- return
813
- if size == "custom":
814
- self.pending[uid] = Pending(
815
- kind="resize_size", chat_id=event.chat_id, msg_id=event.message_id,
816
- back="formats", is_photo=False, expires=time.time() + self.PENDING_TTL,
817
- )
818
- await event.answer()
819
- await self._edit_panel(
820
- event,
821
- "Send the size: a number for the width (<code>800</code>) or <code>800x450</code>.",
822
- [[("✖ Cancel", "x:close")]],
823
- )
824
- return
825
- try:
826
- data = source if size == "orig" else media.resize_image(source, width=int(size))
827
- except Exception as exc: # noqa: BLE001
828
- await event.answer(f"I couldn't resize that: {exc}", alert=True)
829
- return
830
- await self._send_resized(event, uid, data)
831
-
832
- async def _send_resized(self, event: Any, uid: int, data: bytes) -> None:
833
- self._ethumbs[uid] = data
834
- await self.client.send_file(
835
- event.chat_id, io.BytesIO(data), caption="Resized image",
836
- buttons=panels.to_buttons([[("Set as my thumbnail", "eth:set"), ("✖ Close", "x:close")]]),
837
- )
838
- await event.answer()
839
-
840
- # --------------------------------------------------------------- mediainfo
841
- async def cmd_mediainfo(self, event: Any) -> None:
842
- reply = await event.get_reply_message()
843
- if reply is None or not is_renameable(reply):
844
- await self._reply(event, "Reply to a file with <code>/mediainfo</code> to see everything about it.")
845
- return
846
- status = await self._reply(event, "🔎 Reading the file…")
847
- name = original_name_of(reply)
848
- ext = source_ext(reply, name)
849
- tmp_dir = Path(self.cfg.temp_dir) / f"mi{event.chat_id}_{event.id}"
850
- tmp_dir.mkdir(parents=True, exist_ok=True)
851
- try:
852
- path = tmp_dir / f"src{ext}"
853
- await transfer.download(
854
- self.client, reply, path, fast=self.cfg.fast_transfer, connections=self.cfg.connections
855
- )
856
- probe = await media.probe_full(path)
857
- url = await self._publish_media_info(probe, name)
858
- if url:
859
- await self.client.edit_message(
860
- status, f"📋 <b>Media info</b>\n<a href=\"{esc(url)}\">open the report</a>", parse_mode="html"
861
- )
862
- return
863
- summary = clip(telegraph.build_summary(probe, name), 3500)
864
- await self.client.edit_message(
865
- status, f"📋 <b>Media info</b> (telegra.ph is unreachable, so here it is)\n\n{esc(summary)}",
866
- parse_mode="html",
867
- )
868
- except Exception as exc: # noqa: BLE001
869
- await self.client.edit_message(
870
- status, f"❌ I couldn't read that file: {esc(clip(str(exc), 200))}", parse_mode="html"
871
- )
872
- finally:
873
- shutil.rmtree(tmp_dir, ignore_errors=True)
874
-
875
- async def _publish_media_info(self, probe: Dict[str, Any], name: str) -> str:
876
- """Upload the report to telegra.ph and return its URL ('' when it fails)."""
877
- try:
878
- token = await self.db.kv_get("telegraph_token")
879
- if not token:
880
- token = await telegraph.Telegraph().create_account()
881
- if token:
882
- await self.db.kv_set("telegraph_token", token)
883
- if not token:
884
- return ""
885
- page = telegraph.build_page(probe, name)
886
- return await telegraph.Telegraph(token).upload_page(f"Media info — {name}", [page])
887
- except Exception as exc: # noqa: BLE001
888
- log.warning("telegra.ph upload failed: %s", exc)
889
- return ""
890
-
891
- # ------------------------------------------------------------------ speed
892
- async def cmd_speed(self, event: Any) -> None:
893
- status = await self._reply(event, "📡 Testing your connection…")
894
- try:
895
- result = await run_speedtest()
896
- except FileNotFoundError:
897
- await self.client.edit_message(
898
- status, "❌ The speedtest tool isn't installed in this container.", parse_mode="html"
899
- )
900
- return
901
- except asyncio.TimeoutError:
902
- await self.client.edit_message(status, "❌ The speed test timed out.", parse_mode="html")
903
- return
904
- except Exception as exc: # noqa: BLE001
905
- await self.client.edit_message(
906
- status, f"❌ The speed test failed: {esc(clip(str(exc), 200))}", parse_mode="html"
907
- )
908
- return
909
- await self.client.edit_message(status, format_speed(result), parse_mode="html")
910
-
911
  # ------------------------------------------------------------------ tracks
912
  async def cmd_tracks(self, event: Any) -> None:
913
  reply = await event.get_reply_message()
@@ -1002,15 +630,6 @@ class App:
1002
  if p is None or p.expires < time.time():
1003
  self.pending.pop(uid, None)
1004
  return
1005
- if p.kind == "rename_format":
1006
- await self._reply(event, "Send me the <b>file</b> to rename, not text.")
1007
- return
1008
- if p.kind == "resize_size":
1009
- await self._on_resize_text(event, uid, p)
1010
- return
1011
- if p.format_id is not None or p.kind == "fmt_name":
1012
- await self._on_format_text(event, uid, p)
1013
- return
1014
  ok, msg = await apply_setting(self.db, uid, p.kind, event.raw_text)
1015
  if not ok:
1016
  await self._reply(event, msg + "\n\nTry again, or tap Cancel.")
@@ -1028,179 +647,7 @@ class App:
1028
  except Exception: # noqa: BLE001
1029
  await self._reply(event, full, rows)
1030
 
1031
- async def _on_resize_text(self, event: Any, uid: int, p: Pending) -> None:
1032
- """Handle the size the user typed for a custom /resize."""
1033
- width, height, error = media.parse_size(event.raw_text or "")
1034
- if error:
1035
- await self._reply(event, "❌ " + error)
1036
- return
1037
- source = self._resize_source.get(uid)
1038
- if not source:
1039
- self.pending.pop(uid, None)
1040
- await self._reply(event, "❌ That image is gone — run /resize again.")
1041
- return
1042
- try:
1043
- data = media.resize_image(source, width=width, height=height)
1044
- except Exception as exc: # noqa: BLE001
1045
- await self._reply(event, f"❌ I couldn't resize that: {esc(clip(str(exc), 120))}")
1046
- return
1047
- self.pending.pop(uid, None)
1048
- self._ethumbs[uid] = data
1049
- await self.client.send_file(
1050
- event.chat_id, io.BytesIO(data), caption="Resized image",
1051
- buttons=panels.to_buttons([[("Set as my thumbnail", "eth:set"), ("✖ Close", "x:close")]]),
1052
- )
1053
-
1054
- # ------------------------------------------------------------------ smedia
1055
- async def cmd_smedia(self, event: Any) -> None:
1056
- banner = await self.db.get_start_media()
1057
- kind = banner["kind"] if banner else None
1058
- text, rows = panels.smedia_panel(kind, banner is not None)
1059
- await self._reply(event, text, rows)
1060
-
1061
- async def on_animation(self, event: Any) -> None:
1062
- """Catches a GIF sent while the owner is setting the start screen."""
1063
- uid = event.sender_id
1064
- p = self.pending.get(uid)
1065
- if p is None or p.kind != "smedia_gif":
1066
- return
1067
- raw = await event.download_media(bytes)
1068
- if not raw:
1069
- await self._reply(event, "❌ I couldn't download that GIF.")
1070
- return
1071
- await self.db.set_start_media("gif", raw)
1072
- self.pending.pop(uid, None)
1073
- await self._reply(event, "✅ Start GIF saved.")
1074
-
1075
  # ---------------------------------------------------------------- callbacks
1076
- async def _on_format_text(self, event: Any, uid: int, p: Pending) -> None:
1077
- """Handle a text reply that fills in one field of a saved format."""
1078
- text = (event.raw_text or "").strip()
1079
- fmt_id = p.format_id
1080
- if p.kind == "fmt_name" and fmt_id is None:
1081
- created = await self.db.create_format(uid, text)
1082
- if created is None:
1083
- await self._reply(event, "❌ That name won't do — send a different one.")
1084
- return
1085
- fmt_id = created["id"]
1086
- message = f"✅ Created <b>{esc(created['name'])}</b>."
1087
- else:
1088
- ok, message = await apply_format_field(self.db, uid, fmt_id, p.kind, text)
1089
- if not ok:
1090
- await self._reply(event, message + "\n\nTry again, or tap Cancel.")
1091
- return
1092
- self.pending.pop(uid, None)
1093
- try:
1094
- await event.delete()
1095
- except Exception: # noqa: BLE001
1096
- pass
1097
- panel_text, rows = await self.format_panel(uid, fmt_id)
1098
- full = (
1099
- f"{message}\n\n{panel_text}"
1100
- if visible_len(message) + visible_len(panel_text) < 3900
1101
- else f"{message}\n\n(Open the menu to see the update.)"
1102
- )
1103
- try:
1104
- await self.client.edit_message(
1105
- p.chat_id, p.msg_id, full, buttons=panels.to_buttons(rows), parse_mode="html"
1106
- )
1107
- except Exception: # noqa: BLE001
1108
- await self._reply(event, full, rows)
1109
-
1110
- async def _cb_use_format(self, event: Any, uid: int, fmt_id: int) -> None:
1111
- fmt = await self.db.get_format(uid, fmt_id)
1112
- if fmt is None:
1113
- await event.answer("That format is gone.", alert=True)
1114
- return
1115
- self.pending[uid] = Pending(
1116
- kind="rename_format", chat_id=event.chat_id, msg_id=event.message_id,
1117
- back="formats", is_photo=False, expires=time.time() + self.PENDING_TTL, format_id=fmt_id,
1118
- )
1119
- await event.answer()
1120
- await self._edit_panel(
1121
- event, f"✅ <b>{esc(fmt['name'])}</b>\n\nSend me the file to rename with it.", [[("✖ Close", "x:close")]]
1122
- )
1123
-
1124
- async def _cb_open_format(self, event: Any, uid: int, fmt_id: int) -> None:
1125
- text, rows = await self.format_panel(uid, fmt_id)
1126
- await self._edit_panel(event, text, rows)
1127
- await event.answer()
1128
-
1129
- async def _cb_new_format(self, event: Any, uid: int) -> None:
1130
- if await self.db.count_formats(uid) >= MAX_FORMATS:
1131
- await event.answer(f"You already have {MAX_FORMATS} formats.", alert=True)
1132
- return
1133
- self.pending[uid] = Pending(
1134
- kind="fmt_name", chat_id=event.chat_id, msg_id=event.message_id,
1135
- back="formats", is_photo=False, expires=time.time() + self.PENDING_TTL,
1136
- )
1137
- await event.answer()
1138
- await self._edit_panel(
1139
- event, panels.FORMAT_PROMPTS["fmt_name"], [[("✖ Cancel", "x:close")]]
1140
- )
1141
-
1142
- async def _cb_set_format_field(self, event: Any, uid: int, kind: str, fmt_id: int) -> None:
1143
- if await self.db.get_format(uid, fmt_id) is None:
1144
- await event.answer("That format is gone.", alert=True)
1145
- return
1146
- self.pending[uid] = Pending(
1147
- kind=kind, chat_id=event.chat_id, msg_id=event.message_id,
1148
- back="formats", is_photo=False, expires=time.time() + self.PENDING_TTL, format_id=fmt_id,
1149
- )
1150
- await event.answer()
1151
- await self._edit_panel(
1152
- event, panels.FORMAT_PROMPTS[kind], [[("✖ Cancel", "x:close")]]
1153
- )
1154
-
1155
- async def _cb_format_thumb(self, event: Any, uid: int, action: str, fmt_id: int) -> None:
1156
- if await self.db.get_format(uid, fmt_id) is None:
1157
- await event.answer("That format is gone.", alert=True)
1158
- return
1159
- if action == "fthumb":
1160
- self.pending[uid] = Pending(
1161
- kind="fmt_thumb", chat_id=event.chat_id, msg_id=event.message_id,
1162
- back="formats", is_photo=True, expires=time.time() + self.PENDING_TTL, format_id=fmt_id,
1163
- )
1164
- await event.answer()
1165
- await self._edit_panel(
1166
- event, "Send me the photo to use as this format's thumbnail.", [[("✖ Cancel", "x:close")]]
1167
- )
1168
- return
1169
- if action == "fthumbdef":
1170
- await self.db.update_format(uid, fmt_id, thumb=None)
1171
- toast = "Thumbnail back to your default."
1172
- else:
1173
- await self.db.update_format(uid, fmt_id, thumb=b"")
1174
- toast = "No thumbnail for this format."
1175
- text, rows = await self.format_panel(uid, fmt_id)
1176
- await self._edit_panel(event, text, rows)
1177
- await event.answer(toast)
1178
-
1179
- async def _cb_rename_format(self, event: Any, uid: int, fmt_id: int) -> None:
1180
- if await self.db.get_format(uid, fmt_id) is None:
1181
- await event.answer("That format is gone.", alert=True)
1182
- return
1183
- self.pending[uid] = Pending(
1184
- kind="fmt_name", chat_id=event.chat_id, msg_id=event.message_id,
1185
- back="formats", is_photo=False, expires=time.time() + self.PENDING_TTL, format_id=fmt_id,
1186
- )
1187
- await event.answer()
1188
- await self._edit_panel(
1189
- event, panels.FORMAT_PROMPTS["fmt_name"], [[("✖ Cancel", "x:close")]]
1190
- )
1191
-
1192
- async def _cb_delete_format(self, event: Any, uid: int, fmt_id: int) -> None:
1193
- fmt = await self.db.get_format(uid, fmt_id)
1194
- if fmt is None:
1195
- await event.answer("That format is gone.", alert=True)
1196
- return
1197
- await self._edit_panel(
1198
- event,
1199
- f"🗑 Delete <b>{esc(fmt['name'])}</b>?",
1200
- [[("Yes, delete it", f"fdelok:{fmt_id}"), ("✖ Keep it", f"fl:{fmt_id}")]],
1201
- )
1202
- await event.answer()
1203
-
1204
  async def on_callback(self, event: Any) -> None:
1205
  uid = event.sender_id
1206
  data = event.data.decode("utf-8", "ignore")
@@ -1213,62 +660,6 @@ class App:
1213
  await self._remove_message(event)
1214
  return
1215
 
1216
- if data == "sq:cancel":
1217
- await self.db.clear_sequence(uid)
1218
- self._seq_status.pop(uid, None)
1219
- await event.answer("Sequence cancelled.")
1220
- await self._remove_message(event)
1221
- return
1222
-
1223
- if data == "eth:set":
1224
- data_bytes = self._ethumbs.get(uid)
1225
- if not data_bytes:
1226
- await event.answer("That image is gone — run /ethumb or /resize again.", alert=True)
1227
- return
1228
- try:
1229
- thumb = await asyncio.to_thread(media.prepare_thumb, data_bytes)
1230
- except Exception: # noqa: BLE001
1231
- await event.answer("I couldn't read that image.", alert=True)
1232
- return
1233
- await self.db.set_thumb(uid, thumb)
1234
- await event.answer("Thumbnail saved.")
1235
- return
1236
-
1237
- if head == "rz" and len(parts) == 2 and parts[1]:
1238
- await self._cb_resize(event, uid, parts[1])
1239
- return
1240
-
1241
- if data in ("smedia:image", "smedia:gif"):
1242
- kind = "smedia_image" if data.endswith("image") else "smedia_gif"
1243
- self.pending[uid] = Pending(
1244
- kind=kind, chat_id=event.chat_id, msg_id=event.message_id,
1245
- back="home", is_photo=kind == "smedia_image",
1246
- expires=time.time() + self.PENDING_TTL,
1247
- )
1248
- await event.answer()
1249
- await self._edit_panel(
1250
- event,
1251
- "Send me the image." if kind == "smedia_image" else "Send me the GIF.",
1252
- [[("✖ Cancel", "x:close")]],
1253
- )
1254
- return
1255
-
1256
- if data == "smedia:view":
1257
- banner = await self.db.get_start_media()
1258
- if not banner:
1259
- await event.answer("There is no start screen yet.", alert=True)
1260
- return
1261
- await event.answer()
1262
- await self._send_banner(event.chat_id, banner, "This is the start screen.", [])
1263
- return
1264
-
1265
- if data == "smedia:del":
1266
- await self.db.clear_start_media()
1267
- text, rows = panels.smedia_panel(None, False)
1268
- await self._edit_panel(event, text, rows)
1269
- await event.answer("Start screen deleted.")
1270
- return
1271
-
1272
  if head == "m" and len(parts) == 2 and parts[1] in PANEL_NAMES:
1273
  self.pending.pop(uid, None)
1274
  if parts[1] == "admin" and not self.is_owner(uid):
@@ -1283,42 +674,6 @@ class App:
1283
  await self._cb_set(event, uid, parts[1], parts[2])
1284
  return
1285
 
1286
- if head == "fm" and len(parts) == 2 and parts[1].isdigit():
1287
- await self._cb_use_format(event, uid, int(parts[1]))
1288
- return
1289
-
1290
- if head == "fl" and len(parts) == 2 and parts[1].isdigit():
1291
- await self._cb_open_format(event, uid, int(parts[1]))
1292
- return
1293
-
1294
- if data == "fnew":
1295
- await self._cb_new_format(event, uid)
1296
- return
1297
-
1298
- if head == "fset" and len(parts) == 3 and parts[1] in panels.FORMAT_PROMPTS and parts[2].isdigit():
1299
- await self._cb_set_format_field(event, uid, parts[1], int(parts[2]))
1300
- return
1301
-
1302
- if head in ("fthumb", "fthumbdef", "fthumbnone") and len(parts) == 2 and parts[1].isdigit():
1303
- await self._cb_format_thumb(event, uid, head, int(parts[1]))
1304
- return
1305
-
1306
- if head == "frename" and len(parts) == 2 and parts[1].isdigit():
1307
- await self._cb_rename_format(event, uid, int(parts[1]))
1308
- return
1309
-
1310
- if head == "fdel" and len(parts) == 2 and parts[1].isdigit():
1311
- await self._cb_delete_format(event, uid, int(parts[1]))
1312
- return
1313
-
1314
- if head == "fdelok" and len(parts) == 2 and parts[1].isdigit():
1315
- fmt_id = int(parts[1])
1316
- ok = await self.db.delete_format(uid, fmt_id)
1317
- text, rows = panels.formats_list(await self.db.list_formats(uid), for_rename=False)
1318
- await self._edit_panel(event, text, rows)
1319
- await event.answer("Format deleted." if ok else "That format is gone.")
1320
- return
1321
-
1322
  if head == "in":
1323
  await self._cb_input(event, uid, parts)
1324
  return
@@ -1415,7 +770,7 @@ class App:
1415
  await event.answer("Cancelled.")
1416
  return
1417
  kind = parts[1] if len(parts) == 2 else ""
1418
- if kind not in panels.PROMPTS or kind.startswith("fmt"):
1419
  await event.answer()
1420
  return
1421
  msg_obj = await event.get_message()
 
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__)
 
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"),
 
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
 
62
 
63
  @dataclass
 
68
  back: str
69
  is_photo: bool
70
  expires: float
 
71
 
72
 
73
  def cmd_pattern(name: str) -> "re.Pattern[str]":
 
89
  )
90
 
91
 
 
 
 
 
 
 
 
 
92
  class App:
93
  PENDING_TTL = 300
94
 
 
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()
 
 
 
104
 
105
  # ------------------------------------------------------------------ auth
106
  def is_owner(self, uid: Optional[int]) -> bool:
 
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)))
 
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():
 
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
 
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()
287
  await self._reply(event, text, [[("✖ Close", "x:close")]])
 
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.")
 
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:
470
  await self._reply(event, f"❌ That file is {human_size(size)}; the limit is {human_size(self.cfg.max_file_bytes)}.")
471
  return
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"
 
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()
 
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.")
 
647
  except Exception: # noqa: BLE001
648
  await self._reply(event, full, rows)
649
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
650
  # ---------------------------------------------------------------- callbacks
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
651
  async def on_callback(self, event: Any) -> None:
652
  uid = event.sender_id
653
  data = event.data.decode("utf-8", "ignore")
 
660
  await self._remove_message(event)
661
  return
662
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
663
  if head == "m" and len(parts) == 2 and parts[1] in PANEL_NAMES:
664
  self.pending.pop(uid, None)
665
  if parts[1] == "admin" and not self.is_owner(uid):
 
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
 
770
  await event.answer("Cancelled.")
771
  return
772
  kind = parts[1] if len(parts) == 2 else ""
773
+ if kind not in panels.PROMPTS:
774
  await event.answer()
775
  return
776
  msg_obj = await event.get_message()
bot/config.py CHANGED
@@ -50,6 +50,7 @@ class Config:
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,6 +91,7 @@ def load_config(env: Optional[Mapping[str, str]] = None) -> Config:
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,
 
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
  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,
bot/db.py CHANGED
@@ -12,7 +12,7 @@ from datetime import datetime, timezone
12
  from typing import Any, Dict, List, Optional, Tuple
13
  from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
14
 
15
- from sqlalchemy import BigInteger, DateTime, Integer, LargeBinary, String, Text, UniqueConstraint, func, select
16
  from sqlalchemy.dialects.postgresql import insert as pg_insert
17
  from sqlalchemy.dialects.sqlite import insert as sqlite_insert
18
  from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine
@@ -20,9 +20,6 @@ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
20
 
21
  from .parser import DEFAULT_TEMPLATE
22
 
23
- MAX_FORMATS = 12
24
- META_FIELDS = ("title", "author", "artist", "audio", "subtitle", "video")
25
-
26
  DEFAULT_SETTINGS: Dict[str, Any] = {
27
  "mode": "filename", # filename | caption
28
  "template": DEFAULT_TEMPLATE,
@@ -71,50 +68,6 @@ class KV(Base):
71
  value: Mapped[str] = mapped_column(Text, default="")
72
 
73
 
74
- class Format(Base):
75
- """A saved, named rename format.
76
-
77
- Every field is tri-state: ``NULL`` means "inherit the user's global
78
- setting", while a stored value overrides it. ``keep_audio`` /
79
- ``keep_subtitle`` store JSON, so ``NULL`` (inherit) stays distinct from
80
- ``[]`` (explicitly keep every track).
81
- """
82
-
83
- __tablename__ = "formats"
84
- __table_args__ = (UniqueConstraint("user_id", "name"),)
85
-
86
- id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
87
- user_id: Mapped[int] = mapped_column(BigInteger, index=True)
88
- name: Mapped[str] = mapped_column(String(64))
89
- position: Mapped[int] = mapped_column(Integer, default=0)
90
- template: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
91
- thumb: Mapped[Optional[bytes]] = mapped_column(LargeBinary, nullable=True)
92
- meta: Mapped[str] = mapped_column(Text, default="{}")
93
- keep_audio: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
94
- keep_subtitle: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
95
-
96
-
97
- class SequenceRow(Base):
98
- """Files collected for a ``/sequence`` batch, waiting to be sorted."""
99
-
100
- __tablename__ = "sequences"
101
-
102
- user_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
103
- items: Mapped[str] = mapped_column(Text, default="[]")
104
- copied: Mapped[int] = mapped_column(Integer, default=0)
105
- updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
106
-
107
-
108
- class BotMedia(Base):
109
- """The banner shown on ``/start`` (one row, id 1)."""
110
-
111
- __tablename__ = "bot_media"
112
-
113
- id: Mapped[int] = mapped_column(Integer, primary_key=True)
114
- kind: Mapped[str] = mapped_column(String(8))
115
- data: Mapped[bytes] = mapped_column(LargeBinary)
116
-
117
-
118
  def normalize_database_url(url: str) -> Tuple[str, Dict[str, Any]]:
119
  """Turn a provider URL into something SQLAlchemy's async drivers accept."""
120
  connect_args: Dict[str, Any] = {}
@@ -133,17 +86,6 @@ def normalize_database_url(url: str) -> Tuple[str, Dict[str, Any]]:
133
  return url, connect_args
134
 
135
 
136
- def _decode_list(raw: Optional[str]) -> Optional[List[str]]:
137
- """``NULL`` stays ``None`` (inherit); a JSON array decodes to a list."""
138
- if raw is None:
139
- return None
140
- try:
141
- value = json.loads(raw)
142
- except ValueError:
143
- return None
144
- return list(value) if isinstance(value, list) else None
145
-
146
-
147
  def _merge_settings(stored: Dict[str, Any]) -> Dict[str, Any]:
148
  merged = copy.deepcopy(DEFAULT_SETTINGS)
149
  for key, value in stored.items():
@@ -271,189 +213,6 @@ class Database:
271
  await session.commit()
272
  return True
273
 
274
- # ----- formats --------------------------------------------------------------------
275
- @staticmethod
276
- def _format_dict(row: Format) -> Dict[str, Any]:
277
- try:
278
- meta = json.loads(row.meta or "{}")
279
- except ValueError:
280
- meta = {}
281
- if not isinstance(meta, dict):
282
- meta = {}
283
- return {
284
- "id": row.id,
285
- "name": row.name,
286
- "template": row.template,
287
- "thumb": bytes(row.thumb) if row.thumb is not None else None,
288
- "meta": {field: meta.get(field) for field in META_FIELDS},
289
- "keep_audio": _decode_list(row.keep_audio),
290
- "keep_subtitle": _decode_list(row.keep_subtitle),
291
- }
292
-
293
- async def list_formats(self, uid: int) -> List[Dict[str, Any]]:
294
- async with self._sessions() as session:
295
- rows = (
296
- await session.execute(
297
- select(Format).where(Format.user_id == uid).order_by(Format.position, Format.id)
298
- )
299
- ).scalars().all()
300
- return [self._format_dict(row) for row in rows]
301
-
302
- async def get_format(self, uid: int, fmt_id: int) -> Optional[Dict[str, Any]]:
303
- async with self._sessions() as session:
304
- row = await session.get(Format, fmt_id)
305
- if row is None or row.user_id != uid:
306
- return None
307
- return self._format_dict(row)
308
-
309
- async def count_formats(self, uid: int) -> int:
310
- async with self._sessions() as session:
311
- result = await session.execute(
312
- select(func.count()).select_from(Format).where(Format.user_id == uid)
313
- )
314
- return int(result.scalar() or 0)
315
-
316
- async def create_format(self, uid: int, name: str) -> Optional[Dict[str, Any]]:
317
- """Create a format. ``None`` when the name is taken, blank or the 12-slot limit is hit."""
318
- name = (name or "").strip()
319
- if not name:
320
- return None
321
- async with self._sessions() as session:
322
- clash = (
323
- await session.execute(select(Format).where(Format.user_id == uid, Format.name == name))
324
- ).scalars().first()
325
- if clash is not None:
326
- return None
327
- count = int(
328
- (
329
- await session.execute(
330
- select(func.count()).select_from(Format).where(Format.user_id == uid)
331
- )
332
- ).scalar()
333
- or 0
334
- )
335
- if count >= MAX_FORMATS:
336
- return None
337
- row = Format(
338
- user_id=uid,
339
- name=name,
340
- position=count,
341
- meta=json.dumps({field: None for field in META_FIELDS}),
342
- )
343
- session.add(row)
344
- await session.commit()
345
- return self._format_dict(row)
346
-
347
- async def update_format(self, uid: int, fmt_id: int, **changes: Any) -> bool:
348
- """Update a format. Only the keys passed in are touched."""
349
- allowed = {"name", "template", "thumb", "meta", "keep_audio", "keep_subtitle"}
350
- if not changes or not set(changes) <= allowed:
351
- return False
352
- async with self._sessions() as session:
353
- row = await session.get(Format, fmt_id)
354
- if row is None or row.user_id != uid:
355
- return False
356
- if "name" in changes:
357
- name = (changes["name"] or "").strip()
358
- if not name:
359
- return False
360
- clash = (
361
- await session.execute(
362
- select(Format).where(
363
- Format.user_id == uid, Format.name == name, Format.id != fmt_id
364
- )
365
- )
366
- ).scalars().first()
367
- if clash is not None:
368
- return False
369
- row.name = name
370
- if "template" in changes:
371
- row.template = changes["template"]
372
- if "thumb" in changes:
373
- value = changes["thumb"]
374
- row.thumb = None if value is None else bytes(value)
375
- if "meta" in changes:
376
- try:
377
- current = json.loads(row.meta or "{}")
378
- except ValueError:
379
- current = {}
380
- if not isinstance(current, dict):
381
- current = {}
382
- for field, value in (changes["meta"] or {}).items():
383
- if field in META_FIELDS:
384
- current[field] = value
385
- row.meta = json.dumps(current, ensure_ascii=False)
386
- for key in ("keep_audio", "keep_subtitle"):
387
- if key in changes:
388
- value = changes[key]
389
- setattr(row, key, None if value is None else json.dumps(list(value), ensure_ascii=False))
390
- await session.commit()
391
- return True
392
-
393
- async def delete_format(self, uid: int, fmt_id: int) -> bool:
394
- async with self._sessions() as session:
395
- row = await session.get(Format, fmt_id)
396
- if row is None or row.user_id != uid:
397
- return False
398
- await session.delete(row)
399
- await session.commit()
400
- return True
401
-
402
- # ----- sequences ---------------------------------------------------------------------
403
- async def get_sequence(self, uid: int) -> Optional[Dict[str, Any]]:
404
- async with self._sessions() as session:
405
- row = await session.get(SequenceRow, uid)
406
- if row is None:
407
- return None
408
- try:
409
- items = json.loads(row.items or "[]")
410
- except ValueError:
411
- items = []
412
- return {"items": items if isinstance(items, list) else [], "copied": int(row.copied or 0)}
413
-
414
- async def set_sequence(self, uid: int, items: List[Dict[str, Any]], copied: int = 0) -> None:
415
- async with self._sessions() as session:
416
- row = await session.get(SequenceRow, uid)
417
- if row is None:
418
- row = SequenceRow(user_id=uid, items="[]", copied=0)
419
- session.add(row)
420
- row.items = json.dumps(items, ensure_ascii=False)
421
- row.copied = int(copied)
422
- row.updated_at = _utcnow()
423
- await session.commit()
424
-
425
- async def clear_sequence(self, uid: int) -> None:
426
- async with self._sessions() as session:
427
- row = await session.get(SequenceRow, uid)
428
- if row is not None:
429
- await session.delete(row)
430
- await session.commit()
431
-
432
- # ----- start banner ---------------------------------------------------------------------
433
- async def get_start_media(self) -> Optional[Dict[str, Any]]:
434
- async with self._sessions() as session:
435
- row = await session.get(BotMedia, 1)
436
- if row is None:
437
- return None
438
- return {"kind": row.kind, "data": bytes(row.data)}
439
-
440
- async def set_start_media(self, kind: str, data: bytes) -> None:
441
- async with self._sessions() as session:
442
- row = await session.get(BotMedia, 1)
443
- if row is None:
444
- session.add(BotMedia(id=1, kind=kind, data=data))
445
- else:
446
- row.kind = kind
447
- row.data = data
448
- await session.commit()
449
-
450
- async def clear_start_media(self) -> None:
451
- async with self._sessions() as session:
452
- row = await session.get(BotMedia, 1)
453
- if row is not None:
454
- await session.delete(row)
455
- await session.commit()
456
-
457
  # ----- key/value (session string etc.) ---------------------------------------------
458
  async def kv_get(self, key: str) -> Optional[str]:
459
  async with self._sessions() as session:
 
12
  from typing import Any, Dict, List, Optional, Tuple
13
  from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
14
 
15
+ from sqlalchemy import BigInteger, DateTime, Integer, LargeBinary, String, Text, func, select
16
  from sqlalchemy.dialects.postgresql import insert as pg_insert
17
  from sqlalchemy.dialects.sqlite import insert as sqlite_insert
18
  from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine
 
20
 
21
  from .parser import DEFAULT_TEMPLATE
22
 
 
 
 
23
  DEFAULT_SETTINGS: Dict[str, Any] = {
24
  "mode": "filename", # filename | caption
25
  "template": DEFAULT_TEMPLATE,
 
68
  value: Mapped[str] = mapped_column(Text, default="")
69
 
70
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  def normalize_database_url(url: str) -> Tuple[str, Dict[str, Any]]:
72
  """Turn a provider URL into something SQLAlchemy's async drivers accept."""
73
  connect_args: Dict[str, Any] = {}
 
86
  return url, connect_args
87
 
88
 
 
 
 
 
 
 
 
 
 
 
 
89
  def _merge_settings(stored: Dict[str, Any]) -> Dict[str, Any]:
90
  merged = copy.deepcopy(DEFAULT_SETTINGS)
91
  for key, value in stored.items():
 
213
  await session.commit()
214
  return True
215
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
216
  # ----- key/value (session string etc.) ---------------------------------------------
217
  async def kv_get(self, key: str) -> Optional[str]:
218
  async with self._sessions() as session:
bot/formats.py DELETED
@@ -1,160 +0,0 @@
1
- """Saved rename formats: the per-user named templates.
2
-
3
- A format carries its own template, thumbnail, metadata and track-keep lists.
4
- Every field is **tri-state**:
5
-
6
- * ``None`` — inherit the user's global setting for that field
7
- * ``""`` / ``[]`` — explicitly empty, overriding the global setting
8
- * a value — an override
9
-
10
- The distinction matters: without it there would be no way to say "this format keeps
11
- every track" when the global keep-audio list is set, and no way to say "this format
12
- has no title" when the global title is set.
13
- """
14
- from __future__ import annotations
15
-
16
- import copy
17
- from typing import Any, Dict, List, Optional, Tuple
18
-
19
- from .db import META_FIELDS, MAX_FORMATS, Database
20
- from .media import parse_track_tokens, validate_track_tokens
21
- from .panels import fmt_tokens
22
- from .settings_ops import CLEAR_WORDS, preview_template
23
- from .utils import clip, esc
24
-
25
- META_KINDS = {
26
- "fmt_title": "title",
27
- "fmt_author": "author",
28
- "fmt_artist": "artist",
29
- "fmt_audio": "audio",
30
- "fmt_subtitle": "subtitle",
31
- "fmt_video": "video",
32
- }
33
-
34
- KEEP_KINDS = {
35
- "fmt_keep_audio": "keep_audio",
36
- "fmt_keep_subtitle": "keep_subtitle",
37
- }
38
-
39
- MAX_TEMPLATE_LEN = 200
40
- MAX_META_LEN = 200
41
- MAX_TRACKS = 12
42
- DEFAULT_WORDS = {"default", "inherit", "my default"}
43
-
44
- __all__ = [
45
- "MAX_FORMATS",
46
- "META_FIELDS",
47
- "META_KINDS",
48
- "apply_format_field",
49
- "resolve_format",
50
- "resolve_thumbnail",
51
- ]
52
-
53
-
54
- def _is_clear(text: str) -> bool:
55
- return text.strip().lower() in CLEAR_WORDS
56
-
57
-
58
- def _is_default(text: str) -> bool:
59
- """``default`` means "go back to my global setting"."""
60
- return text.strip().lower() in DEFAULT_WORDS
61
-
62
-
63
- def resolve_format(fmt: Dict[str, Any], settings: Dict[str, Any]) -> Dict[str, Any]:
64
- """Overlay a format's fields onto the user's global settings.
65
-
66
- Only fields the format actually sets are overlaid; everything else keeps the
67
- global value. The input settings are not modified.
68
- """
69
- out = copy.deepcopy(settings)
70
- if fmt.get("template") is not None:
71
- out["template"] = fmt["template"]
72
- meta = dict(out.get("meta") or {})
73
- for field in META_FIELDS:
74
- value = (fmt.get("meta") or {}).get(field)
75
- if value is not None:
76
- meta[field] = value
77
- out["meta"] = meta
78
- if fmt.get("keep_audio") is not None:
79
- out["keep_audio"] = list(fmt["keep_audio"])
80
- if fmt.get("keep_subtitle") is not None:
81
- out["keep_subtitle"] = list(fmt["keep_subtitle"])
82
- return out
83
-
84
-
85
- def resolve_thumbnail(fmt: Dict[str, Any], global_thumb: Optional[bytes]) -> Tuple[str, Optional[bytes]]:
86
- """Work out which thumbnail a format sends, as ``(mode, bytes)``.
87
-
88
- * ``"auto"`` — inherit the user's global thumbnail; a frame of the video
89
- stands in when there is none
90
- * ``"custom"`` — the format's own image
91
- * ``"none"`` — no thumbnail at all, and never a video frame
92
- """
93
- thumb = fmt.get("thumb")
94
- if thumb is None:
95
- return "auto", global_thumb
96
- if not thumb:
97
- return "none", None
98
- return "custom", bytes(thumb)
99
-
100
-
101
- async def apply_format_field(db: Database, uid: int, fmt_id: int, kind: str, text: str) -> Tuple[bool, str]:
102
- """Validate and store one format field. Returns ``(ok, html_message)``."""
103
- text = (text or "").strip()
104
- if await db.get_format(uid, fmt_id) is None:
105
- return False, "❌ That format is gone."
106
-
107
- if kind == "fmt_name":
108
- if not text or _is_clear(text):
109
- return False, "❌ Send a name for the format."
110
- if len(text) > 60:
111
- return False, "❌ That name is too long (60 characters max)."
112
- if not await db.update_format(uid, fmt_id, name=text):
113
- return False, "❌ You already have a format with that name."
114
- return True, f"✅ Renamed to <code>{esc(clip(text, 40))}</code>."
115
-
116
- if kind == "fmt_template":
117
- if _is_default(text) or _is_clear(text):
118
- await db.update_format(uid, fmt_id, template=None)
119
- return True, "✅ Template back to my default."
120
- if not text:
121
- return False, "❌ The template can't be empty. Send <code>default</code> to use my default."
122
- if len(text) > MAX_TEMPLATE_LEN:
123
- return False, f"❌ That template is too long ({MAX_TEMPLATE_LEN} characters max)."
124
- await db.update_format(uid, fmt_id, template=text)
125
- return True, "✅ Template saved.\n" + preview_template(text)
126
-
127
- if kind in META_KINDS:
128
- field = META_KINDS[kind]
129
- if _is_default(text):
130
- await db.update_format(uid, fmt_id, meta={field: None})
131
- return True, f"✅ {field.title()} back to your default."
132
- if not text:
133
- return False, "❌ Send a value, <code>off</code> to clear it, or <code>default</code> for mine."
134
- value = "" if _is_clear(text) else text
135
- if len(value) > MAX_META_LEN:
136
- return False, f"❌ That's too long ({MAX_META_LEN} characters max)."
137
- await db.update_format(uid, fmt_id, meta={field: value})
138
- if not value:
139
- return True, f"✅ {field.title()} cleared for this format."
140
- return True, f"✅ {field.title()} set to <code>{esc(clip(value, 80))}</code>."
141
-
142
- if kind in KEEP_KINDS:
143
- label = "Keep Audio" if kind == "fmt_keep_audio" else "Keep Subtitle"
144
- key = KEEP_KINDS[kind]
145
- if _is_default(text):
146
- await db.update_format(uid, fmt_id, **{key: None})
147
- return True, f"✅ {label} back to your default."
148
- if _is_clear(text):
149
- await db.update_format(uid, fmt_id, **{key: []})
150
- return True, f"✅ {label}: keep every track."
151
- tokens: List[str] = parse_track_tokens(text)
152
- problem = validate_track_tokens(tokens)
153
- if problem:
154
- return False, "❌ " + esc(problem)
155
- if len(tokens) > MAX_TRACKS:
156
- return False, f"❌ That's too many tracks ({MAX_TRACKS} max)."
157
- await db.update_format(uid, fmt_id, **{key: tokens})
158
- return True, f"✅ {label}: {esc(fmt_tokens(tokens))}"
159
-
160
- raise ValueError(f"unknown format field kind {kind!r}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bot/media.py CHANGED
@@ -391,93 +391,6 @@ 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
- async def probe_full(path: Path | str, cancel: Optional[asyncio.Event] = None) -> Dict[str, Any]:
395
- """The raw ffprobe JSON: format, streams, chapters and programs.
396
-
397
- Unlike :func:`probe` this hands back everything ffprobe found, not a
398
- parsed subset — it is used to publish a full media report.
399
- """
400
- code, out, err = await _run(
401
- [
402
- "ffprobe", "-v", "quiet", "-print_format", "json",
403
- "-show_format", "-show_streams", "-show_chapters", "-show_programs", str(path),
404
- ],
405
- cancel,
406
- )
407
- try:
408
- data = json.loads(out.decode("utf-8", "replace") or "{}")
409
- except ValueError:
410
- data = {}
411
- return data if isinstance(data, dict) else {}
412
-
413
-
414
- async def extract_frame(src: Path, dst: Path, duration: float, cancel: Optional[asyncio.Event] = None) -> bool:
415
- """Grab one frame at ~10% of the duration, at the source width.
416
-
417
- Unlike ``make_video_thumbnail`` this keeps the full width — it is for
418
- showing a frame, not for Telegram's 320px preview. Failure is non-fatal.
419
- """
420
- seek = 1.0 if duration <= 0 else min(max(duration * 0.1, 0.0), 60.0)
421
- cmd = [
422
- "ffmpeg", "-hide_banner", "-nostdin", "-y", "-loglevel", "error", "-ss", f"{seek:.2f}", "-i", str(src),
423
- "-frames:v", "1", "-q:v", "2", str(dst),
424
- ]
425
- try:
426
- code, _o, _e = await _run(cmd, cancel)
427
- except MediaError:
428
- return False
429
- return code == 0 and dst.exists() and dst.stat().st_size > 0
430
-
431
-
432
- def parse_size(text: str) -> Tuple[Optional[int], Optional[int], Optional[str]]:
433
- """``"800"`` -> ``(800, None, None)``; ``"800x450"`` -> ``(800, 450, None)``.
434
-
435
- A bad input gives ``(None, None, "an error message")``.
436
- """
437
- raw = (text or "").strip().lower().replace("×", "x")
438
- bad = "Send a size, like <code>800</code> or <code>800x450</code>."
439
- if not raw:
440
- return None, None, bad
441
- if "x" in raw:
442
- parts = raw.split("x")
443
- if len(parts) != 2 or not all(part.strip().isdigit() for part in parts):
444
- return None, None, bad
445
- width, height = int(parts[0]), int(parts[1])
446
- if width < 1 or height < 1:
447
- return None, None, "The size must be at least 1 pixel."
448
- return width, height, None
449
- if not raw.isdigit():
450
- return None, None, bad
451
- width = int(raw)
452
- if width < 1:
453
- return None, None, "The size must be at least 1 pixel."
454
- return width, None, None
455
-
456
-
457
- def resize_image(data: bytes, width: Optional[int] = None, height: Optional[int] = None) -> bytes:
458
- """Resize an image and return it as a JPEG.
459
-
460
- With one dimension the aspect ratio is kept; with both the exact size is
461
- forced.
462
- """
463
- from PIL import Image
464
-
465
- resample = Image.Resampling.LANCZOS if hasattr(Image, "Resampling") else Image.LANCZOS
466
- with Image.open(io.BytesIO(data)) as img:
467
- img = img.convert("RGB")
468
- if width and height:
469
- img = img.resize((width, height), resample)
470
- elif width:
471
- ratio = width / max(img.width, 1)
472
- img = img.resize((width, max(1, round(img.height * ratio))), resample)
473
- elif height:
474
- ratio = height / max(img.height, 1)
475
- img = img.resize((max(1, round(img.width * ratio)), height), resample)
476
- buf = io.BytesIO()
477
- img.save(buf, "JPEG", quality=90, optimize=True)
478
- return buf.getvalue()
479
-
480
-
481
  def prepare_thumb(data: bytes) -> bytes:
482
  """Resize/re-encode an image to Telegram's thumbnail limits (<=320px, <=200 KB JPEG)."""
483
  from PIL import Image
 
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
bot/panels.py CHANGED
@@ -6,12 +6,10 @@ limit because the home banner makes every panel a photo caption.
6
  """
7
  from __future__ import annotations
8
 
9
- from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
10
 
11
- from .db import MAX_FORMATS
12
  from .media import LANGUAGES, canonical_lang
13
- from .parser import Info
14
- from .utils import clip, esc, to_small_caps
15
 
16
  Row = List[Tuple[str, str]]
17
  Panel = Tuple[str, List[Row]]
@@ -185,134 +183,19 @@ def help_panel() -> Panel:
185
  text = (
186
  "<b>How to use</b>\n\n"
187
  "1. Send a file — I rename it with your template and send it back.\n"
188
- "2. <code>/rename</code> — pick one of your saved formats, then send the file.\n"
189
- "3. <code>/formats</code> — create, edit and delete your saved formats.\n"
190
- "4. <code>/template</code> and <code>/caption</code> — the default naming and caption templates.\n"
191
- "5. <code>/mode</code> — read the details from the file name or the caption.\n"
192
- "6. <code>/check</code> — reply to a file to see what I read from it.\n"
193
- "7. <code>/sequence</code> then <code>/sort</code> — collect files, sort them by episode, get them back in order.\n"
194
- "8. <code>/metadata</code>, <code>/keepaudio</code>, <code>/keepsubtitle</code> — tags and track clean-up.\n"
195
- "9. <code>/filetype</code> — send as document, video or audio.\n"
196
- "10. <code>/ethumb</code> and <code>/resize</code> — thumbnails and images.\n"
197
- "11. <code>/speed</code> and <code>/mediainfo</code> — my line speed and a file's media info.\n"
198
- "12. <code>/queue</code> and <code>/cancel</code> — manage running jobs.\n\n"
199
  "Template variables: <code>{title} {season} {episode} {quality} {year} {codec} {filename} {ext}</code>\n"
200
  "A [bracket] group is dropped when a variable inside it is empty."
201
  )
202
  return text, nav("home")
203
 
204
 
205
- def check_report(file_info: Info, caption_info: Info) -> str:
206
- """The ``/check`` body: what the bot extracted from the name and from the caption."""
207
-
208
- def field(label: str, value: str) -> str:
209
- return f"\u27a6 {label} \u2b46 {esc(value) if value else 'None'}"
210
-
211
- def section(heading: str, info: Info) -> str:
212
- lines = [
213
- f"{to_small_caps(heading)}:",
214
- field("Title", info.title),
215
- field("Season", info.season),
216
- field("Episode", info.episode),
217
- field("Quality", info.quality),
218
- ]
219
- if info.year:
220
- lines.append(field("Year", info.year))
221
- if info.codec:
222
- lines.append(field("Codec", info.codec))
223
- return "\n".join(lines)
224
-
225
- header = f"{to_small_caps('Your Can Change Mode By')} /mode"
226
- return "\n\n".join([header, section("FileName", file_info), section("Caption Mode", caption_info)])
227
-
228
-
229
- def _keep_label(value: Any) -> str:
230
- """``None`` (inherit) / ``[]`` (every track) / a list of tokens."""
231
- if value is None:
232
- return "my default"
233
- if not value:
234
- return "every track"
235
- return esc(fmt_tokens(value))
236
-
237
-
238
- def _thumb_label(value: Any) -> str:
239
- if value is None:
240
- return "my default"
241
- return "none" if not value else "✅ set"
242
-
243
-
244
- def formats_list(formats: Sequence[Dict[str, Any]], *, for_rename: bool) -> Panel:
245
- """The saved-format picker (``/rename``) and manager (``/formats``)."""
246
- if for_rename:
247
- title = "<b>PICK A FORMAT</b>"
248
- lead = "Choose the format to use, then send me the file."
249
- action = "fm"
250
- else:
251
- title = "<b>MY FORMATS</b>"
252
- lead = "Your saved rename formats. Tap one to edit it."
253
- action = "fl"
254
-
255
- if not formats:
256
- text = (
257
- f"{title}\n\n{lead}\n\n"
258
- "You have no saved formats yet — create one first.\n"
259
- "A format keeps its own template, thumbnail, metadata and track lists; "
260
- "everything else uses your normal settings."
261
- )
262
- return text, [[("➕ New format", "fnew")]] + nav("home")
263
-
264
- lines = [title, "", lead, ""]
265
- rows: List[Row] = []
266
- for f in formats:
267
- own = " ★" if f.get("template") else ""
268
- lines.append(f"{DOT} <b>{esc(f['name'])}</b>{own}")
269
- rows.append([(clip(f["name"], 30), f"{action}:{f['id']}")])
270
- lines.append("")
271
- lines.append("★ = this format has its own template.")
272
- if len(formats) < MAX_FORMATS:
273
- rows.append([("➕ New format", "fnew")])
274
- return "\n".join(lines), rows + nav("home")
275
-
276
-
277
- def format_editor(fmt: Dict[str, Any]) -> Panel:
278
- """The editor for one saved format."""
279
- fid = fmt["id"]
280
- lines = [f"<b>FORMAT · {esc(fmt['name'])}</b>", ""]
281
- template = fmt.get("template")
282
- lines.append(f"{DOT} <b>Template</b> ▸ {_code(template, 120) if template else 'my default'}")
283
- for label, key in (("Title", "title"), ("Author", "author"), ("Artist", "artist"),
284
- ("Audio", "audio"), ("Subtitle", "subtitle"), ("Video", "video")):
285
- value = (fmt.get("meta") or {}).get(key)
286
- lines.append(f"{DOT} <b>{label}</b> ▸ {_code(value, 60) if value is not None else 'my default'}")
287
- lines.append(f"{DOT} <b>Keep Audio</b> ▸ {_keep_label(fmt.get('keep_audio'))}")
288
- lines.append(f"{DOT} <b>Keep Subtitle</b> ▸ {_keep_label(fmt.get('keep_subtitle'))}")
289
- lines.append(f"{DOT} <b>Thumbnail</b> ▸ {_thumb_label(fmt.get('thumb'))}")
290
- rows: List[Row] = [
291
- [("📝 Template", f"fset:fmt_template:{fid}"), ("🖼 Thumbnail", f"fthumb:{fid}")],
292
- [("🎬 Title", f"fset:fmt_title:{fid}"), ("👤 Author", f"fset:fmt_author:{fid}")],
293
- [("🎵 Artist", f"fset:fmt_artist:{fid}"), ("🔊 Audio", f"fset:fmt_audio:{fid}")],
294
- [("💬 Subtitle", f"fset:fmt_subtitle:{fid}"), ("🎥 Video", f"fset:fmt_video:{fid}")],
295
- [("🔊 Keep Audio", f"fset:fmt_keep_audio:{fid}"), ("💬 Keep Subtitle", f"fset:fmt_keep_subtitle:{fid}")],
296
- [("🖼 Use default", f"fthumbdef:{fid}"), ("🚫 No thumbnail", f"fthumbnone:{fid}")],
297
- [("✏️ Name", f"frename:{fid}"), ("🗑 Delete", f"fdel:{fid}")],
298
- ]
299
- return "\n".join(lines), rows + nav("formats")
300
-
301
-
302
- def smedia_panel(kind: Optional[str], has_media: bool) -> Panel:
303
- """The owner's panel for the banner shown on ``/start``."""
304
- what = {"gif": "a GIF", "image": "an image"}.get(kind or "", "nothing")
305
- text = (
306
- "<b>🖼 START SCREEN</b>\n\n"
307
- f"The banner shown on <code>/start</code> is currently <b>{what}</b>.\n\n"
308
- "Pick what you want, then send me the file."
309
- )
310
- rows: List[Row] = [[("🖼 Image", "smedia:image"), ("🎞 GIF", "smedia:gif")]]
311
- if has_media:
312
- rows.append([("👁 View", "smedia:view"), ("🗑 Delete", "smedia:del")])
313
- return text, rows + nav("home")
314
-
315
-
316
  def admin(owner_id: int, admins: Sequence[int], running: int, waiting: int, log_channel: Any) -> Panel:
317
  shown = ", ".join(f"<code>{a}</code>" for a in list(admins)[:10]) or "none"
318
  more = f" (+{len(admins) - 10} more)" if len(admins) > 10 else ""
@@ -378,43 +261,5 @@ def input_prompt(kind: str, back: str) -> Panel:
378
  return PROMPTS[kind], [[("✖ Cancel", f"in:cancel:{back}")]]
379
 
380
 
381
- FORMAT_PROMPTS: Dict[str, str] = {
382
- "fmt_name": (
383
- "<b>Send the name for this format.</b>\n\n"
384
- "Something you'll recognise later, like <code>One Piece format</code>."
385
- ),
386
- "fmt_template": (
387
- "<b>Send the filename template for this format.</b>\n\n"
388
- "Variables: <code>{title} {season} {episode} {quality} {year} {codec} {filename} {ext}</code>\n"
389
- "A [bracket] group disappears when a variable inside it is empty.\n\n"
390
- "Send <code>default</code> to go back to your normal template."
391
- ),
392
- "fmt_title": "<b>Send the Title</b> to write into the files' metadata.\nSend <code>off</code> to clear it, or <code>default</code> to use your normal one.",
393
- "fmt_author": "<b>Send the Author</b> to write into the files' metadata.\nSend <code>off</code> to clear it, or <code>default</code> to use your normal one.",
394
- "fmt_artist": "<b>Send the Artist</b> to write into the files' metadata.\nSend <code>off</code> to clear it, or <code>default</code> to use your normal one.",
395
- "fmt_audio": (
396
- "<b>Send the title for audio tracks</b>.\n<code>{lang}</code> becomes each track's language, "
397
- "e.g. <code>{lang} Audio</code>.\nSend <code>off</code> to clear it, or <code>default</code> to use your normal one."
398
- ),
399
- "fmt_subtitle": (
400
- "<b>Send the title for subtitle tracks</b>.\n<code>{lang}</code> becomes each track's language.\n"
401
- "Send <code>off</code> to clear it, or <code>default</code> to use your normal one."
402
- ),
403
- "fmt_video": "<b>Send the title for the video track</b>.\nSend <code>off</code> to clear it, or <code>default</code> to use your normal one.",
404
- "fmt_keep_audio": (
405
- "<b>Which audio tracks should this format keep?</b>\n\n"
406
- "Send language names (<code>English Japanese</code>) or track numbers (<code>2 1</code>). "
407
- "The order you give is the output order; all other tracks are dropped.\n"
408
- "Send <code>off</code> to keep every track, or <code>default</code> to use your normal list."
409
- ),
410
- "fmt_keep_subtitle": (
411
- "<b>Which subtitle tracks should this format keep?</b>\n\n"
412
- "Send language names (<code>English</code>) or track numbers (<code>2</code>). "
413
- "The order you give is the output order; all other tracks are dropped.\n"
414
- "Send <code>off</code> to keep every track, or <code>default</code> to use your normal list."
415
- ),
416
- }
417
-
418
-
419
  def known_languages() -> str:
420
  return ", ".join(sorted(LANGUAGES))
 
6
  """
7
  from __future__ import annotations
8
 
9
+ from typing import Any, Dict, Iterable, List, Sequence, Tuple
10
 
 
11
  from .media import LANGUAGES, canonical_lang
12
+ from .utils import clip, esc
 
13
 
14
  Row = List[Tuple[str, str]]
15
  Panel = Tuple[str, List[Row]]
 
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
  )
196
  return text, nav("home")
197
 
198
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
199
  def admin(owner_id: int, admins: Sequence[int], running: int, waiting: int, log_channel: Any) -> Panel:
200
  shown = ", ".join(f"<code>{a}</code>" for a in list(admins)[:10]) or "none"
201
  more = f" (+{len(admins) - 10} more)" if len(admins) > 10 else ""
 
261
  return PROMPTS[kind], [[("✖ Cancel", f"in:cancel:{back}")]]
262
 
263
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
264
  def known_languages() -> str:
265
  return ", ".join(sorted(LANGUAGES))
bot/parser.py CHANGED
@@ -327,24 +327,6 @@ def parse_caption(caption: str) -> Info:
327
  return first
328
 
329
 
330
- def effective_info(stem: str, caption: str, mode: str) -> Info:
331
- """The details a rename would use for this file.
332
-
333
- Caption mode only takes the caption when it yields a structured field; an
334
- unstructured caption is discarded and the filename is used unchanged.
335
- """
336
- info = parse_info(stem)
337
- if mode != "caption" or not (caption or "").strip():
338
- return info
339
- cinfo = parse_caption(caption)
340
- if not cinfo.structured:
341
- return info
342
- for field in ("title", "season", "episode", "quality", "year", "codec"):
343
- if not getattr(cinfo, field) and getattr(info, field):
344
- setattr(cinfo, field, getattr(info, field))
345
- return cinfo
346
-
347
-
348
  # ---------------------------------------------------------------------------
349
  # templates
350
  # ---------------------------------------------------------------------------
@@ -446,7 +428,14 @@ def compute_new_name(
446
  stem, ext = split_ext(original_name)
447
  if not ext:
448
  ext = original_ext or ""
449
- info = effective_info(stem, caption, mode)
 
 
 
 
 
 
 
450
  values = build_values(info, stem, ext)
451
  rendered = render_template(template or DEFAULT_TEMPLATE, values)
452
  rendered = apply_remove_words(rendered, remove_words)
 
327
  return first
328
 
329
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
330
  # ---------------------------------------------------------------------------
331
  # templates
332
  # ---------------------------------------------------------------------------
 
428
  stem, ext = split_ext(original_name)
429
  if not ext:
430
  ext = original_ext or ""
431
+ info = parse_info(stem)
432
+ if mode == "caption" and caption.strip():
433
+ cinfo = parse_caption(caption)
434
+ if cinfo.structured:
435
+ for field in ("title", "season", "episode", "quality", "year", "codec"):
436
+ if not getattr(cinfo, field) and getattr(info, field):
437
+ setattr(cinfo, field, getattr(info, field))
438
+ info = cinfo
439
  values = build_values(info, stem, ext)
440
  rendered = render_template(template or DEFAULT_TEMPLATE, values)
441
  rendered = apply_remove_words(rendered, remove_words)
bot/pipeline.py CHANGED
@@ -94,7 +94,7 @@ class Renamer:
94
 
95
  # ------------------------------------------------------------------
96
  async def process(self, job: Job) -> None:
97
- s = job.settings_override or 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,16 +178,11 @@ class Renamer:
178
  )
179
  )
180
 
181
- if job.thumbnail_mode == "none":
182
- thumb = None
183
- elif job.thumbnail_mode == "custom":
184
- thumb = job.thumbnail
185
- else:
186
- thumb = job.thumbnail or 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):
190
- thumb = thumb_path.read_bytes()
191
 
192
  # ---- caption ---------------------------------------------------------------------
193
  parsed = parse_info(split_ext(orig_name)[0])
 
94
 
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
  )
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):
185
+ thumb = thumb_path.read_bytes()
 
 
 
 
 
186
 
187
  # ---- caption ---------------------------------------------------------------------
188
  parsed = parse_info(split_ext(orig_name)[0])
bot/queue.py CHANGED
@@ -20,6 +20,7 @@ class Job:
20
  file_name: str
21
  size: int
22
  new_name: str
 
23
  notes: List[str] = field(default_factory=list) # warnings shown with the queued / finished message
24
  order: int = 0 # id of the message that triggered the job: files are processed oldest message first
25
  status_msg: Any = None
@@ -27,12 +28,6 @@ class Job:
27
  cancel: asyncio.Event = field(default_factory=asyncio.Event)
28
  created: float = field(default_factory=time.time)
29
  started: float = 0.0
30
- # the settings the name was computed from, so a queued job cannot drift
31
- settings_override: Optional[Dict[str, Any]] = None
32
- # auto = the user's global thumbnail (a video frame may stand in),
33
- # custom = this job's own thumbnail, none = send no thumbnail at all
34
- thumbnail_mode: str = "auto"
35
- thumbnail: Optional[bytes] = None
36
 
37
 
38
  Runner = Callable[[Job], Awaitable[None]]
 
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
 
28
  cancel: asyncio.Event = field(default_factory=asyncio.Event)
29
  created: float = field(default_factory=time.time)
30
  started: float = 0.0
 
 
 
 
 
 
31
 
32
 
33
  Runner = Callable[[Job], Awaitable[None]]
bot/sequence.py DELETED
@@ -1,100 +0,0 @@
1
- """Collecting files and sorting them by episode.
2
-
3
- The pure part lives here: which episode each file is, the order they go in,
4
- and what is missing or repeated. The Telegram part lives in ``bot/app.py``.
5
- """
6
- from __future__ import annotations
7
-
8
- from dataclasses import dataclass, field
9
- from typing import Any, Dict, List, Tuple
10
-
11
- from .parser import effective_info, split_ext
12
-
13
-
14
- @dataclass
15
- class Duplicate:
16
- season: str
17
- episode: str
18
- names: List[str] = field(default_factory=list)
19
-
20
- @property
21
- def label(self) -> str:
22
- return f"S{self.season}E{self.episode}"
23
-
24
-
25
- @dataclass
26
- class SortResult:
27
- ordered: List[Dict[str, Any]] = field(default_factory=list)
28
- missing: List[str] = field(default_factory=list)
29
- duplicates: List[Duplicate] = field(default_factory=list)
30
- no_episode: List[str] = field(default_factory=list)
31
-
32
-
33
- def detect_episodes(items: List[Dict[str, Any]], mode: str) -> List[Dict[str, Any]]:
34
- """Fill in each item's ``season`` and ``episode`` using the mode-aware merge.
35
-
36
- An item is ``{"msg_id": int, "name": str, "caption": str}``; the result adds
37
- ``season`` and ``episode`` (empty strings when there are none).
38
- """
39
- out: List[Dict[str, Any]] = []
40
- for item in items:
41
- stem, _ext = split_ext(item.get("name") or "")
42
- info = effective_info(stem, item.get("caption") or "", mode)
43
- entry = dict(item)
44
- entry["season"] = info.season
45
- entry["episode"] = info.episode
46
- out.append(entry)
47
- return out
48
-
49
-
50
- def plan_sort(items: List[Dict[str, Any]]) -> SortResult:
51
- """Sort the items by season then episode, and find the gaps and the repeats.
52
-
53
- Files with no episode number are kept — they sort last, in the order they
54
- arrived, and are reported separately.
55
- """
56
- numbered = [it for it in items if it.get("episode")]
57
- unnumbered = [it for it in items if not it.get("episode")]
58
- ordered = sorted(numbered, key=lambda it: (it.get("season") or "", it["episode"])) + unnumbered
59
-
60
- by_season: Dict[str, set] = {}
61
- for item in numbered:
62
- by_season.setdefault(item.get("season") or "", set()).add(int(item["episode"]))
63
-
64
- missing: List[str] = []
65
- for season in sorted(by_season):
66
- episodes = by_season[season]
67
- for number in range(min(episodes), max(episodes) + 1):
68
- if number not in episodes:
69
- missing.append(f"S{season}E{number:02d}")
70
-
71
- names: Dict[Tuple[str, str], List[str]] = {}
72
- for item in numbered:
73
- key = (item.get("season") or "", item["episode"])
74
- names.setdefault(key, []).append(item.get("name") or "")
75
- duplicates = [
76
- Duplicate(season=season, episode=episode, names=files)
77
- for (season, episode), files in names.items()
78
- if len(files) > 1
79
- ]
80
-
81
- return SortResult(
82
- ordered=ordered,
83
- missing=missing,
84
- duplicates=duplicates,
85
- no_episode=[item.get("name") or "" for item in unnumbered],
86
- )
87
-
88
-
89
- def format_report(result: SortResult, total: int) -> str:
90
- """The message shown once the files have been sent back."""
91
- lines = [f"✅ Sorted {total} file{'s' if total != 1 else ''}."]
92
- if result.missing:
93
- lines += ["", "⚠️ Missing: " + ", ".join(result.missing)]
94
- if result.duplicates:
95
- lines += [""]
96
- for duplicate in result.duplicates:
97
- lines.append(f"⚠️ Duplicate {duplicate.label}: " + ", ".join(duplicate.names))
98
- if result.no_episode:
99
- lines += ["", "⚠️ No episode number: " + ", ".join(result.no_episode)]
100
- return "\n".join(lines)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bot/speedtest.py DELETED
@@ -1,115 +0,0 @@
1
- """Internet speed test, using the official Ookla CLI.
2
-
3
- The binary is installed in the Docker image (``ookla-speedtest``); see the
4
- Dockerfile. Everything here is pure except :func:`run_speedtest`, so the
5
- parsing and the message can be tested without a network.
6
- """
7
- from __future__ import annotations
8
-
9
- import asyncio
10
- import json
11
- from dataclasses import dataclass
12
-
13
- from .utils import esc
14
-
15
- OOKLA_BIN = "ookla-speedtest"
16
- # --accept-license / --accept-gdpr stop the tool waiting for a prompt; the
17
- # licence notice goes to stderr, so stdout stays pure JSON.
18
- OOKLA_ARGS = ("--accept-license", "--accept-gdpr", "--format=json")
19
-
20
-
21
- @dataclass
22
- class SpeedResult:
23
- download_mbps: float = 0.0
24
- upload_mbps: float = 0.0
25
- ping_ms: float = 0.0
26
- server: str = ""
27
- isp: str = ""
28
-
29
-
30
- def parse_ookla_json(raw: str) -> SpeedResult:
31
- """Read the JSON that ``ookla-speedtest --format=json`` prints.
32
-
33
- The CLI puts its licence notice on stderr, but be forgiving about anything
34
- that ends up in front of the JSON.
35
- """
36
- text = (raw or "").strip()
37
- start = text.find("{")
38
- if start > 0:
39
- text = text[start:]
40
- try:
41
- data = json.loads(text)
42
- except (TypeError, ValueError) as exc:
43
- raise ValueError("the speedtest output was not valid JSON") from exc
44
- if not isinstance(data, dict):
45
- raise ValueError("the speedtest output was not a JSON object")
46
- try:
47
- download = float(data["download"]["bandwidth"])
48
- upload = float(data["upload"]["bandwidth"])
49
- except (KeyError, TypeError, ValueError) as exc:
50
- raise ValueError("the speedtest output had no download/upload speeds") from exc
51
-
52
- ping = data.get("ping") or {}
53
- try:
54
- # the CLI reports the round-trip time as "latency"; "iqm" is the
55
- # inter-quartile mean it uses for the per-phase numbers
56
- ping_ms = float(ping.get("latency") or ping.get("iqm") or 0)
57
- except (TypeError, ValueError):
58
- ping_ms = 0.0
59
-
60
- server = data.get("server") or {}
61
- name = str(server.get("name") or "").strip()
62
- location = str(server.get("location") or "").strip()
63
- country = str(server.get("country") or "").strip()
64
- where = ", ".join(part for part in (location, country) if part)
65
- label = " — ".join(part for part in (name, where) if part)
66
- return SpeedResult(
67
- download_mbps=download / 1_000_000,
68
- upload_mbps=upload / 1_000_000,
69
- ping_ms=ping_ms,
70
- server=label,
71
- isp=str(data.get("isp") or "").strip(),
72
- )
73
-
74
-
75
- def format_speed(result: SpeedResult) -> str:
76
- """The message shown in the chat."""
77
- lines = [
78
- "<b>📡 Internet speed</b>",
79
- "",
80
- f"⬇️ Download: <b>{result.download_mbps:.2f} Mbps</b>",
81
- f"⬆️ Upload: <b>{result.upload_mbps:.2f} Mbps</b>",
82
- f"⏱ Ping: <b>{result.ping_ms:.0f} ms</b>",
83
- ]
84
- if result.server:
85
- lines.append(f"📍 Server: {esc(result.server)}")
86
- if result.isp:
87
- lines.append(f"🏢 ISP: {esc(result.isp)}")
88
- return "\n".join(lines)
89
-
90
-
91
- async def run_speedtest(binary: str = OOKLA_BIN, timeout: float = 180.0) -> SpeedResult:
92
- """Run the Ookla CLI and return the result.
93
-
94
- Raises ``FileNotFoundError`` when the binary is missing and
95
- ``asyncio.TimeoutError`` when it takes too long.
96
- """
97
- try:
98
- proc = await asyncio.create_subprocess_exec(
99
- binary, *OOKLA_ARGS,
100
- stdin=asyncio.subprocess.DEVNULL,
101
- stdout=asyncio.subprocess.PIPE,
102
- stderr=asyncio.subprocess.PIPE,
103
- )
104
- except FileNotFoundError as exc:
105
- raise FileNotFoundError(f"{binary} is not installed") from exc
106
- try:
107
- out, err = await asyncio.wait_for(proc.communicate(), timeout)
108
- except asyncio.TimeoutError:
109
- proc.kill()
110
- await proc.wait()
111
- raise
112
- if proc.returncode:
113
- detail = (err or b"").decode("utf-8", "replace").strip()[-300:]
114
- raise RuntimeError(detail or "the speedtest failed")
115
- return parse_ookla_json((out or b"").decode("utf-8", "replace"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bot/telegraph.py DELETED
@@ -1,146 +0,0 @@
1
- """Publishing a media-info report to telegra.ph.
2
-
3
- No extra dependency: the public Telegraph API is a couple of HTTP calls, so
4
- ``aiohttp`` (already required) is enough.
5
- """
6
- from __future__ import annotations
7
-
8
- import json
9
- from typing import Any, Dict, List, Optional
10
-
11
- import aiohttp
12
-
13
- from .utils import esc
14
-
15
- TELEGRAPH_API = "https://api.telegra.ph"
16
- AUTHOR_NAME = "Auto Rename Bot"
17
- SHORT_NAME = "autorename"
18
- MAX_RAW = 100_000
19
-
20
-
21
- def _tags(raw: Any) -> Dict[str, Any]:
22
- tags = raw.get("tags") if isinstance(raw, dict) else None
23
- return tags if isinstance(tags, dict) else {}
24
-
25
-
26
- def summary_lines(probe: Dict[str, Any], name: str) -> List[str]:
27
- """The plain-text summary: one line per fact."""
28
- fmt = probe.get("format") or {}
29
- lines = [f"File: {name}"]
30
- if fmt.get("format_name"):
31
- lines.append(f"Container: {fmt['format_name']}")
32
- if fmt.get("duration"):
33
- lines.append(f"Duration: {fmt['duration']} s")
34
- if fmt.get("size"):
35
- lines.append(f"Size: {fmt['size']} bytes")
36
- if fmt.get("bit_rate"):
37
- lines.append(f"Bit rate: {fmt['bit_rate']}")
38
- for key, value in _tags(fmt).items():
39
- lines.append(f"{str(key).title()}: {value}")
40
- return lines
41
-
42
-
43
- def stream_lines(probe: Dict[str, Any]) -> List[str]:
44
- lines: List[str] = []
45
- for stream in probe.get("streams") or []:
46
- bits = [f"#{stream.get('index')} {stream.get('codec_type') or ''}".strip()]
47
- if stream.get("codec_name"):
48
- bits.append(str(stream["codec_name"]))
49
- if stream.get("width") and stream.get("height"):
50
- bits.append(f"{stream['width']}x{stream['height']}")
51
- if stream.get("channels"):
52
- bits.append(f"{stream['channels']}ch")
53
- if stream.get("sample_rate"):
54
- bits.append(f"{stream['sample_rate']} Hz")
55
- language = _tags(stream).get("language")
56
- if language:
57
- bits.append(str(language))
58
- title = _tags(stream).get("title")
59
- if title:
60
- bits.append(f"“{title}”")
61
- disposition = stream.get("disposition") or {}
62
- flags = [name for name in ("default", "forced", "attached_pic") if disposition.get(name)]
63
- if flags:
64
- bits.append(", ".join(flags))
65
- lines.append(" · ".join(bit for bit in bits if bit))
66
- return lines
67
-
68
-
69
- def chapter_lines(probe: Dict[str, Any]) -> List[str]:
70
- lines: List[str] = []
71
- for chapter in probe.get("chapters") or []:
72
- title = _tags(chapter).get("title") or "chapter"
73
- lines.append(f"{title} — {chapter.get('start_time')} → {chapter.get('end_time')}")
74
- return lines
75
-
76
-
77
- def build_summary(probe: Dict[str, Any], name: str) -> str:
78
- """A plain-text summary — used as the fallback when telegra.ph is unreachable."""
79
- lines = summary_lines(probe, name)
80
- streams = stream_lines(probe)
81
- if streams:
82
- lines += ["", "Streams:"] + streams
83
- chapters = chapter_lines(probe)
84
- if chapters:
85
- lines += ["", "Chapters:"] + chapters
86
- return "\n".join(lines)
87
-
88
-
89
- def build_page(probe: Dict[str, Any], name: str) -> str:
90
- """The HTML body of the telegra.ph page."""
91
- parts = ["<p>" + "<br>".join(esc(line) for line in summary_lines(probe, name)) + "</p>"]
92
- streams = stream_lines(probe)
93
- if streams:
94
- parts.append("<p><b>Streams</b><br>" + "<br>".join(esc(line) for line in streams) + "</p>")
95
- chapters = chapter_lines(probe)
96
- if chapters:
97
- parts.append("<p><b>Chapters</b><br>" + "<br>".join(esc(line) for line in chapters) + "</p>")
98
- raw = json.dumps(probe, indent=2, ensure_ascii=False)
99
- if len(raw) > MAX_RAW:
100
- raw = raw[:MAX_RAW] + "\n… (truncated)"
101
- parts.append("<p><b>Raw ffprobe output</b></p>")
102
- parts.append(f"<pre><code>{esc(raw)}</code></pre>")
103
- return "".join(parts)
104
-
105
-
106
- class Telegraph:
107
- """A tiny client for the public telegra.ph API."""
108
-
109
- def __init__(self, token: Optional[str] = None):
110
- self.token = token
111
-
112
- async def _post(self, session: aiohttp.ClientSession, method: str, payload: Dict[str, Any]) -> Dict[str, Any]:
113
- async with session.post(f"{TELEGRAPH_API}/{method}", json=payload) as response:
114
- data = await response.json(content_type=None)
115
- if not isinstance(data, dict) or not data.get("ok"):
116
- error = data.get("error") if isinstance(data, dict) else None
117
- raise RuntimeError(error or f"telegra.ph {method} failed")
118
- result = data.get("result")
119
- return result if isinstance(result, dict) else {}
120
-
121
- async def create_account(self) -> str:
122
- """Make a telegra.ph account and return its access token."""
123
- async with aiohttp.ClientSession() as session:
124
- result = await self._post(
125
- session,
126
- "createAccount",
127
- {"short_name": SHORT_NAME, "author_name": AUTHOR_NAME},
128
- )
129
- return str(result.get("access_token") or "")
130
-
131
- async def upload_page(self, title: str, content: List[str]) -> str:
132
- """Upload a page and return its URL."""
133
- if not self.token:
134
- raise RuntimeError("no telegra.ph access token")
135
- async with aiohttp.ClientSession() as session:
136
- result = await self._post(
137
- session,
138
- "uploadPage",
139
- {
140
- "access_token": self.token,
141
- "title": title,
142
- "content": content,
143
- "author_name": AUTHOR_NAME,
144
- },
145
- )
146
- return str(result.get("url") or "")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bot/utils.py CHANGED
@@ -47,22 +47,3 @@ def progress_bar(fraction: float, width: int = 14) -> str:
47
 
48
  def clip(text: str, limit: int) -> str:
49
  return text if len(text) <= limit else text[: max(0, limit - 1)] + "…"
50
-
51
-
52
- SMALL_CAPS = {
53
- "a": "\u1d43", "b": "\u0299", "c": "\u1d04", "d": "\u1d05", "e": "\u1d07",
54
- "f": "\ua730", "g": "\u0262", "h": "\u029c", "i": "\u026a", "j": "\u1d0a",
55
- "k": "\u1d0b", "l": "\u029f", "m": "\u1d0d", "n": "\u0274", "o": "\u1d0f",
56
- "p": "\u1d18", "q": "\u01eb", "r": "\u0280", "s": "\u0073", "t": "\u1d1b",
57
- "u": "\u1d1c", "v": "\u1d20", "w": "\u1d21", "x": "\u0078", "y": "\u02bf",
58
- "z": "\u1d22",
59
- }
60
- SMALL_CAPS.update({ch.upper(): glyph for ch, glyph in list(SMALL_CAPS.items())})
61
-
62
-
63
- def to_small_caps(text: str) -> str:
64
- """Map ``text`` to Unicode small capitals (``"Your" -> "\u02bf\u1d0f\u1d1c\u0280"``).
65
-
66
- Both ASCII cases map to the same glyph; unmapped characters pass through.
67
- """
68
- return "".join(SMALL_CAPS.get(ch, ch) for ch in (text or ""))
 
47
 
48
  def clip(text: str, limit: int) -> str:
49
  return text if len(text) <= limit else text[: max(0, limit - 1)] + "…"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/superpowers/plans/2026-10-20-media-tools-and-formats.md DELETED
@@ -1,1523 +0,0 @@
1
- # Media Tools & Named Formats Implementation Plan
2
-
3
- > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
-
5
- **Goal:** Add saved named rename formats, a collect-and-sort episode flow, a `/check` inspection command, thumbnail extraction and image resizing, an Ookla speed test, a telegra.ph media-info report, and an owner-only `/smedia` command that replaces the `START_IMAGE` environment variable.
6
-
7
- **Architecture:** Eight independent subsystems bolted onto the existing Telethon bot. Pure logic (parsing, sorting, small caps, report building, CLI output parsing) lives in new focused modules and is unit-tested without network or Telegram. Telegram-facing behaviour is added to `bot/app.py` as new handlers and to `bot/panels.py` as pure `(text, buttons)` builders, matching the existing pattern. Persistence goes in three new SQLAlchemy tables created by the existing `Base.metadata.create_all` in `Database.init()`.
8
-
9
- **Tech Stack:** Python 3.12, Telethon 1.45, SQLAlchemy 2.x (async), ffmpeg/ffprobe, Pillow, aiohttp, pytest + pytest-asyncio.
10
-
11
- **Spec:** `docs/superpowers/specs/2026-10-20-media-tools-and-formats-design.md` — the plan argues from the spec, so the spec travels with it; executors read both.
12
-
13
- ## Global Constraints
14
-
15
- - Python 3.12; the project runs on `python:3.12-slim`. No new pip dependency is needed: `aiohttp` (Telegraph), `Pillow` (resize) and `ffmpeg`/`ffprobe` are already present.
16
- - The Ookla CLI is installed in the Dockerfile, not pip-installed. Version pinned to `ookla-speedtest-1.2.0-linux-x86_64.tgz`.
17
- - `DATABASE_URL` may be Postgres or SQLite. **Every new primary key column must be `Integer`, not `BigInteger`** — SQLite only auto-increments a column declared exactly `INTEGER PRIMARY KEY`.
18
- - The existing test suite (266 tests) must keep passing, except the three tests that cover behaviour this work deliberately removes (see Task 4 and Task 10).
19
- - The bot only reacts in private chats. Unknown users are ignored and reported to the log channel.
20
- - Every new user-facing string is plain text with Telegram-HTML escaping applied to user-supplied values.
21
- - Inline-button callback payloads are limited to 64 bytes.
22
- - `MAX_FORMATS` is 12, per user.
23
- - A format field is tri-state: `None` = inherit the user's global setting, `""`/`[]` = explicitly empty, a real value = override.
24
- - The user calls the formats "One Piece format", "Dragon Ball format" — never "Format 1".
25
-
26
- ## Review Focus
27
-
28
- Five failure modes the spec implies that no task's tests would otherwise exercise. Each line gets a test in the task that owns the code.
29
-
30
- 1. **A format's template must reach the file name.** The name is computed in `App.enqueue`, not in `Renamer.process`; resolving the format only inside the pipeline silently renames every file with the global template. → Task 4.
31
- 2. **A format that explicitly sets "no thumbnail" must send no thumbnail at all** — not the global thumbnail, not an automatic video frame, and never an empty byte string handed to `send_file(thumb=...)`. → Task 4.
32
- 3. **A file with no episode number must survive a sort.** It is not dropped; it sorts last in arrival order and is listed separately in the report. → Task 5.
33
- 4. **An open sequence must suppress auto-rename.** Files sent while a sequence is open are collected, never renamed and re-uploaded. → Task 5.
34
- 5. **A format deleted between enqueue and the run must not break the queued job.** The job carries resolved settings, not a format id. → Task 4.
35
-
36
- ---
37
-
38
- ### Task 1: Mode-aware info, small caps, and the `/check` report
39
-
40
- **Files:**
41
- - Modify: `bot/parser.py` (add `effective_info`, refactor `compute_new_name` to use it)
42
- - Modify: `bot/utils.py` (add `to_small_caps`)
43
- - Modify: `bot/panels.py` (add `check_report`)
44
- - Create: `tests/test_check.py`
45
-
46
- **Interfaces:**
47
- - Consumes: nothing new. `parser.parse_info`, `parser.parse_caption`, `parser.Info` already exist.
48
- - Produces:
49
- - `parser.effective_info(stem: str, caption: str, mode: str) -> Info` — the details a rename would use. For `mode != "caption"` it returns `parse_info(stem)`. For `mode == "caption"` it parses the caption; if that parse has no structured field (season/episode/quality/year) it returns `parse_info(stem)` unchanged, otherwise the caption's values with the filename filling in only the fields the caption left empty.
50
- - `utils.to_small_caps(text: str) -> str` — maps both cases to Unicode small capitals, leaves unmapped characters alone.
51
- - `panels.check_report(file_info: Info, caption_info: Info) -> str` — the `/check` message body (HTML-escaped values, no buttons).
52
-
53
- - [ ] **Step 1: Write the failing tests**
54
-
55
- In `tests/test_check.py`:
56
-
57
- ```python
58
- from bot.parser import Info, effective_info
59
- from bot.panels import check_report
60
- from bot.utils import to_small_caps
61
-
62
-
63
- def test_to_small_caps_maps_every_letter_and_leaves_the_rest():
64
- assert to_small_caps("Your") == "\u02bf\u1d0f\u1d1c\u0280" # ʏ ᴏ ᴜ ʀ
65
- assert to_small_caps("FileName") == to_small_caps("filename")
66
- assert to_small_caps("123 /mode!") == "123 /mode!"
67
-
68
-
69
- def test_effective_info_uses_the_caption_only_when_it_is_structured():
70
- stem = "Dragon Ball Super Beerus S01E02 1080p"
71
- assert effective_info(stem, "", "filename").title == "Dragon Ball Super Beerus"
72
- # an unstructured caption is discarded entirely
73
- assert effective_info(stem, "My Show", "caption").title == "Dragon Ball Super Beerus"
74
- # a structured caption wins, and the filename fills in what it lacks
75
- info = effective_info(stem, "Beerus 1080p S01E02", "caption")
76
- assert info.season == "01" and info.episode == "02" and info.quality == "1080p"
77
-
78
-
79
- def test_check_report_has_the_agreed_layout():
80
- info = Info(title="Dragon Ball Super Beerus", season="01", episode="02", quality="1080p")
81
- text = check_report(info, info)
82
- assert "\u02bf\u1d0f\u1d1c\u0280" in text # ʏ ᴏ ᴜ ʀ — the small-capped header
83
- assert "\ua730\u026a\u029f\u1d07\u0274\u1d43\u1d0d\u1d07:" in text # ꜰɪʟᴇɴᴀᴍᴇ
84
- assert "\u27a1 Title \u2986 Dragon Ball Super Beerus" in text
85
- assert "\u27a1 Season \u2986 01" in text
86
- assert "\u27a1 Episode \u2986 02" in text
87
- assert "\u27a1 Quality \u2986 1080p" in text
88
- assert text.count("\u27a1") == 8 # four fields in each of the two sections
89
- assert "Chapter" not in text
90
-
91
-
92
- def test_check_report_shows_none_for_empty_values_and_extra_fields_only_when_present():
93
- text = check_report(Info(title="X"), Info(title="X"))
94
- assert "\u27a1 Season \u2986 None" in text
95
- assert "Year" not in text and "Codec" not in text
96
- text = check_report(Info(title="X", year="2023", codec="HEVC"), Info(title="X"))
97
- assert "\u27a1 Year \u2986 2023" in text and "\u27a1 Codec \u2986 HEVC" in text
98
- ```
99
-
100
- - [ ] **Step 2: Run the tests to verify they fail**
101
-
102
- Run: `pytest tests/test_check.py -v`
103
- Expected: FAIL — `ImportError: cannot import name 'effective_info' from 'bot.parser'`
104
-
105
- - [ ] **Step 3: Implement `effective_info` in `bot/parser.py`**
106
-
107
- Add after `parse_caption`:
108
-
109
- ```python
110
- def effective_info(stem: str, caption: str, mode: str) -> Info:
111
- """The details a rename would use for this file.
112
-
113
- Caption mode only takes the caption when it yields a structured field; an
114
- unstructured caption is discarded and the filename is used unchanged.
115
- """
116
- info = parse_info(stem)
117
- if mode != "caption" or not (caption or "").strip():
118
- return info
119
- cinfo = parse_caption(caption)
120
- if not cinfo.structured:
121
- return info
122
- for field in ("title", "season", "episode", "quality", "year", "codec"):
123
- if not getattr(cinfo, field) and getattr(info, field):
124
- setattr(cinfo, field, getattr(info, field))
125
- return cinfo
126
- ```
127
-
128
- Refactor `compute_new_name` to call it: replace its inline merge block with
129
- `info = effective_info(stem, caption, mode)`. The returned name must not change.
130
-
131
- - [ ] **Step 4: Implement `to_small_caps` in `bot/utils.py`**
132
-
133
- A module-level dict mapping each ASCII letter to its small-capital codepoint, and:
134
-
135
- ```python
136
- def to_small_caps(text: str) -> str:
137
- """'Your' -> 'Y\u0282\u1d0f\u1d1c'. Unmapped characters are left alone."""
138
- return "".join(SMALL_CAPS.get(ch, ch) for ch in (text or ""))
139
- ```
140
-
141
- The mapping table (both cases map to the same glyph):
142
-
143
- ```
144
- a \u1d43 b \u0299 c \u1d04 d \u1d05 e \u1d07 f \ua730 g \u0262
145
- h \u029c i \u026a j \u1d0a k \u1d0b l \u029f m \u1d0d n \u0274
146
- o \u1d0f p \u1d18 q \u01eb r \u0280 s \u0073 t \u1d1b u \u1d1c
147
- v \u1d20 w \u1d21 x \u0078 y \u02bf z \u1d22
148
- ```
149
-
150
- - [ ] **Step 5: Implement `check_report` in `bot/panels.py`**
151
-
152
- ```python
153
- def check_report(file_info: Info, caption_info: Info) -> str:
154
- """The /check body: what the bot extracted from the name and from the caption."""
155
- ```
156
-
157
- Structure, exactly:
158
-
159
- ```
160
- <smallcaps "Your Can Change Mode By"> /mode
161
-
162
- <smallcaps "FileName">:
163
- ➦ Title ⭆ <value>
164
- ➦ Season ⭆ <value>
165
- ➦ Episode ⭆ <value>
166
- ➦ Quality ⭆ <value>
167
- [➦ Year ⭆ <value>] only when the parser found one
168
- [➦ Codec ⭆ <value>] only when the parser found one
169
-
170
- <smallcaps "Caption Mode">:
171
- ➦ ... same fields ...
172
- ```
173
-
174
- Field order is Title, Season, Episode, Quality, then Year and Codec when present.
175
- An empty value renders as `None`. Values are HTML-escaped with `esc` from
176
- `bot.utils`. The section headings are small-capped; the field names and values are not.
177
- Import `Info` and `to_small_caps` in `bot/panels.py`.
178
-
179
- - [ ] **Step 6: Run the tests to verify they pass**
180
-
181
- Run: `pytest tests/test_check.py -v`
182
- Expected: PASS (4 tests)
183
-
184
- - [ ] **Step 7: Run the whole suite to confirm nothing regressed**
185
-
186
- Run: `pytest -q`
187
- Expected: all previously passing tests still pass (266 + 4)
188
-
189
- - [ ] **Step 8: Commit**
190
-
191
- ```bash
192
- git add bot/parser.py bot/utils.py bot/panels.py tests/test_check.py
193
- git commit -m "feat: mode-aware info parsing, small caps and the /check report"
194
- ```
195
-
196
- ---
197
-
198
- ### Task 2: Database tables for formats, sequences and the start banner
199
-
200
- **Files:**
201
- - Modify: `bot/db.py`
202
- - Modify: `tests/test_db.py`
203
-
204
- **Interfaces:**
205
- - Consumes: nothing new.
206
- - Produces:
207
- - `db.MAX_FORMATS: int` — 12.
208
- - Model `Format` with columns `id` (Integer pk), `user_id` (BigInteger, indexed), `name` (String 64), `position` (Integer), `template` (Text, nullable), `thumb` (LargeBinary, nullable), `meta` (Text, JSON), `keep_audio` (Text, nullable), `keep_subtitle` (Text, nullable). Unique on `(user_id, name)`.
209
- - Model `SequenceRow` with `user_id` (BigInteger pk), `items` (Text, JSON list), `copied` (Integer), `updated_at` (DateTime).
210
- - Model `BotMedia` with `id` (Integer pk), `kind` (String 8), `data` (LargeBinary).
211
- - `Database.list_formats(uid) -> List[Dict]`, `get_format(uid, fmt_id) -> Optional[Dict]`,
212
- `create_format(uid, name) -> Optional[Dict]` (None when the name is taken or 12 exist),
213
- `update_format(uid, fmt_id, **changes) -> bool`, `delete_format(uid, fmt_id) -> bool`,
214
- `count_formats(uid) -> int`.
215
- - `Database.get_sequence(uid) -> Optional[Dict]` (`{"items": [...], "copied": int}`),
216
- `set_sequence(uid, items, copied=0) -> None`, `clear_sequence(uid) -> None`.
217
- - `Database.get_start_media() -> Optional[Dict]` (`{"kind": "image"|"gif", "data": bytes}`),
218
- `set_start_media(kind, data) -> None`, `clear_start_media() -> None`.
219
- - The format dict shape is `{"id": int, "name": str, "template": Optional[str], "thumb": Optional[bytes], "meta": Dict[str, Optional[str]], "keep_audio": Optional[List[str]], "keep_subtitle": Optional[List[str]]}`. `meta` always has the six keys `title, author, artist, audio, subtitle, video`, each `None` (inherit) or a string.
220
-
221
- - [ ] **Step 1: Write the failing tests**
222
-
223
- Append to `tests/test_db.py`:
224
-
225
- ```python
226
- async def test_formats_crud_and_the_twelve_slot_limit(db):
227
- assert await db.count_formats(7) == 0
228
- first = await db.create_format(7, "One Piece format")
229
- assert first["id"] and first["name"] == "One Piece format"
230
- assert first["template"] is None and first["keep_audio"] is None
231
- assert first["meta"]["title"] is None
232
- assert await db.create_format(7, "One Piece format") is None # the name is taken
233
- for i in range(11):
234
- assert await db.create_format(7, f"F{i}") is not None
235
- assert await db.count_formats(7) == 12
236
- assert await db.create_format(7, "Thirteenth") is None # the limit
237
- assert await db.count_formats(8) == 0 # per user
238
- assert [f["name"] for f in await db.list_formats(7)][0] == "One Piece format"
239
-
240
-
241
- async def test_format_fields_round_trip_through_the_database(db):
242
- fmt = await db.create_format(7, "Dragon Ball")
243
- await db.update_format(7, fmt["id"], template="[S{season}E{episode}] {title}",
244
- meta={"title": "", "author": "Kuririn"},
245
- keep_audio=[], keep_subtitle=["english"])
246
- got = await db.get_format(7, fmt["id"])
247
- assert got["template"] == "[S{season}E{episode}] {title}"
248
- assert got["meta"]["title"] == "" and got["meta"]["author"] == "Kuririn"
249
- assert got["meta"]["artist"] is None
250
- assert got["keep_audio"] == [] and got["keep_subtitle"] == ["english"]
251
- assert await db.get_format(9, fmt["id"]) is None # another user
252
- assert await db.delete_format(7, fmt["id"]) is True
253
- assert await db.delete_format(7, fmt["id"]) is False
254
-
255
-
256
- async def test_sequence_and_start_media(db):
257
- assert await db.get_sequence(3) is None
258
- await db.set_sequence(3, [{"msg_id": 1, "name": "a.mkv", "caption": ""}])
259
- seq = await db.get_sequence(3)
260
- assert seq["items"][0]["name"] == "a.mkv" and seq["copied"] == 0
261
- await db.set_sequence(3, seq["items"], copied=2)
262
- assert (await db.get_sequence(3))["copied"] == 2
263
- await db.clear_sequence(3)
264
- assert await db.get_sequence(3) is None
265
-
266
- assert await db.get_start_media() is None
267
- await db.set_start_media("gif", b"\x00gif")
268
- assert (await db.get_start_media())["kind"] == "gif"
269
- assert (await db.get_start_media())["data"] == b"\x00gif"
270
- await db.set_start_media("image", b"\xff\xd8")
271
- assert (await db.get_start_media())["kind"] == "image" # one row, replaced
272
- await db.clear_start_media()
273
- assert await db.get_start_media() is None
274
- ```
275
-
276
- - [ ] **Step 2: Run the tests to verify they fail**
277
-
278
- Run: `pytest tests/test_db.py -v`
279
- Expected: FAIL — `AttributeError: 'Database' object has no attribute 'count_formats'`
280
-
281
- - [ ] **Step 3: Add the models to `bot/db.py`**
282
-
283
- Add `UniqueConstraint` to the sqlalchemy import. Add the three models after `class Admin`. Use
284
- `mapped_column(Integer, primary_key=True, autoincrement=True)` for `Format.id` and `BotMedia.id`
285
- — **not** `BigInteger`, because SQLite only auto-increments a column declared exactly
286
- `INTEGER PRIMARY KEY`. `Format` gets `__table_args__ = (UniqueConstraint("user_id", "name"),)`.
287
-
288
- `Format.meta` stores a JSON object; `keep_audio` / `keep_subtitle` store `NULL` for "inherit"
289
- and a JSON array otherwise, so `NULL` and `[]` stay distinguishable.
290
-
291
- - [ ] **Step 4: Add the `Database` methods to `bot/db.py`**
292
-
293
- Add `MAX_FORMATS = 12` at module level.
294
-
295
- `create_format` must: reject a blank name, reject a name already used by that user, reject the
296
- call when `count_formats(uid) >= MAX_FORMATS`, and otherwise insert with `position` equal to the
297
- current count. It returns the created format dict, or `None` on any rejection.
298
-
299
- `update_format(uid, fmt_id, **changes)` accepts only the keys `name`, `template`, `thumb`,
300
- `meta`, `keep_audio`, `keep_subtitle`. It must only touch rows belonging to `uid` and returns
301
- whether a row was changed. `meta` is merged key by key over the stored dict; `keep_audio` and
302
- `keep_subtitle` are replaced wholesale and `None` is stored as SQL `NULL`.
303
-
304
- Every format read decodes `meta` from JSON (a missing key reads back as `None`) and decodes
305
- `keep_audio` / `keep_subtitle` from JSON, leaving `None` as `None`.
306
-
307
- `get_sequence` returns `None` when there is no row, otherwise `{"items": json.loads(row.items), "copied": row.copied}`.
308
- `set_sequence` upserts the row for that user. `clear_sequence` deletes the row.
309
-
310
- `get_start_media` returns the single row (`id == 1`) as `{"kind": ..., "data": bytes(row.data)}` or `None`.
311
- `set_start_media` replaces the row (`id == 1`). `clear_start_media` deletes it.
312
-
313
- - [ ] **Step 5: Run the tests to verify they pass**
314
-
315
- Run: `pytest tests/test_db.py -v`
316
- Expected: PASS (3 new tests plus the existing ones)
317
-
318
- - [ ] **Step 6: Run the whole suite**
319
-
320
- Run: `pytest -q`
321
- Expected: everything passes
322
-
323
- - [ ] **Step 7: Commit**
324
-
325
- ```bash
326
- git add bot/db.py tests/test_db.py
327
- git commit -m "feat: database tables for formats, sequences and the start banner"
328
- ```
329
-
330
- ---
331
-
332
- ### Task 3: Resolving a format into effective settings
333
-
334
- **Files:**
335
- - Create: `bot/formats.py`
336
- - Create: `tests/test_formats.py`
337
-
338
- **Interfaces:**
339
- - Consumes: `db.get_format` and the format dict shape from Task 2; `db.DEFAULT_SETTINGS` from `bot.db`.
340
- - Produces:
341
- - `formats.MAX_FORMATS: int` — re-exported from `bot.db`.
342
- - `formats.META_FIELDS: Tuple[str, ...]` — `("title", "author", "artist", "audio", "subtitle", "video")`.
343
- - `formats.resolve_format(fmt: Dict[str, Any], settings: Dict[str, Any]) -> Dict[str, Any]` — a deep copy of `settings` with the format's non-`None` fields overlaid. Overlays `template`, each non-`None` key of `meta`, and `keep_audio` / `keep_subtitle` when they are not `None`.
344
- - `formats.resolve_thumbnail(fmt: Dict[str, Any], global_thumb: Optional[bytes]) -> Tuple[Optional[bytes], bool]` — the thumbnail bytes to send and whether an automatic video frame may still be used as a fallback.
345
- - `formats.apply_format_field(db, uid, fmt_id, kind, text) -> Tuple[bool, str]` — validates and stores one format field, returning `(ok, html_message)`. Kinds: `fmt_name`, `fmt_template`, `fmt_title`, `fmt_author`, `fmt_artist`, `fmt_audio`, `fmt_subtitle`, `fmt_video`, `fmt_keep_audio`, `fmt_keep_subtitle`.
346
-
347
- - [ ] **Step 1: Write the failing tests**
348
-
349
- Create `tests/test_formats.py`:
350
-
351
- ```python
352
- from bot.db import DEFAULT_SETTINGS
353
- from bot.formats import apply_format_field, resolve_format, resolve_thumbnail
354
-
355
-
356
- def test_a_format_that_inherits_everything_changes_nothing():
357
- fmt = {"template": None, "meta": {f: None for f in ("title", "author", "artist", "audio", "subtitle", "video")},
358
- "keep_audio": None, "keep_subtitle": None}
359
- settings = dict(DEFAULT_SETTINGS, template="GLOBAL", keep_audio=["english"])
360
- assert resolve_format(fmt, settings) == settings
361
-
362
-
363
- def test_a_format_overlays_only_the_fields_it_sets():
364
- fmt = {"template": "[S{season}E{episode}] {title}",
365
- "meta": {"title": "One Piece", "author": None, "artist": None, "audio": None, "subtitle": None, "video": None},
366
- "keep_audio": [], "keep_subtitle": None}
367
- settings = dict(DEFAULT_SETTINGS, template="GLOBAL", keep_audio=["english"], keep_subtitle=["japanese"])
368
- out = resolve_format(fmt, settings)
369
- assert out["template"] == "[S{season}E{episode}] {title}"
370
- assert out["meta"]["title"] == "One Piece" and out["meta"]["author"] == ""
371
- assert out["keep_audio"] == [] and out["keep_subtitle"] == ["japanese"] # inherit
372
- assert settings["template"] == "GLOBAL" # the input is not mutated
373
-
374
-
375
- def test_resolve_thumbnail_states():
376
- inherit = {"thumb": None}
377
- custom = {"thumb": b"\xff\xd8jpeg"}
378
- none = {"thumb": b""}
379
- assert resolve_thumbnail(inherit, b"GLOBAL") == (b"GLOBAL", False)
380
- assert resolve_thumbnail(inherit, None) == (None, True) # a video frame may still be used
381
- assert resolve_thumbnail(custom, b"GLOBAL") == (b"\xff\xd8jpeg", False)
382
- assert resolve_thumbnail(none, b"GLOBAL") == (None, False) # explicitly none: never a frame
383
-
384
-
385
- async def test_apply_format_field_clearing_and_inheriting(db):
386
- fmt = await db.create_format(1, "One Piece")
387
- ok, msg = await apply_format_field(db, 1, fmt["id"], "fmt_title", "One Piece")
388
- assert ok and "One Piece" in msg
389
- ok, _ = await apply_format_field(db, 1, fmt["id"], "fmt_title", "off")
390
- assert ok
391
- assert (await db.get_format(1, fmt["id"]))["meta"]["title"] == "" # explicitly empty
392
- ok, _ = await apply_format_field(db, 1, fmt["id"], "fmt_keep_audio", "off")
393
- assert ok
394
- assert (await db.get_format(1, fmt["id"]))["keep_audio"] == [] # keep every track
395
- ok, msg = await apply_format_field(db, 1, fmt["id"], "fmt_name", "off")
396
- assert not ok # a format must have a name
397
- ```
398
-
399
- - [ ] **Step 2: Run the tests to verify they fail**
400
-
401
- Run: `pytest tests/test_formats.py -v`
402
- Expected: FAIL — `ModuleNotFoundError: No module named 'bot.formats'`
403
-
404
- - [ ] **Step 3: Implement `bot/formats.py`**
405
-
406
- `resolve_format` deep-copies `settings` (use `copy.deepcopy`), then applies the overlays. The
407
- `meta` overlay merges field by field over the copied `settings["meta"]`, keeping the six
408
- `META_FIELDS` keys.
409
-
410
- `resolve_thumbnail` implements the three states exactly as the test pins them:
411
- a `None` thumb inherits the global thumbnail (and only allows a video frame when there is no
412
- global thumbnail at all), a non-empty thumb is the custom one, and an empty thumb means
413
- "no thumbnail at all" — never a video frame.
414
-
415
- `apply_format_field` maps each kind onto a storage key:
416
-
417
- | kind | stored as | `off` means |
418
- |---|---|---|
419
- | `fmt_name` | `name` | rejected — a format must have a name |
420
- | `fmt_template` | `template` | inherit (`None`) — an empty template is not a valid state |
421
- | `fmt_title` … `fmt_video` | `meta[<field>]` | explicitly empty (`""`) |
422
- | `fmt_keep_audio`, `fmt_keep_subtitle` | `keep_audio` / `keep_subtitle` | explicitly empty (`[]`) — keep every track |
423
-
424
- Reuse `parse_track_tokens` and `validate_track_tokens` from `bot.media` for the keep lists, and
425
- `CLEAR_WORDS` from `bot.settings_ops` for the `off` check. The template is validated with the
426
- same rules the global template uses (non-empty, at most 200 characters).
427
-
428
- - [ ] **Step 4: Run the tests to verify they pass**
429
-
430
- Run: `pytest tests/test_formats.py -v`
431
- Expected: PASS (4 tests)
432
-
433
- - [ ] **Step 5: Run the whole suite**
434
-
435
- Run: `pytest -q`
436
- Expected: everything passes
437
-
438
- - [ ] **Step 6: Commit**
439
-
440
- ```bash
441
- git add bot/formats.py tests/test_formats.py
442
- git commit -m "feat: resolve a saved format into effective settings"
443
- ```
444
-
445
- ---
446
-
447
- ### Task 4: `/rename` as a format picker, and the format editor
448
-
449
- **Files:**
450
- - Modify: `bot/queue.py` (`Job` gains the override fields)
451
- - Modify: `bot/pipeline.py` (apply the override; honour the thumbnail state)
452
- - Modify: `bot/panels.py` (the format list and the format editor panels)
453
- - Modify: `bot/app.py` (the `/rename` and `/formats` commands, the callbacks, the enqueue path)
454
- - Modify: `tests/test_app.py` (rewrite the two removed-behaviour tests, add the new ones)
455
-
456
- **Interfaces:**
457
- - Consumes: `db.list_formats` / `get_format` / `create_format` / `update_format` / `delete_format` from Task 2; `formats.resolve_format`, `formats.resolve_thumbnail`, `formats.apply_format_field` from Task 3.
458
- - Produces:
459
- - `queue.Job.thumbnail_mode: str` — `"auto"` (use the global thumbnail), `"custom"` (use `Job.thumbnail`), `"none"` (send no thumbnail at all). Default `"auto"`.
460
- - `queue.Job.thumbnail: Optional[bytes]` — the custom thumbnail bytes, used when `thumbnail_mode == "custom"`.
461
- - `queue.Job.settings_override: Optional[Dict[str, Any]]` — the already-resolved effective settings.
462
- - `panels.formats_list(formats: Sequence[Dict], *, for_rename: bool) -> Panel` — the picker.
463
- - `panels.format_editor(fmt: Dict, settings: Dict) -> Panel` — the editor.
464
- - `app.App.format_panel(uid: int, fmt_id: int) -> Panel` — loads the format and the global settings and returns the editor panel.
465
-
466
- **Callback data** (all under 64 bytes):
467
-
468
- | data | meaning |
469
- |---|---|
470
- | `fm:<id>` | use this format for the next file |
471
- | `fl:<id>` | open this format in the editor |
472
- | `fnew` | create a format (asks for a name) |
473
- | `fset:<field>:<id>` | ask for a text value for a field |
474
- | `fdef:<field>:<id>` | set a field back to "inherit my default" |
475
- | `fthumb:<id>` | ask for a photo (the thumbnail field) |
476
- | `fthumbdef:<id>` | thumbnail → inherit the global thumbnail |
477
- | `fthumbnone:<id>` | thumbnail → explicitly no thumbnail |
478
- | `fdel:<id>` | ask to confirm the delete |
479
- | `fdelok:<id>` | delete it |
480
- | `frename:<id>` | ask for a new name |
481
-
482
- - [ ] **Step 1: Write the failing tests**
483
-
484
- In `tests/test_app.py`, **replace** `test_rename_command_uses_the_given_name_and_keeps_the_extension`
485
- and `test_rename_usage_messages` with:
486
-
487
- ```python
488
- async def test_rename_picks_a_format_then_renames_the_next_file(ctx):
489
- ev = await say(ctx, OWNER, "/rename")
490
- assert "fmt" not in ev.out[0][0] # no formats yet
491
- assert "fnew" in data_of(ev.out[0][1]) # only "create a format" is offered
492
-
493
- fmt = await ctx.db.create_format(OWNER, "One Piece format")
494
- ev = await say(ctx, OWNER, "/rename")
495
- assert "One Piece format" in ev.out[0][0]
496
- assert f"fm:{fmt['id']}" in data_of(ev.out[0][1])
497
-
498
- await click(ctx, OWNER, f"fm:{fmt['id']}")
499
- assert ctx.app.pending[OWNER].kind == "rename_format"
500
- await send_doc(ctx, OWNER, "[SubsPlease] Tokyo Revengers - 05 (1080p) [ABCD1234].mkv")
501
- (job,) = ctx.app.queue.jobs.values()
502
- assert job.new_name == EPISODE_NEW # the format's template was applied
503
- assert OWNER not in ctx.app.pending # one file per /rename
504
- ```
505
-
506
- Add a test that the format's template really reaches the name (Review Focus 1):
507
-
508
- ```python
509
- async def test_a_formats_template_reaches_the_file_name(ctx):
510
- fmt = await ctx.db.create_format(OWNER, "One Piece")
511
- await ctx.db.update_format(OWNER, fmt["id"], template="TR - {title} - E{episode}")
512
- await click(ctx, OWNER, f"fm:{fmt['id']}")
513
- await send_doc(ctx, OWNER, "Tokyo Revengers - 07 (1080p).mkv")
514
- (job,) = ctx.app.queue.jobs.values()
515
- assert job.new_name == "TR - Tokyo Revengers - E07.mkv"
516
- assert job.settings_override["template"] == "TR - {title} - E{episode}"
517
- ```
518
-
519
- Add a test for the thumbnail states (Review Focus 2):
520
-
521
- ```python
522
- async def test_a_format_can_suppress_the_thumbnail_entirely(ctx):
523
- fmt = await ctx.db.create_format(OWNER, "No Thumb")
524
- await ctx.db.update_format(OWNER, fmt["id"], thumb=b"")
525
- await click(ctx, OWNER, f"fm:{fmt['id']}")
526
- await send_doc(ctx, OWNER, "Movie.mkv")
527
- (job,) = ctx.app.queue.jobs.values()
528
- assert job.thumbnail_mode == "none" and job.thumbnail is None
529
- ```
530
-
531
- Add a test for a deleted format (Review Focus 5):
532
-
533
- ```python
534
- async def test_deleting_a_format_does_not_break_a_queued_job(ctx):
535
- fmt = await ctx.db.create_format(OWNER, "Temp")
536
- await click(ctx, OWNER, f"fm:{fmt['id']}")
537
- await send_doc(ctx, OWNER, "Movie.mkv")
538
- (job,) = ctx.app.queue.jobs.values()
539
- await ctx.db.delete_format(OWNER, fmt["id"])
540
- assert job.settings_override is not None # the job carries resolved settings, not an id
541
- ```
542
-
543
- And the format-editor tests:
544
-
545
- ```python
546
- async def test_the_format_editor_saves_every_field(ctx):
547
- fmt = await ctx.db.create_format(OWNER, "One Piece")
548
- ev = await click(ctx, OWNER, f"fl:{fmt['id']}")
549
- assert "One Piece" in ev.edits[0][0]
550
- await click(ctx, OWNER, f"fset:fmt_title:{fmt['id']}")
551
- assert ctx.app.pending[OWNER].kind == "fmt_title"
552
- await say(ctx, OWNER, "One Piece")
553
- got = await ctx.db.get_format(OWNER, fmt["id"])
554
- assert got["meta"]["title"] == "One Piece"
555
-
556
- await click(ctx, OWNER, f"fdef:fmt_title:{fmt['id']}")
557
- assert (await ctx.db.get_format(OWNER, fmt["id"]))["meta"]["title"] is None # inherit
558
-
559
- await click(ctx, OWNER, f"fthumbnone:{fmt['id']}")
560
- assert (await ctx.db.get_format(OWNER, fmt["id"]))["thumb"] == b""
561
- ```
562
-
563
- - [ ] **Step 2: Run the tests to verify they fail**
564
-
565
- Run: `pytest tests/test_app.py -v -k "format or rename"`
566
- Expected: FAIL — the `/rename` command still expects a name, and `Job` has no `thumbnail_mode`.
567
-
568
- - [ ] **Step 3: Add the override fields to `Job` in `bot/queue.py`**
569
-
570
- ```python
571
- thumbnail_mode: str = "auto" # auto | custom | none
572
- thumbnail: Optional[bytes] = None
573
- settings_override: Optional[Dict[str, Any]] = None
574
- ```
575
-
576
- Remove the `manual: bool = False` field at the same time (see Step 7).
577
-
578
- - [ ] **Step 4: Apply the override in `bot/pipeline.py`**
579
-
580
- In `Renamer.process`, replace `s = await self.db.get_settings(job.user_id)` with a merge of
581
- the global settings and `job.settings_override` (the override is already the full effective
582
- dict, so the merge is a plain replacement when it is not `None`).
583
-
584
- In `Renamer._run`, replace the thumbnail block with:
585
-
586
- ```python
587
- if job.thumbnail_mode == "none":
588
- thumb = None
589
- elif job.thumbnail_mode == "custom":
590
- thumb = job.thumbnail
591
- else:
592
- thumb = await self.db.get_thumb(job.user_id)
593
- if thumb is None and ext.lower() in media.VIDEO_CONTAINERS and info is not None and info.of("video"):
594
- ...the existing video-frame fallback...
595
- ```
596
-
597
- Never pass an empty byte string to `send_file(thumb=...)`.
598
-
599
- - [ ] **Step 5: Add the two panels to `bot/panels.py`**
600
-
601
- `formats_list(formats, *, for_rename)` — the text names the command (`/rename` or `/formats`)
602
- and lists one button per format showing its name, with a marker when the format overrides the
603
- global template. Add a "➕ New format" button (`fnew`) only when `len(formats) < MAX_FORMATS`.
604
- Add `nav("home")`.
605
-
606
- `format_editor(fmt, settings)` — one row per field with buttons `fset:<kind>:<id>` for the
607
- template, the six metadata fields and the two keep lists; a thumbnail row with `fthumb:<id>`,
608
- `fthumbdef:<id>` and `fthumbnone:<id>`; a name row (`frename:<id>`) and a delete row
609
- (`fdel:<id>`). Every field shows its current value, and marks an inherited field as
610
- "my default" so the tri-state is visible. Back goes to `m:formats`.
611
-
612
- - [ ] **Step 6: Add `"formats"` to `PANEL_NAMES` in `bot/app.py`**
613
-
614
- `PANEL_NAMES = {"home", "rename", "cap", "meta", "tool", "ftype", "help", "admin", "formats"}`.
615
-
616
- - [ ] **Step 7: Add the `/rename` and `/formats` commands to `bot/app.py`**
617
-
618
- `cmd_rename(event)`:
619
- - If the command has an argument, reply
620
- "❌ /rename no longer takes a name. Pick a format, then send me the file."
621
- - Otherwise reply with `panels.formats_list(await self.db.list_formats(uid), for_rename=True)`.
622
-
623
- `cmd_formats(event)`: reply with `panels.formats_list(await self.db.list_formats(uid), for_rename=False)`.
624
-
625
- `cmd_rename` must be reachable as `/rename` with no argument. The old `/rename <name>` path
626
- goes away, and with it:
627
-
628
- - The `manual` parameter of `App.enqueue` and the `manual=` argument on the `Job` it builds.
629
- - The `manual` field on `queue.Job`. Update the assertion in `tests/test_app.py` that reads
630
- `not job.manual` (the "queued" test) to drop that part — it becomes
631
- `assert job.file_name == EPISODE and job.size == 50 * 1024 * 1024 and job.state == "queued"`.
632
- - The `build_manual_name` import in `bot/app.py`. Keep `parser.build_manual_name` and its
633
- tests in `tests/test_parser.py` — it is a small tested public helper, the bot simply
634
- stops calling it.
635
-
636
- - [ ] **Step 8: Wire the pending state and the callbacks in `bot/app.py`**
637
-
638
- `Pending` gains `format_id: Optional[int] = None`. `Pending.kind` gains the value
639
- `"rename_format"` and the ten `fmt_*` kinds from Task 3. Every format pending uses
640
- `back="formats"` so the existing `Pending` construction stays valid.
641
-
642
- `on_text` branches: when `p.format_id is not None`, call
643
- `apply_format_field(self.db, uid, p.format_id, p.kind, event.raw_text)` and re-render the
644
- editor with `await self.format_panel(uid, p.format_id)`; otherwise keep the existing
645
- `apply_setting` path unchanged.
646
-
647
- `on_photo` branches: when the pending kind is `fmt_thumb`, store the prepared photo in the
648
- format (`update_format(..., thumb=data)`) and re-render the editor; otherwise keep the existing
649
- global-thumbnail behaviour.
650
-
651
- `on_media` branches: when the pending kind is `rename_format`, enqueue the file with that
652
- format and clear the pending; otherwise enqueue normally.
653
-
654
- `on_callback` handles the new callback data. `fm:<id>` sets
655
- `Pending(kind="rename_format", format_id=id, ...)` and replies
656
- "Send me the file to rename with **<name>**." `fnew` sets
657
- `Pending(kind="fmt_name", ...)` and asks for a name. `fset:<field>:<id>` sets the matching
658
- pending kind. `fdef:<field>:<id>` writes `None` for that field. `fthumbdef:<id>` writes
659
- `thumb=None`, `fthumbnone:<id>` writes `thumb=b""`. `fdel:<id>` asks for confirmation and
660
- `fdelok:<id>` deletes and re-renders the list. `frename:<id>` asks for a new name.
661
-
662
- Every callback must check that the format belongs to the sender's user id before changing it.
663
-
664
- - [ ] **Step 9: Resolve the format at enqueue time in `bot/app.py`**
665
-
666
- `App.enqueue` gains a `fmt: Optional[Dict]` parameter. When it is not `None`:
667
-
668
- ```python
669
- s = resolve_format(fmt, s)
670
- thumb_mode, thumb = resolve_thumbnail(fmt, await self.db.get_thumb(uid))
671
- ```
672
-
673
- and `compute_new_name` is called with the **resolved** `s["template"]` and `s["mode"]`, and the
674
- job is created with `settings_override=s`, `thumbnail_mode=thumb_mode` and `thumbnail=thumb`.
675
-
676
- This is the step that makes a format's template reach the file name — the name is computed
677
- here, not in the pipeline.
678
-
679
- - [ ] **Step 10: Run the tests to verify they pass**
680
-
681
- Run: `pytest tests/test_app.py -v`
682
- Expected: PASS
683
-
684
- - [ ] **Step 11: Run the whole suite**
685
-
686
- Run: `pytest -q`
687
- Expected: everything passes
688
-
689
- - [ ] **Step 12: Commit**
690
-
691
- ```bash
692
- git add bot/queue.py bot/pipeline.py bot/panels.py bot/app.py tests/test_app.py
693
- git commit -m "feat: named rename formats, the /rename picker and the format editor"
694
- ```
695
-
696
- ---
697
-
698
- ### Task 5: `/sequence` and `/sort`
699
-
700
- **Files:**
701
- - Create: `bot/sequence.py`
702
- - Create: `tests/test_sequence.py`
703
- - Modify: `bot/app.py` (the commands, the collection branch, the copy loop)
704
- - Modify: `tests/test_app.py` (the handler tests)
705
-
706
- **Interfaces:**
707
- - Consumes: `parser.effective_info` from Task 1; `db.get_sequence` / `set_sequence` / `clear_sequence` from Task 2.
708
- - Produces:
709
- - `sequence.detect_episodes(items: List[Dict[str, Any]], mode: str) -> List[Dict[str, Any]]` — returns the items with `season` and `episode` filled in. Each item is `{"msg_id": int, "name": str, "caption": str, "season": str, "episode": str}`.
710
- - `sequence.plan_sort(items: List[Dict[str, Any]]) -> sequence.SortResult` with
711
- - `SortResult.ordered: List[Dict[str, Any]]` — the items in sorted order.
712
- - `SortResult.missing: List[str]` — e.g. `["S01E03", "S01E07"]`.
713
- - `SortResult.duplicates: List[sequence.Duplicate]` — `Duplicate(season, episode, names)`.
714
- - `SortResult.no_episode: List[str]` — the file names that carry no episode number.
715
- - `sequence.format_report(result: SortResult, total: int) -> str` — the message shown after the sort.
716
-
717
- - [ ] **Step 1: Write the failing tests**
718
-
719
- Create `tests/test_sequence.py`:
720
-
721
- ```python
722
- from bot.sequence import detect_episodes, format_report, plan_sort
723
-
724
-
725
- def items(*names):
726
- return [{"msg_id": i, "name": n, "caption": ""} for i, n in enumerate(names)]
727
-
728
-
729
- def test_episodes_are_detected_from_the_file_name():
730
- out = detect_episodes(items("Show S01E02.mkv", "Show S01E10.mkv"), "filename")
731
- assert [(i["season"], i["episode"]) for i in out] == [("01", "02"), ("01", "10")]
732
-
733
-
734
- def test_sorting_is_by_season_then_episode_ascending():
735
- out = plan_sort(detect_episodes(
736
- items("S01E10.mkv", "S02E01.mkv", "S01E02.mkv", "S01E03.mkv"), "filename"))
737
- assert [i["name"] for i in out.ordered] == ["S01E02.mkv", "S01E03.mkv", "S01E10.mkv", "S02E01.mkv"]
738
-
739
-
740
- def test_missing_episodes_are_reported_per_season():
741
- out = plan_sort(detect_episodes(items("S01E01.mkv", "S01E04.mkv", "S02E01.mkv"), "filename"))
742
- assert out.missing == ["S01E02", "S01E03"]
743
-
744
-
745
- def test_duplicate_episodes_are_reported_with_their_file_names():
746
- out = plan_sort(detect_episodes(items("S01E02.mkv", "S01E02 (copy).mkv", "S01E03.mkv"), "filename"))
747
- assert len(out.duplicates) == 1
748
- assert out.duplicates[0].episode == "02"
749
- assert out.duplicates[0].names == ["S01E02.mkv", "S01E02 (copy).mkv"]
750
-
751
-
752
- def test_a_file_without_an_episode_is_kept_and_reported():
753
- out = plan_sort(detect_episodes(items("S01E02.mkv", "bonus.mkv", "extra.mkv"), "filename"))
754
- assert [i["name"] for i in out.ordered] == ["S01E02.mkv", "bonus.mkv", "extra.mkv"]
755
- assert out.no_episode == ["bonus.mkv", "extra.mkv"]
756
-
757
-
758
- def test_the_report_lists_only_what_is_wrong():
759
- out = plan_sort(detect_episodes(items("S01E01.mkv", "S01E03.mkv"), "filename"))
760
- text = format_report(out, 2)
761
- assert "S01E02" in text and "Duplicates" not in text and "No episode" not in text
762
- ```
763
-
764
- - [ ] **Step 2: Run the tests to verify they fail**
765
-
766
- Run: `pytest tests/test_sequence.py -v`
767
- Expected: FAIL — `ModuleNotFoundError: No module named 'bot.sequence'`
768
-
769
- - [ ] **Step 3: Implement `bot/sequence.py`**
770
-
771
- `detect_episodes` calls `effective_info(stem, item["caption"], mode)` for each item, where
772
- `stem` is the file name without its extension, and fills `season` and `episode` with the
773
- parsed values (empty strings when there are none).
774
-
775
- `plan_sort` sorts with a **stable** sort whose key is `(0, season, episode)` for an item with
776
- an episode and `(1, "", "")` for one without, so the numbered files come first in season and
777
- episode order and the unnumbered ones keep their arrival order at the end.
778
-
779
- Missing episodes: group the items by season; for each season take the episodes from the
780
- lowest to the highest present and subtract the ones present. The result is sorted and
781
- formatted as `S{season}E{episode}`.
782
-
783
- Duplicates: count the `(season, episode)` pairs; every pair seen more than once becomes a
784
- `Duplicate` holding the season, the episode and the file names in arrival order.
785
-
786
- `format_report` returns a message with the sorted count first, then the missing list, the
787
- duplicate list and the no-episode list, omitting any section that is empty.
788
-
789
- - [ ] **Step 4: Run the tests to verify they pass**
790
-
791
- Run: `pytest tests/test_sequence.py -v`
792
- Expected: PASS (6 tests)
793
-
794
- - [ ] **Step 5: Write the handler tests for the sequence**
795
-
796
- In `tests/test_app.py`:
797
-
798
- ```python
799
- async def test_an_open_sequence_collects_files_instead_of_renaming_them(ctx):
800
- ev = await say(ctx, OWNER, "/sequence")
801
- assert "Send me the files" in ev.texts[0]
802
- await send_doc(ctx, OWNER, "Show S01E02.mkv")
803
- await send_doc(ctx, OWNER, "Show S01E01.mkv")
804
- assert not ctx.app.queue.jobs # nothing was renamed
805
- seq = await ctx.db.get_sequence(OWNER)
806
- assert [i["name"] for i in seq["items"]] == ["Show S01E02.mkv", "Show S01E01.mkv"]
807
-
808
-
809
- async def test_sort_copies_the_files_back_in_order(ctx):
810
- await say(ctx, OWNER, "/sequence")
811
- await send_doc(ctx, OWNER, "Show S01E02.mkv", mid=11)
812
- await send_doc(ctx, OWNER, "Show S01E01.mkv", mid=12)
813
- await say(ctx, OWNER, "/sort")
814
- # the copies are made one at a time, in episode order
815
- assert [ids for _to, _from, ids in ctx.client.copies] == [[12], [11]]
816
- assert await ctx.db.get_sequence(OWNER) is None # the sequence closed
817
- ```
818
-
819
- `FakeClient` needs a `copies` list and a `forward_messages` method — add to `tests/tgfakes.py`
820
- (Telethon has no `copy_messages`; the sort uses `forward_messages` with `drop_author=True`,
821
- which is what drops the "forwarded from" header):
822
-
823
- ```python
824
- self.copies: List[tuple] = [] # forward_messages(to, messages, from_peer, **kw)
825
-
826
- async def forward_messages(self, to, messages, from_peer=None, **kw):
827
- self.copies.append((to, from_peer, list(messages), kw))
828
- return [SimpleNamespace(id=4000 + len(self.copies))]
829
- ```
830
-
831
- - [ ] **Step 6: Run the handler tests to verify they fail**
832
-
833
- Run: `pytest tests/test_app.py -v -k sequence`
834
- Expected: FAIL — `/sequence` is not a command.
835
-
836
- - [ ] **Step 7: Add the commands to `bot/app.py`**
837
-
838
- `cmd_sequence(event)`: if a sequence is already open, reply with the count and the
839
- `/sort` hint; otherwise create an empty sequence and reply "Send me the files to sort.
840
- When you're done, send /sort." with a "✖ Cancel" button (`sq:cancel`).
841
-
842
- `cmd_sort(event)`: if no sequence is open, reply "You have no files to sort." Otherwise:
843
-
844
- 1. `items = detect_episodes(seq["items"], mode)` where `mode` is the user's current mode.
845
- 2. `result = plan_sort(items)`.
846
- 3. Send the files back **one at a time**, awaiting each one, with a short sleep between
847
- them (0.4 s), starting from `seq["copied"]` so a retry resumes. Each one is
848
- `await self.client.forward_messages(chat_id, [msg_id], chat_id, drop_author=True)` —
849
- Telethon has no true copy, and `drop_author=True` is what removes the "forwarded
850
- from" header. `copied` is an index into the **ordered** list, so the ordering must be
851
- identical on a retry; it is, as long as the user's mode has not changed since the
852
- sort started.
853
- 4. Save the new `copied` count after every copy.
854
- 5. Edit the status message with `format_report(result, len(result.ordered))`.
855
- 6. Clear the sequence.
856
-
857
- The original messages are **kept** — never delete them. A copy that raises `FloodWaitError`
858
- must wait and retry, and a copy that fails for any other reason must stop the loop, report
859
- how many were copied, and leave the sequence open.
860
-
861
- `on_media`: before enqueueing, check `await self.db.get_sequence(uid)`. If a sequence is open,
862
- collect the file instead of renaming it (Review Focus 4) — append
863
- `{"msg_id": msg.id, "name": original_name_of(msg), "caption": msg.raw_text or ""}` to the
864
- sequence's items, save it, and acknowledge with the new count.
865
-
866
- The `sq:cancel` callback clears the sequence.
867
-
868
- - [ ] **Step 8: Run the handler tests to verify they pass**
869
-
870
- Run: `pytest tests/test_app.py -v -k "sequence or sort"`
871
- Expected: PASS
872
-
873
- - [ ] **Step 9: Run the whole suite**
874
-
875
- Run: `pytest -q`
876
- Expected: everything passes
877
-
878
- - [ ] **Step 10: Commit**
879
-
880
- ```bash
881
- git add bot/sequence.py tests/test_sequence.py tests/tgfakes.py bot/app.py tests/test_app.py
882
- git commit -m "feat: collect files with /sequence and sort them with /sort"
883
- ```
884
-
885
- ---
886
-
887
- ### Task 6: The `/check` command
888
-
889
- **Files:**
890
- - Modify: `bot/app.py`
891
- - Modify: `tests/test_app.py`
892
-
893
- **Interfaces:**
894
- - Consumes: `panels.check_report` and `parser.effective_info` from Task 1; `pipeline.original_name_of` and `pipeline.source_ext`.
895
- - Produces: the `/check` command handler.
896
-
897
- - [ ] **Step 1: Write the failing test**
898
-
899
- In `tests/test_app.py`:
900
-
901
- ```python
902
- async def test_check_reports_what_the_bot_extracted(ctx):
903
- reply = document_message("Dragon Ball Super Beerus S01E02 1080p.mkv", mid=40, caption="")
904
- ev = await say(ctx, OWNER, "/check", reply=reply)
905
- text = ev.texts[0]
906
- assert "Title \u2986 Dragon Ball Super Beerus" in text
907
- assert "Season \u2986 01" in text and "Episode \u2986 02" in text
908
- assert "Quality \u2986 1080p" in text
909
- assert text.count("\u27a1") == 8 # the name and the caption sections
910
-
911
-
912
- async def test_check_needs_a_file(ctx):
913
- ev = await say(ctx, OWNER, "/check")
914
- assert "Reply to a file" in ev.texts[0]
915
- ```
916
-
917
- - [ ] **Step 2: Run the test to verify it fails**
918
-
919
- Run: `pytest tests/test_app.py -v -k check`
920
- Expected: FAIL — `/check` is not a command.
921
-
922
- - [ ] **Step 3: Implement `cmd_check` in `bot/app.py`**
923
-
924
- Reply to a file with `/check`:
925
-
926
- 1. `reply = await event.get_reply_message()`; if it is not a renameable file, reply
927
- "Reply to a file with /check to see what I can read from it."
928
- 2. `name = original_name_of(reply)`, `stem, _ext = split_ext(name)`.
929
- 3. `file_info = parse_info(stem)` — the FileName section.
930
- 4. `caption_info = effective_info(stem, reply.raw_text or "", "caption")` — the Caption Mode
931
- section, exactly what a rename in caption mode would use.
932
- 5. Reply with `panels.check_report(file_info, caption_info)`.
933
-
934
- Import `parse_info`, `split_ext` from `bot.parser` and `check_report` from `bot.panels`.
935
-
936
- - [ ] **Step 4: Run the test to verify it passes**
937
-
938
- Run: `pytest tests/test_app.py -v -k check`
939
- Expected: PASS
940
-
941
- - [ ] **Step 5: Run the whole suite**
942
-
943
- Run: `pytest -q`
944
- Expected: everything passes
945
-
946
- - [ ] **Step 6: Commit**
947
-
948
- ```bash
949
- git add bot/app.py tests/test_app.py
950
- git commit -m "feat: the /check command"
951
- ```
952
-
953
- ---
954
-
955
- ### Task 7: `/ethumb` and `/resize`
956
-
957
- **Files:**
958
- - Modify: `bot/media.py` (add `extract_frame`, `resize_image`, `parse_size`)
959
- - Modify: `bot/app.py` (the two commands and their callbacks)
960
- - Modify: `tests/test_media.py` (the media helpers)
961
- - Modify: `tests/test_app.py` (the handlers)
962
- - Modify: `tests/tgfakes.py` (the fake client needs to serve a video file)
963
-
964
- **Interfaces:**
965
- - Consumes: `media.probe` for the duration; `media.prepare_thumb` for the Telegram thumbnail limits.
966
- - Produces:
967
- - `media.extract_frame(src: Path, dst: Path, duration: float, cancel=None) -> bool` — grabs one frame at ~10% of the duration at the **source width** (the existing `make_video_thumbnail` scales to 320px; this one does not). Failure is non-fatal and returns `False`.
968
- - `media.resize_image(data: bytes, width: Optional[int] = None, height: Optional[int] = None) -> bytes` — resizes an image. With one dimension the aspect ratio is kept; with both the exact size is forced. The result is a JPEG.
969
- - `media.parse_size(text: str) -> Tuple[Optional[int], Optional[int], Optional[str]]` — `"800"` gives `(800, None, None)`, `"800x450"` gives `(800, 450, None)`, anything invalid gives `(None, None, "an error message")`.
970
-
971
- - [ ] **Step 1: Write the failing tests**
972
-
973
- In `tests/test_media.py`:
974
-
975
- ```python
976
- def test_parse_size_accepts_a_width_or_an_exact_size():
977
- assert media.parse_size("800") == (800, None, None)
978
- assert media.parse_size("800x450") == (800, 450, None)
979
- assert media.parse_size(" 640 ") == (640, None, None)
980
- w, h, err = media.parse_size("huge")
981
- assert (w, h) == (None, None) and "number" in err
982
-
983
-
984
- def test_resize_image_keeps_the_aspect_ratio_or_forces_the_exact_size():
985
- buf = io.BytesIO()
986
- Image.new("RGB", (1000, 500), (10, 20, 30)).save(buf, "PNG")
987
- data = buf.getvalue()
988
- out = Image.open(io.BytesIO(media.resize_image(data, width=200)))
989
- assert out.size == (200, 100)
990
- out = Image.open(io.BytesIO(media.resize_image(data, width=200, height=200)))
991
- assert out.size == (200, 200)
992
- ```
993
-
994
- In `tests/test_app.py`:
995
-
996
- ```python
997
- async def test_resize_answers_with_size_buttons(ctx):
998
- reply = FakeMessage(mid=60, photo=object())
999
- ev = await say(ctx, OWNER, "/resize", reply=reply)
1000
- assert "rz:320" in data_of(ev.out[0][1]) and "rz:orig" in data_of(ev.out[0][1])
1001
-
1002
-
1003
- async def test_resize_needs_an_image(ctx):
1004
- ev = await say(ctx, OWNER, "/resize")
1005
- assert "Reply to an image" in ev.texts[0]
1006
- ```
1007
-
1008
- - [ ] **Step 2: Run the tests to verify they fail**
1009
-
1010
- Run: `pytest tests/test_media.py tests/test_app.py -v -k "resize or parse_size"`
1011
- Expected: FAIL — `media.parse_size` does not exist.
1012
-
1013
- - [ ] **Step 3: Implement the three helpers in `bot/media.py`**
1014
-
1015
- `parse_size` accepts a bare number or `WIDTHxHEIGHT` (also `WIDTH X HEIGHT` with spaces).
1016
- Anything else returns an error message mentioning that a number was expected.
1017
-
1018
- `resize_image` opens the image with Pillow, converts to RGB, resizes with
1019
- `Image.LANCZOS` (or `Image.Resampling.LANCZOS` on newer Pillow) and saves as a JPEG.
1020
-
1021
- `extract_frame` runs:
1022
-
1023
- ```
1024
- ffmpeg -hide_banner -nostdin -y -loglevel error -ss {seek} -i {src} -frames:v 1 -q:v 2 {dst}
1025
- ```
1026
-
1027
- where `seek` is `1.0` when the duration is unknown, otherwise
1028
- `min(max(duration * 0.1, 0.0), 60.0)` — the same rule as `make_video_thumbnail`, but without
1029
- the `scale='min(320,iw)':-2` filter, so the frame keeps the source width.
1030
-
1031
- - [ ] **Step 4: Implement `cmd_ethumb` and `cmd_resize` in `bot/app.py`**
1032
-
1033
- `cmd_ethumb(event)`: reply to a file with `/ethumb`. Download it to a temp dir, probe it for
1034
- the duration, call `extract_frame`, and send the JPEG back as a photo with the buttons
1035
- `[("Set as my thumbnail", "eth:set"), ("✖ Close", "x:close")]`. Keep the bytes in
1036
- `self._ethumbs[uid]` so the `eth:set` callback can store them with `db.set_thumb`. If the
1037
- file has no video stream, reply "That file has no video — I can't take a thumbnail from it."
1038
-
1039
- `cmd_resize(event)`: reply to an image with `/resize`. Reply with the buttons
1040
- `[("320", "rz:320"), ("640", "rz:640"), ("1280", "rz:1280"), ("Original", "rz:orig"),
1041
- ("Custom", "rz:custom"), ("✖ Close", "x:close")]`.
1042
-
1043
- The `rz:<size>` callback resizes the replied-to image and sends the result back as a photo
1044
- with a "Set as my thumbnail" button (`eth:set`). `rz:orig` sends the image unchanged.
1045
- `rz:custom` asks for a size with a text pending (`kind="resize_size"`) and the next text
1046
- message is parsed with `media.parse_size`; an invalid size is reported and the pending stays.
1047
-
1048
- - [ ] **Step 5: Run the tests to verify they pass**
1049
-
1050
- Run: `pytest tests/test_media.py tests/test_app.py -v -k "resize or ethumb or parse_size"`
1051
- Expected: PASS
1052
-
1053
- - [ ] **Step 6: Run the whole suite**
1054
-
1055
- Run: `pytest -q`
1056
- Expected: everything passes
1057
-
1058
- - [ ] **Step 7: Commit**
1059
-
1060
- ```bash
1061
- git add bot/media.py bot/app.py tests/test_media.py tests/test_app.py tests/tgfakes.py
1062
- git commit -m "feat: /ethumb thumbnail extraction and /resize"
1063
- ```
1064
-
1065
- ---
1066
-
1067
- ### Task 8: `/speed` with the Ookla CLI
1068
-
1069
- **Files:**
1070
- - Create: `bot/speedtest.py`
1071
- - Create: `tests/test_speedtest.py`
1072
- - Modify: `bot/app.py` (the command)
1073
- - Modify: `Dockerfile` (install the Ookla CLI)
1074
-
1075
- **Interfaces:**
1076
- - Consumes: nothing new.
1077
- - Produces:
1078
- - `speedtest.SpeedResult` — a dataclass with `download_mbps: float`, `upload_mbps: float`, `ping_ms: float`, `server: str`, `isp: str`.
1079
- - `speedtest.parse_ookla_json(raw: str) -> SpeedResult` — parses the JSON from `ookla-speedtest --format=json`. Raises `ValueError` on anything it cannot read.
1080
- - `speedtest.format_speed(result: SpeedResult) -> str` — the message shown in the chat.
1081
- - `speedtest.run_speedtest(binary: str = "ookla-speedtest", timeout: float = 120.0) -> SpeedResult` — runs the CLI with `--accept-license --accept-gdpr --simple --format=json`. Raises `FileNotFoundError` when the binary is missing and `asyncio.TimeoutError` when it times out.
1082
-
1083
- - [ ] **Step 1: Write the failing tests**
1084
-
1085
- Create `tests/test_speedtest.py`:
1086
-
1087
- ```python
1088
- import pytest
1089
- from bot.speedtest import format_speed, parse_ookla_json
1090
-
1091
- RAW = """{"timestamp":"2024-01-01T00:00:00.000Z",
1092
- "download":{"bandwidth":91234567,"bytes":1000,"elapsed":10000},
1093
- "upload":{"bandwidth":4567890,"bytes":1000,"elapsed":10000},
1094
- "ping":{"jitter":2,"iqm":12,"low":10,"high":15},
1095
- "server":{"id":"1","host":"x","name":"Mumbai","country":"IN","sponsor":"ACME","d":0,"latency":12},
1096
- "isp":"Example ISP","result":{"id":"1","url":"http://x","ok":true}}"""
1097
-
1098
-
1099
- def test_parse_ookla_json_reads_the_speeds():
1100
- r = parse_ookla_json(RAW)
1101
- assert r.download_mbps == pytest.approx(91.234567, rel=1e-4)
1102
- assert r.upload_mbps == pytest.approx(4.56789, rel=1e-4)
1103
- assert r.ping_ms == 12 and r.server == "Mumbai" and r.isp == "Example ISP"
1104
-
1105
-
1106
- def test_parse_ookla_json_rejects_rubbish():
1107
- with pytest.raises(ValueError):
1108
- parse_ookla_json("not json")
1109
- with pytest.raises(ValueError):
1110
- parse_ookla_json("{}")
1111
-
1112
-
1113
- def test_format_speed_shows_the_numbers():
1114
- text = format_speed(parse_ookla_json(RAW))
1115
- assert "91.23" in text and "4.57" in text and "12" in text
1116
- assert "Mumbai" in text and "Example ISP" in text
1117
- ```
1118
-
1119
- - [ ] **Step 2: Run the tests to verify they fail**
1120
-
1121
- Run: `pytest tests/test_speedtest.py -v`
1122
- Expected: FAIL — `ModuleNotFoundError: No module named 'bot.speedtest'`
1123
-
1124
- - [ ] **Step 3: Implement `bot/speedtest.py`**
1125
-
1126
- `parse_ookla_json` loads the JSON, reads `download.bandwidth` and `upload.bandwidth`
1127
- (bits per second) and divides by 1_000_000 for Mbps, reads `ping.iqm` for the latency in
1128
- milliseconds, and joins `server.name` with `server.country` for the server label. It raises
1129
- `ValueError` when the JSON is unparsable or when the `download` or `upload` keys are missing.
1130
-
1131
- `run_speedtest` uses `asyncio.create_subprocess_exec` with the argument list
1132
- `[binary, "--accept-license", "--accept-gdpr", "--simple", "--format=json"]`, captures the
1133
- stdout, waits with `asyncio.wait_for(..., timeout)` and parses the result. A non-zero exit
1134
- code raises `RuntimeError` with the tail of stderr.
1135
-
1136
- `format_speed` returns a message with the download speed, the upload speed, the ping, the
1137
- server and the ISP — two decimals on the speeds, the ping as an integer.
1138
-
1139
- - [ ] **Step 4: Run the tests to verify they pass**
1140
-
1141
- Run: `pytest tests/test_speedtest.py -v`
1142
- Expected: PASS (3 tests)
1143
-
1144
- - [ ] **Step 5: Add the command to `bot/app.py`**
1145
-
1146
- `cmd_speed(event)`: reply "Testing your connection…", run `run_speedtest` in a thread
1147
- (`asyncio.to_thread` is not enough because it is async — use `await run_speedtest(...)`),
1148
- then edit the message with `format_speed(result)`. Catch `FileNotFoundError` and reply
1149
- "The speedtest tool is not installed in this container." and `asyncio.TimeoutError` and reply
1150
- "The speed test timed out."
1151
-
1152
- Add `("speed", "Check the bot's internet speed")` to the `COMMANDS` list.
1153
-
1154
- - [ ] **Step 6: Install the Ookla CLI in the `Dockerfile`**
1155
-
1156
- Add `curl` to the existing `apt-get install` line, then add a new `RUN` layer:
1157
-
1158
- ```dockerfile
1159
- RUN curl -sSL https://s3.amazonaws.com/ookla.speedtest.net/tools/cli/ookla-speedtest-1.2.0-linux-x86_64.tgz -o /tmp/ookla.tgz \
1160
- && tar -xzf /tmp/ookla.tgz -C /tmp \
1161
- && install -m 0755 /tmp/ookla-speedtest /usr/local/bin/ookla-speedtest \
1162
- && rm -rf /tmp/ookla.tgz /tmp/ookla-speedtest
1163
- ```
1164
-
1165
- - [ ] **Step 7: Run the whole suite**
1166
-
1167
- Run: `pytest -q`
1168
- Expected: everything passes
1169
-
1170
- - [ ] **Step 8: Commit**
1171
-
1172
- ```bash
1173
- git add bot/speedtest.py bot/app.py tests/test_speedtest.py Dockerfile
1174
- git commit -m "feat: /speed using the Ookla CLI"
1175
- ```
1176
-
1177
- ---
1178
-
1179
- ### Task 9: `/mediainfo` with a telegra.ph page
1180
-
1181
- **Files:**
1182
- - Create: `bot/telegraph.py`
1183
- - Create: `tests/test_telegraph.py`
1184
- - Modify: `bot/media.py` (add `probe_full`)
1185
- - Modify: `bot/app.py` (the command)
1186
- - Modify: `tests/test_app.py` (the handler)
1187
-
1188
- **Interfaces:**
1189
- - Consumes: `db.kv_get` / `db.kv_set` from Task 2 for the cached access token; `aiohttp` for the HTTP calls.
1190
- - Produces:
1191
- - `telegraph.TELEGRAPH_API` — `"https://api.telegra.ph"`.
1192
- - `telegraph.build_page(probe: Dict[str, Any], name: str) -> str` — the HTML body of the page.
1193
- - `telegraph.Telegraph` — a class with `async def create_account(self) -> str` and `async def upload_page(self, title: str, content: List[str]) -> str` (returns the page URL).
1194
- - `media.probe_full(path: Path | str, cancel=None) -> Dict[str, Any]` — the raw ffprobe JSON with format, streams, chapters and programs.
1195
-
1196
- - [ ] **Step 1: Write the failing tests**
1197
-
1198
- Create `tests/test_telegraph.py`:
1199
-
1200
- ```python
1201
- from bot.telegraph import build_page
1202
-
1203
- PROBE = {
1204
- "format": {"filename": "movie.mkv", "format_name": "matroska,webm", "duration": "120.0",
1205
- "size": "1048576", "bit_rate": "8000000",
1206
- "tags": {"title": "Movie Title"}},
1207
- "streams": [
1208
- {"index": 0, "codec_type": "video", "codec_name": "h264", "width": 1920, "height": 1080,
1209
- "r_frame_rate": "24/1", "bit_rate": "7000000", "disposition": {"default": 1}},
1210
- {"index": 1, "codec_type": "audio", "codec_name": "aac", "channels": 2, "sample_rate": "48000",
1211
- "tags": {"language": "eng", "title": "English"}},
1212
- {"index": 2, "codec_type": "subtitle", "codec_name": "subrip",
1213
- "tags": {"language": "eng"}},
1214
- ],
1215
- "chapters": [{"id": 0, "start_time": "0.0", "end_time": "60.0", "tags": {"title": "Intro"}}],
1216
- }
1217
-
1218
-
1219
- def test_build_page_lists_the_format_the_streams_and_the_chapters():
1220
- html = build_page(PROBE, "movie.mkv")
1221
- assert "movie.mkv" in html and "matroska" in html
1222
- assert "h264" in html and "1920x1080" in html
1223
- assert "aac" in html and "English" in html
1224
- assert "subrip" in html
1225
- assert "Intro" in html
1226
- assert "Movie Title" in html
1227
- assert "<pre" in html and "raw" not in html.lower()[:0] # the raw JSON is included
1228
-
1229
-
1230
- def test_build_page_survives_an_empty_probe():
1231
- html = build_page({}, "nothing.mkv")
1232
- assert "nothing.mkv" in html
1233
- ```
1234
-
1235
- - [ ] **Step 2: Run the tests to verify they fail**
1236
-
1237
- Run: `pytest tests/test_telegraph.py -v`
1238
- Expected: FAIL — `ModuleNotFoundError: No module named 'bot.telegraph'`
1239
-
1240
- - [ ] **Step 3: Implement `bot/telegraph.py`**
1241
-
1242
- `build_page` returns an HTML fragment (Telegraph accepts an array of HTML strings) with:
1243
-
1244
- 1. A summary paragraph: the file name, the container, the duration, the size, the bit rate and
1245
- the format tags.
1246
- 2. One paragraph per stream: the index, the type, the codec, the language, the resolution or
1247
- the channel count, the sample rate, the bit rate, the title and the disposition flags.
1248
- 3. A paragraph per chapter.
1249
- 4. A `<pre><code>` block with the raw ffprobe JSON, HTML-escaped, truncated to 100 000
1250
- characters.
1251
-
1252
- Every value that comes from the file must be HTML-escaped.
1253
-
1254
- `Telegraph` uses `aiohttp.ClientSession` to POST to `{TELEGRAPH_API}/createAccount` with
1255
- `{"short_name": "autorename", "author_name": "Auto Rename Bot"}` and then to
1256
- `{TELEGRAPH_API}/uploadPage` with `{"access_token": ..., "title": ..., "content": [...],
1257
- "author_name": ...}`. Both raise `RuntimeError` with the API's `error` field when the response
1258
- is not `ok`.
1259
-
1260
- - [ ] **Step 4: Run the tests to verify they pass**
1261
-
1262
- Run: `pytest tests/test_telegraph.py -v`
1263
- Expected: PASS (2 tests)
1264
-
1265
- - [ ] **Step 5: Add `probe_full` to `bot/media.py`**
1266
-
1267
- ```python
1268
- async def probe_full(path: Path | str, cancel: Optional[asyncio.Event] = None) -> Dict[str, Any]:
1269
- """The raw ffprobe JSON: format, streams, chapters and programs."""
1270
- ```
1271
-
1272
- It runs `ffprobe -v quiet -print_format json -show_format -show_streams -show_chapters
1273
- -show_programs` and returns the parsed JSON, or `{}` when it cannot be parsed.
1274
-
1275
- - [ ] **Step 6: Add the command to `bot/app.py`**
1276
-
1277
- `cmd_mediainfo(event)`: reply to a file with `/mediainfo`. Download the whole file to a temp
1278
- dir, run `media.probe_full`, build the page, and upload it:
1279
-
1280
- 1. Read the access token from `db.kv_get("telegraph_token")`.
1281
- 2. If there is no token, call `Telegraph.create_account()`, store it with
1282
- `db.kv_set("telegraph_token", token)` and use it.
1283
- 3. `url = await Telegraph(token).upload_page(title, [build_page(probe, name)])`.
1284
- 4. Reply with the link: "📋 <b>Media info:</b> <a href=\"<url>\">open it</a>".
1285
-
1286
- If the upload fails, reply with the readable summary in the chat instead of the link, so the
1287
- command is never a dead end. Clean up the temp dir in a `finally` block.
1288
-
1289
- - [ ] **Step 7: Run the whole suite**
1290
-
1291
- Run: `pytest -q`
1292
- Expected: everything passes
1293
-
1294
- - [ ] **Step 8: Commit**
1295
-
1296
- ```bash
1297
- git add bot/telegraph.py bot/media.py bot/app.py tests/test_telegraph.py tests/test_app.py
1298
- git commit -m "feat: /mediainfo published to telegra.ph"
1299
- ```
1300
-
1301
- ---
1302
-
1303
- ### Task 10: `/smedia` and removing `START_IMAGE`
1304
-
1305
- **Files:**
1306
- - Modify: `bot/config.py` (drop the `start_image` field)
1307
- - Modify: `bot/app.py` (the command, the animation handler, `/start`)
1308
- - Modify: `bot/panels.py` (the `/smedia` panel)
1309
- - Modify: `.env.example` (drop `START_IMAGE`)
1310
- - Modify: `tests/test_app.py` (rewrite the start-image test, add the `/smedia` tests)
1311
- - Modify: `tests/test_config.py` if it exists (drop the `START_IMAGE` assertions)
1312
-
1313
- **Interfaces:**
1314
- - Consumes: `db.get_start_media` / `set_start_media` / `clear_start_media` from Task 2.
1315
- - Produces:
1316
- - `panels.smedia_panel(kind: Optional[str], has_media: bool) -> Panel` — the owner panel.
1317
- - The `smedia` owner command.
1318
-
1319
- - [ ] **Step 1: Write the failing tests**
1320
-
1321
- In `tests/test_app.py`, **replace** `test_start_image_is_sent_as_a_photo_with_the_panel_as_caption`
1322
- with:
1323
-
1324
- ```python
1325
- async def test_start_sends_the_stored_banner(ctx):
1326
- await ctx.db.set_start_media("image", jpeg())
1327
- ev = await say(ctx, OWNER, "/start")
1328
- assert ev.out == []
1329
- (chat, file, kw), = ctx.client.files
1330
- assert chat == OWNER and "Auto Rename Bot" in kw["caption"]
1331
- assert kw["buttons"] and kw["parse_mode"] == "html"
1332
-
1333
-
1334
- async def test_start_falls_back_to_text_when_there_is_no_banner(ctx):
1335
- ev = await say(ctx, OWNER, "/start")
1336
- assert "Auto Rename Bot" in ev.texts[0] and ctx.client.files == []
1337
-
1338
-
1339
- async def test_smedia_is_owner_only(ctx):
1340
- ev = await say(ctx, ADMIN_CFG, "/smedia")
1341
- assert "owner" in ev.texts[0].lower()
1342
-
1343
-
1344
- async def test_smedia_stores_an_image_and_sends_it_on_start(ctx):
1345
- ev = await say(ctx, OWNER, "/smedia")
1346
- assert "smedia:image" in data_of(ev.out[0][1])
1347
- await click(ctx, OWNER, "smedia:image")
1348
- assert ctx.app.pending[OWNER].kind == "smedia_image"
1349
- photo = FakeEvent(OWNER, message=FakeMessage(mid=70, photo=object()), photo_bytes=jpeg())
1350
- await dispatch(ctx.client, photo)
1351
- media = await ctx.db.get_start_media()
1352
- assert media["kind"] == "image" and media["data"]
1353
- ```
1354
-
1355
- - [ ] **Step 2: Run the tests to verify they fail**
1356
-
1357
- Run: `pytest tests/test_app.py -v -k "smedia or start"`
1358
- Expected: FAIL — `/smedia` is not a command and `Config` still has `start_image`.
1359
-
1360
- - [ ] **Step 3: Remove `start_image` from `bot/config.py`**
1361
-
1362
- Delete the `start_image: Optional[str]` field from the `Config` dataclass and the
1363
- `start_image=env.get("START_IMAGE", "").strip() or None` argument from `load_config`.
1364
- Delete the `START_IMAGE` lines from `.env.example`.
1365
-
1366
- - [ ] **Step 4: Add the panel and the command to `bot/app.py`**
1367
-
1368
- `panels.smedia_panel(kind, has_media)` — the text explains that the banner is shown on
1369
- `/start` and the rows are `[("🖼 Image", "smedia:image"), ("🎞 GIF", "smedia:gif")]` plus
1370
- `[("👁 View", "smedia:view"), ("🗑 Delete", "smedia:del")]` when a banner is stored.
1371
-
1372
- `cmd_smedia(event)` is **owner-only**: add `"smedia"` to `OWNER_COMMANDS` in `bot/app.py` so
1373
- the existing `_guard` enforces it.
1374
-
1375
- The `smedia:view` callback sends the stored banner back to the owner as a photo (or an
1376
- animation) with a "🗑 Delete" button. The `smedia:del` callback deletes it and re-renders the
1377
- panel. Both must be no-ops (with a toast) when no banner is stored.
1378
-
1379
- - [ ] **Step 5: Implement the `/smedia` flow in `bot/app.py`**
1380
-
1381
- The `smedia:image` callback sets `Pending(kind="smedia_image")`; `smedia:gif` sets
1382
- `Pending(kind="smedia_gif")`. Both ask the owner to send the file.
1383
-
1384
- `on_photo`: when the pending kind is `smedia_image`, store the re-encoded JPEG with
1385
- `db.set_start_media("image", data)` instead of the global thumbnail, and reply "✅ Start
1386
- image saved."
1387
-
1388
- A new `on_animation` handler (registered in `_register`) catches
1389
- `events.NewMessage(incoming=True, func=lambda e: e.is_private and e.message.gif is not None)`.
1390
- When the pending kind is `smedia_gif`, it stores the raw bytes with
1391
- `db.set_start_media("gif", data)`. **A GIF must never be re-encoded** — the bytes are stored
1392
- exactly as they arrived so it still animates.
1393
-
1394
- When the pending kind is `smedia_gif` but the file is not an animation, reply "That's not a
1395
- GIF — send me an animated one." and keep the pending.
1396
-
1397
- `cmd_start`: read `db.get_start_media()`. When there is a banner, send it with
1398
- `self.client.send_file(event.chat_id, io.BytesIO(data), caption=text, buttons=..., parse_mode="html", attributes=attrs)`
1399
- where `attrs` is `[types.DocumentAttributeFilename("start.jpg")]` for an image and
1400
- `[types.DocumentAttributeFilename("start.gif"), types.DocumentAttributeAnimated()]` for a
1401
- GIF. When there is no banner, send the text panel as today.
1402
-
1403
- - [ ] **Step 6: Run the tests to verify they pass**
1404
-
1405
- Run: `pytest tests/test_app.py -v -k "smedia or start"`
1406
- Expected: PASS
1407
-
1408
- - [ ] **Step 7: Run the whole suite**
1409
-
1410
- Run: `pytest -q`
1411
- Expected: everything passes, except any test that still references `cfg.start_image` — search
1412
- for `start_image` and remove those references.
1413
-
1414
- - [ ] **Step 8: Commit**
1415
-
1416
- ```bash
1417
- git add bot/config.py bot/app.py bot/panels.py .env.example tests/test_app.py
1418
- git commit -m "feat: /smedia replaces the START_IMAGE environment variable"
1419
- ```
1420
-
1421
- ---
1422
-
1423
- ### Task 11: Command menu, help panel and README
1424
-
1425
- **Files:**
1426
- - Modify: `bot/app.py` (the `COMMANDS` list)
1427
- - Modify: `bot/panels.py` (the help panel)
1428
- - Modify: `README.md`
1429
-
1430
- **Interfaces:**
1431
- - Consumes: everything from Tasks 1–10.
1432
- - Produces: the final command menu and documentation.
1433
-
1434
- - [ ] **Step 1: Update the `COMMANDS` list in `bot/app.py`**
1435
-
1436
- Add these entries to the `COMMANDS` list, keeping the existing ones:
1437
-
1438
- ```python
1439
- ("rename", "Pick a saved format and rename a file"),
1440
- ("formats", "Manage your saved rename formats"),
1441
- ("sequence", "Collect files to sort by episode"),
1442
- ("sort", "Sort the collected files and send them back"),
1443
- ("check", "See what the bot reads from a file"),
1444
- ("ethumb", "Extract a thumbnail from a video"),
1445
- ("resize", "Resize an image (reply to it)"),
1446
- ("speed", "Check the bot's internet speed"),
1447
- ("mediainfo", "Full media info of a file, published to telegra.ph"),
1448
- ("smedia", "Set the start image or GIF (owner only)"),
1449
- ```
1450
-
1451
- Telegram allows at most 100 commands; the list stays well under that.
1452
-
1453
- - [ ] **Step 2: Update the help panel in `bot/panels.py`**
1454
-
1455
- Rewrite `help_panel()` to describe the new flow:
1456
-
1457
- 1. Send a file — it is renamed with your template and sent back.
1458
- 2. `/rename` — pick one of your saved formats, then send the file.
1459
- 3. `/formats` — create, edit and delete your saved formats.
1460
- 4. `/template` and `/caption` — the default naming and caption templates.
1461
- 5. `/mode` — read the details from the file name or the caption.
1462
- 6. `/check` — reply to a file to see what the bot reads from it.
1463
- 7. `/sequence` then `/sort` — collect files, sort them by episode, get them back in order.
1464
- 8. `/metadata`, `/keepaudio`, `/keepsubtitle` — tags and track clean-up.
1465
- 9. `/filetype` — send as document, video or audio.
1466
- 10. `/ethumb` and `/resize` — thumbnails and images.
1467
- 11. `/speed` and `/mediainfo` — the bot's line speed and a file's media info.
1468
- 12. `/queue` and `/cancel` — manage running jobs.
1469
-
1470
- Keep the template-variables line at the bottom.
1471
-
1472
- - [ ] **Step 3: Update the `README.md`**
1473
-
1474
- - The features table: add rows for the saved formats, the sequence sorting, `/check`,
1475
- `/ethumb`, `/resize`, `/speed`, `/mediainfo` and `/smedia`.
1476
- - The commands table: add the new commands and change the `/rename` row to
1477
- "Pick a saved format, then send the file".
1478
- - The settings paragraph: remove `START_IMAGE` from the list of environment variables and say
1479
- the start banner is set with `/smedia`.
1480
- - The test count line: update it to the new number of tests.
1481
-
1482
- - [ ] **Step 4: Run the whole suite**
1483
-
1484
- Run: `pytest -q`
1485
- Expected: everything passes
1486
-
1487
- - [ ] **Step 5: Commit**
1488
-
1489
- ```bash
1490
- git add bot/app.py bot/panels.py README.md
1491
- git commit -m "docs: document the new commands and the format workflow"
1492
- ```
1493
-
1494
- ---
1495
-
1496
- ## Self-review
1497
-
1498
- **Spec coverage.** Every section of the spec maps to a task: the format storage and the
1499
- tri-state → Task 2 and Task 3; the picker, the editor and the enqueue-time overlay → Task 4;
1500
- `/sequence` and `/sort` → Task 5; `/check` and the small caps → Task 1 and Task 6;
1501
- `/ethumb` and `/resize` → Task 7; `/speed` and the Ookla install → Task 8; `/mediainfo` and
1502
- Telegraph → Task 9; `/smedia` and the removal of `START_IMAGE` → Task 10; the command menu
1503
- and the documentation → Task 11.
1504
-
1505
- **Step scan.** Every step names a file, a signature or a test, and an exact expectation.
1506
- The code blocks that remain are the ones the tests do not fully determine: the small-caps
1507
- table, the sort key, the Ookla argument list, the callback data scheme and the Dockerfile
1508
- layer.
1509
-
1510
- **Type consistency.** `effective_info` is defined in Task 1 and consumed in Tasks 5 and 6.
1511
- The format dict shape is defined in Task 2 and used by Tasks 3, 4 and 8. `resolve_format`
1512
- and `resolve_thumbnail` are defined in Task 3 and used by Task 4. `plan_sort` and
1513
- `detect_episodes` are defined in Task 5 and used by Task 5's handler. `Job.thumbnail_mode`,
1514
- `Job.thumbnail` and `Job.settings_override` are defined in Task 4 and read in Task 4's
1515
- pipeline change.
1516
-
1517
- **Review Focus.** All five lines are pinned by a named test: the format template reaching
1518
- the name (Task 4, `test_a_formats_template_reaches_the_file_name`), the explicitly-empty
1519
- thumbnail (Task 4, `test_a_format_can_suppress_the_thumbnail_entirely`), the file with no
1520
- episode surviving a sort (Task 5, `test_a_file_without_an_episode_is_kept_and_reported`),
1521
- the open sequence suppressing auto-rename (Task 5,
1522
- `test_an_open_sequence_collects_files_instead_of_renaming_them`), and the deleted format not
1523
- breaking a queued job (Task 4, `test_deleting_a_format_does_not_break_a_queued_job`).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/superpowers/specs/2026-10-20-media-tools-and-formats-design.md DELETED
@@ -1,522 +0,0 @@
1
- # Media Tools & Named Formats — Design
2
-
3
- Date: 2026-10-20
4
- Project: `autorename-bot` (Telegram auto-rename bot, Python + Telethon + ffmpeg)
5
-
6
- ## Intent and the why
7
-
8
- The bot has grown into a personal toolbelt for a channel admin who renames, tags and
9
- repackages a lot of media. Three things are in the way:
10
-
11
- 1. **One global template.** Every file is renamed with a single template, so a One Piece
12
- batch and a Dragon Ball batch cannot be named differently without editing the setting
13
- mid-run and losing the previous one.
14
- 2. **No way to reorder a batch.** When a season arrives as 25 separate files, there is no
15
- way to see that episode 7 is missing, that episode 12 arrived twice, or to get the
16
- files back in episode order.
17
- 3. **The command set is thin.** There is no way to ask what the bot actually extracted
18
- from a file, no way to pull a thumbnail out of a video, no way to resize an image, no
19
- way to see the bot's line speed, and no way to inspect a file's real media properties.
20
- The start banner is baked into an environment variable, which means changing it means
21
- redeploying.
22
-
23
- This design adds a saved-format system, a collect-and-sort flow, an inspection command,
24
- and four small media tools, and moves the start banner into the database behind an
25
- owner-only command.
26
-
27
- ## Goals
28
-
29
- - Save up to 12 **named** formats per user, each carrying its own template, thumbnail,
30
- title, author, artist, audio, subtitle, video, keep-audio and keep-subtitle.
31
- - `/rename` asks which format to use, then asks for the file, then renames it.
32
- - Collect files, sort them by episode ascending, and report missing and duplicate
33
- episodes.
34
- - Show exactly what the bot can extract from a file's name and from its caption.
35
- - Extract a thumbnail from a video, and resize an image.
36
- - Report the bot's internet speed with the Ookla CLI.
37
- - Publish a full media-info report to telegra.ph and return the link.
38
- - Replace the `START_IMAGE` environment variable with an owner-only `/smedia` command
39
- that accepts an image **or** a GIF.
40
-
41
- ## Non-goals
42
-
43
- - Batch renaming many files in one command. `/rename` handles exactly one file per
44
- invocation.
45
- - Reordering the saved formats in the list. They keep creation order.
46
- - Re-encoding video. Every existing rename path stays stream-copy.
47
- - Any kind of "forwarded from" attribution on sorted output — copies, not forwards.
48
- - Multi-user or shared formats. Each allowed user has their own 12.
49
-
50
- ## Anti-goals
51
-
52
- The change must not slow down or destabilise the existing rename path. Anything that
53
- touches the hot path (download → probe → ffmpeg → upload) is additive: the format
54
- system plugs into it as an override, it does not replace it.
55
-
56
- ## Decisions already made (by the bot owner)
57
-
58
- | Area | Decision |
59
- |---|---|
60
- | `/check` fields | Title, Season, Episode, Quality. **No chapter field.** |
61
- | `/rename` | One file per invocation. After it renames, you start over with `/rename`. |
62
- | `/rename <text>` | **Removed.** `/rename` is now only the format picker. |
63
- | `/speed` | The **Ookla** CLI (`ookla-speedtest`), not the old `speedtest-cli` package. |
64
- | `/sort` output | The **actual files** are re-sent in sorted order. |
65
- | `/sort` mechanism | **Sent back with no "forwarded from" header**, one at a time so the order is exact. |
66
- | `/sort` originals | The original messages are **kept** in the chat. |
67
- | Format manager | **Button-driven**, not command-driven. |
68
- | Format scope | **Per-user**, 12 slots each. |
69
- | `/resize` input | **Reply to** an image. |
70
- | Formats are named | e.g. "One Piece format", "Dragon Ball format" — not "Format 1". |
71
- | `/rename` with no formats | Only "➕ New format" is offered. There is no global-template shortcut. |
72
- | `/check` caption section | Shows exactly what caption mode would produce, not a field-wise merge. |
73
-
74
- ## How it is made
75
-
76
- ### 1. Saved formats
77
-
78
- **Storage.** A new `formats` table — not the `users.settings` JSON blob, because each
79
- format carries a thumbnail (binary) and a thumbnail does not belong in a JSON column.
80
-
81
- ```
82
- formats
83
- id integer primary key autoincrement
84
- user_id bigint index, not null
85
- name varchar(64) not null
86
- position integer not null -- creation order, 0..11
87
- template text null -- null = inherit the user's global template
88
- thumb bytea null -- null = inherit, empty = no thumbnail, bytes = custom
89
- meta text not null -- JSON: {"title": null|"", "author": null|"", ...}
90
- keep_audio text null -- null = inherit, [] = keep all, ["english"] = only that
91
- keep_subtitle text null -- same tri-state
92
- unique (user_id, name)
93
- ```
94
-
95
- `id` is `INTEGER PRIMARY KEY`, not `BIGINT`: the default database and the whole test
96
- suite run on SQLite, and SQLite only auto-increments a column declared exactly
97
- `INTEGER PRIMARY KEY`. SQLAlchemy renders `BigInteger` as `BIGINT`, so an insert
98
- without an explicit id would fail on SQLite. Use `Integer` for the primary key, or
99
- `BigInteger().with_variant(Integer, "sqlite")`.
100
-
101
- **The tri-state.** Every format field has three possible states, and the distinction
102
- matters:
103
-
104
- - `null` — **inherit**: use the user's global setting for that field.
105
- - `""` / `[]` — **explicitly empty**: override the global setting with "nothing".
106
- This is how a format says "keep every track" (`keep_audio = []`) even when the
107
- user's global keep-audio list is `["english"]`.
108
- - a real value — **override**.
109
-
110
- Without the tri-state there is no way to say "this format keeps all tracks" when the
111
- global list is non-empty, and no way to say "this format has no title" when the global
112
- title is set.
113
-
114
- **Two fields are not tri-state, for good reason:**
115
-
116
- - The **template** is inherit-or-value only. An empty template is not a useful state:
117
- the global template rejects an empty value, and an empty template would render to
118
- nothing, leaving the file with its original name. The editor offers "use my default
119
- template" and a real template — never "no template".
120
- - The **thumbnail** is a genuine tri-state, because the thumbnail is not a settings
121
- field at all (it lives in `users.thumb`, not in the settings JSON). See below.
122
-
123
- **Applying a format.** The effective settings for a job are the user's global settings
124
- with the format's non-`null` fields overlaid on top.
125
-
126
- This overlay has to happen in **two** places, not one, and the file name is the reason
127
- the design originally got this wrong:
128
-
129
- - `App.enqueue` computes the new file name with `compute_new_name(...)` from the
130
- **global** settings, and puts the result in `Job.new_name`.
131
- - `Renamer.process` reads the settings again, but only for the metadata / track / file
132
- type / caption work — it never recomputes the name. It uploads `job.new_name` as it
133
- stands.
134
-
135
- So a format's template would never reach the file name if the overlay only happened
136
- inside `process()`. The overlay must be resolved **at enqueue time**, where the name is
137
- computed, and the resolved result travels on the job:
138
-
139
- - `Job` gains `settings_override: Optional[Dict[str, Any]]` — the **already-resolved**
140
- effective settings, not a `format_id`.
141
- - `App.enqueue` resolves the format into an effective settings dict, computes the new
142
- name from **that** dict, and stores both on the job.
143
- - `Renamer.process` becomes `s = merge_settings(global_settings, job.settings_override)`
144
- before it does anything else.
145
-
146
- Resolving at enqueue time also means a format deleted between enqueue and run cannot
147
- break a job already in the queue.
148
-
149
- **The thumbnail is resolved separately.** The global thumbnail is a blob in
150
- `users.thumb`, not a settings key, so `merge_settings` cannot carry it. The effective
151
- thumbnail is resolved on its own and passed to the pipeline alongside the settings
152
- override. Its three states:
153
-
154
- | State | Meaning | Sent with the file |
155
- |---|---|---|
156
- | inherit | use the user's global thumbnail | the global thumbnail; if there is none, a frame of the video |
157
- | custom | this format has its own poster | the format's image |
158
- | explicitly empty | no thumbnail at all | **nothing** — not even an automatic video frame |
159
-
160
- "Explicitly empty" therefore has to suppress the pipeline's existing video-frame
161
- fallback, and an empty byte string must never be handed to `send_file(thumb=...)`.
162
- This is a real change to the thumbnail block in `pipeline._run`, and getting it wrong
163
- silently changes the preview image on every renamed file.
164
-
165
- Fields a format does **not** carry fall back to the global settings: mode
166
- (filename/caption), file type, caption template, remove-words, target channel, and the
167
- metadata on/off switch.
168
-
169
- **Flow.**
170
-
171
- - `/formats` → a panel listing the user's formats as buttons (name, with a marker if the
172
- format overrides the global template), plus "➕ New format" when there is room, plus
173
- Back/Home.
174
- - `/rename` → the same list of format buttons. Picking one sets a pending state
175
- (`kind="rename_format"`, `format_id=…`) and the bot says "Send me the file to rename
176
- with **&lt;name&gt;**." The next document the user sends is renamed with that format.
177
- The pending state is cleared as soon as that file is queued, so a second file needs a
178
- fresh `/rename`.
179
- - **With no saved formats, `/rename` shows only "➕ New format".** There is no
180
- "global template" shortcut and no fallback: renaming goes through a saved format,
181
- full stop. The global template is not dead, though — it is the *default a new format
182
- starts from*, because a new format's template field begins in the "inherit" state
183
- and therefore resolves to the user's global template until they change it.
184
- - Creating a format: "➕ New format" → the bot asks for a name → the format editor
185
- panel opens.
186
- - The format editor shows one button per field (Template, Thumbnail, Title, Author,
187
- Artist, Audio, Subtitle, Video, Keep Audio, Keep Subtitle) plus Rename, Delete and
188
- Back. Tapping a field asks for its value as text (or a photo for the thumbnail) and
189
- returns to the editor. Every field offers a "clear / use default" option that sets
190
- the field to its explicit-empty or inherit state.
191
- - Deleting asks for confirmation, then removes the row.
192
- - The 12-slot limit is enforced: the "➕ New format" button is not shown at 12.
193
-
194
- **Thumbnail states.** A format's thumbnail is `null` (inherit the user's global
195
- thumbnail), empty bytes (send no thumbnail) or the stored JPEG. Sending a photo while
196
- the editor is open on the Thumbnail field sets it. A "use my default" button sets it
197
- back to `null`, and a "no thumbnail" button sets it to empty.
198
-
199
- ### 2. `/sequence` and `/sort`
200
-
201
- **Flow.**
202
-
203
- 1. `/sequence` → the bot replies "Send me the files to sort. When you're done, send
204
- `/sort`." with a "✖ Cancel" button.
205
- 2. Every file the user sends while a sequence is open is **collected, not renamed**.
206
- An open sequence suspends auto-rename for that user. The bot acknowledges each file
207
- with a running count.
208
- 3. `/sort` → the bot sorts the collected files, copies them back into the chat in
209
- episode order, and reports missing and duplicate episodes.
210
- 4. After a sort the sequence closes. The originals stay in the chat.
211
-
212
- **Suspending auto-rename.** Today every incoming document is renamed and re-uploaded
213
- (`App.on_media` → `App.enqueue`). While a sequence is open for that user, the same
214
- handler collects the file instead. This is a check at the top of the media handler
215
- against the open-sequence table — it must not fall through to the rename path.
216
-
217
- **Storage.** A `sequences` table so a sequence survives a bot restart (the bot's whole
218
- premise is that it runs on a free Space where the disk is wiped):
219
-
220
- ```
221
- sequences
222
- user_id bigint primary key
223
- items text not null -- JSON: [{"msg_id": 12, "name": "ep01.mkv", "caption": "..."}]
224
- copied integer not null -- how many files were already copied out
225
- updated_at timestamptz
226
- ```
227
-
228
- **Episode detection happens at sort time, not at collection time.** The caption is
229
- needed to merge with the filename in caption mode, so the caption is stored alongside
230
- the name. The user's mode is read once, when `/sort` runs. This means a mode change
231
- part-way through a batch applies to the whole batch — which is the sane behaviour —
232
- rather than producing a half-and-half batch.
233
-
234
- **Sorting.** Sort key is `(season, episode)` ascending, both zero-padded strings, so
235
- `S01E02` sorts before `S01E10`. Files with no episode keep their arrival order and sit
236
- after every numbered file.
237
-
238
- A file whose episode number cannot be found is **not** dropped. It is sorted to the
239
- end, in the order it was received, and listed separately in the report as "no episode
240
- number".
241
-
242
- **Missing and duplicates.**
243
-
244
- - *Missing* — for each season present, the episodes from the lowest to the highest
245
- number present, minus the ones actually there. Reported as `S01E03, S01E07`.
246
- - *Duplicates* — the same `(season, episode)` appearing more than once. Reported with
247
- the episode and the file names involved.
248
-
249
- Both are reported after the sort finishes, in one message, in this order: how many
250
- files were sorted, the missing list, the duplicate list, and the files that had no
251
- episode number. Any of those sections is omitted when empty.
252
-
253
- **Copying back.** The files are sent back without a "forwarded from" header. They go
254
- out **one at a time**, awaiting each one, with a short delay between them, because
255
- Telegram does not guarantee the order of a batch send. Each one is a `forward_messages`
256
- call with `drop_author=True` from the chat to itself — Telethon has no true copy, and
257
- dropping the author is what removes the header. **Telethon has no true copy**
258
- (that is Bot-API only), so the implementation uses `forward_messages` with
259
- `drop_author=True`, which drops the attribution and leaves no "forwarded from" header.
260
-
261
- The original messages are kept. If a copy fails part-way, the bot says how many were
262
- copied and leaves the sequence open so the user can retry — and the retry **resumes**
263
- from where it stopped rather than re-copying the files that already went out. The
264
- `copied` counter on the sequence row is what makes that possible.
265
-
266
- **The report.** Something like:
267
-
268
- ```
269
- ✅ Sorted 25 files.
270
-
271
- ⚠️ Missing: S01E03, S01E07
272
- ⚠️ Duplicates: S01E12 (episode 12.mkv, episode 12 (copy).mkv)
273
- ⚠️ No episode number: bonus.mkv, extras.mp4
274
- ```
275
-
276
- ### 3. `/check`
277
-
278
- Reply to a file with `/check` and the bot shows what it can extract, using the
279
- small-caps style the owner picked:
280
-
281
- ```
282
- Yᴏᴜ Cᴀɴ Cʰᴀɴɢᴇ Mᴏᴅᴇ Bʏ /mode
283
-
284
- FɪʟᴇNᴀᴍᴇ:
285
- ➦ Title ⭆ Dragon Ball Super Beerus
286
- ➦ Season ⭆ 01
287
- ➦ Episode ⭆ 02
288
- ➦ Quality ⭆ 1080p
289
-
290
- Cᴀᴘᴛɪᴏɴ Mᴏᴅᴇ:
291
- ➦ Title ⭆ Dragon Ball Super Beerus
292
- ➦ Season ⭆ 01
293
- ➦ Episode ⭆ 02
294
- ➦ Quality ⭆ 1080p
295
- ```
296
-
297
- - The **FileName** section is the parse of the file's name.
298
- - The **Caption Mode** section is **exactly what a rename in caption mode would use**
299
- for this file. That is the rule `compute_new_name` already applies, and it is not the
300
- same as a plain field-wise merge:
301
- - The caption is parsed. If that parse produced any structured field (season,
302
- episode, quality or year), the caption's values win and the filename fills in only
303
- the fields the caption did not supply.
304
- - If the caption produced **no** structured field, the caption is discarded entirely
305
- and the filename parse is used unchanged — including its title.
306
- So for a file named `ep01.mkv` with the caption "My Show", the Caption Mode section
307
- shows the title from the *filename*, not "My Show". That is the point of the command:
308
- it shows the two modes' real output, so the user can tell whether switching modes
309
- would change anything.
310
- - Section headings are small-capped; the field names (Title, Season, …) and the values
311
- are normal.
312
- - **Year** and **Codec** lines appear only when the parser found them, so a file
313
- without them produces exactly the output above.
314
-
315
- The mode-aware merge must be one shared helper, used by `/check` and by `/sort`'s
316
- episode detection, so the two can never disagree with what a rename would do.
317
-
318
- **Small caps.** A new helper maps a string to the Unicode small-capital letters and
319
- leaves anything unmapped alone:
320
-
321
- ```
322
- a→ᵃ b→ʙ c→ᴄ d→ᴅ e→ᴇ f→ꜰ g→ɢ h→ʜ i→ɪ j→ᴊ k→ᴋ l→ʟ m→ᴍ
323
- n→ɴ o→ᴏ p→ᴘ q→ǫ r→ʀ s→s t→ᴛ u→ᴜ v→ᴠ w→ᴡ x→x y→ʿ z→ᴢ
324
- ```
325
-
326
- Uppercase letters map to the same glyphs as lowercase, which is what makes
327
- "Yᴏᴜ" out of "Your". Digits, spaces and punctuation are untouched. The helper is
328
- pure and unit-tested.
329
-
330
- ### 4. `/ethumb`
331
-
332
- Reply to a video or media file with `/ethumb`:
333
-
334
- 1. The bot downloads the file to a temp dir.
335
- 2. `ffmpeg` grabs a frame at ~10% of the duration (the same seek point the automatic
336
- video thumbnail uses) at the source width, written as a JPEG.
337
- 3. The bot sends the image back as a photo, with a "Set as my thumbnail" button and a
338
- "Close" button.
339
-
340
- "Set as my thumbnail" stores the image as the user's global thumbnail, resized down to
341
- Telegram's limits by the existing `media.prepare_thumb` helper. A format's thumbnail
342
- is set by sending a photo while the format editor is open — `/ethumb` does not edit
343
- formats.
344
-
345
- If the file has no video stream (an audio file, an image), the bot says so rather than
346
- failing silently.
347
-
348
- ### 5. `/resize`
349
-
350
- Reply to an image with `/resize`:
351
-
352
- 1. The bot replies with size buttons: **320**, **640**, **1280**, **Original**,
353
- **Custom**, plus Close.
354
- 2. Picking a preset resizes immediately. **Custom** asks for a size: a single number is
355
- a maximum width and keeps the aspect ratio; `WIDTHxHEIGHT` forces that exact size.
356
- (This replaces the "force exact size" toggle discussed earlier — the shape of what
357
- you type decides.)
358
- 3. The resized image is sent back as a photo with a "Set as my thumbnail" button.
359
-
360
- Resizing re-encodes the image; that is inherent to resizing and is the one place in
361
- this design where bytes are rewritten. Pillow is already a dependency.
362
-
363
- Both `/resize` and `/ethumb` accept an image sent as a photo **or** as a document.
364
-
365
- ### 6. `/speed`
366
-
367
- The Ookla CLI is installed in the Dockerfile and run as a subprocess:
368
-
369
- ```
370
- ookla-speedtest --accept-license --accept-gdpr --simple --format=json
371
- ```
372
-
373
- - `--accept-license` and `--accept-gdpr` are passed so the tool never blocks on a
374
- prompt.
375
- - `--simple` skips the server scan and latency test, so a run takes ~15–30 seconds
376
- instead of a minute.
377
- - `--format=json` makes the output machine-readable. A pure function parses that JSON
378
- into the values shown in the chat, so the parsing is unit-tested without a network.
379
-
380
- The bot replies with a "Testing your connection…" message first, then reports download
381
- speed, upload speed, ping, the test server and the ISP. A timeout of 120 seconds
382
- applies. If the binary is missing (a local run without the Docker image), the bot says
383
- so clearly instead of showing a stack trace.
384
-
385
- **Dockerfile change.** `python:3.12-slim` has no `curl`, so it is added to the apt
386
- install line, and the Ookla tarball is unpacked to `/usr/local/bin/ookla-speedtest`:
387
-
388
- ```dockerfile
389
- RUN apt-get update \
390
- && apt-get install -y --no-install-recommends ffmpeg ca-certificates curl \
391
- && rm -rf /var/lib/apt/lists/*
392
-
393
- RUN curl -sSL https://s3.amazonaws.com/ookla.speedtest.net/tools/cli/ookla-speedtest-1.2.0-linux-x86_64.tgz -o /tmp/ookla.tgz \
394
- && tar -xzf /tmp/ookla.tgz -C /tmp \
395
- && install -m 0755 /tmp/ookla-speedtest /usr/local/bin/ookla-speedtest \
396
- && rm -rf /tmp/ookla.tgz /tmp/ookla-speedtest
397
- ```
398
-
399
- The tarball is x86-64 only, which matches the Space's architecture. The version is
400
- pinned so a rebuild does not silently change the tool.
401
-
402
- ### 7. `/mediainfo`
403
-
404
- Reply to a file with `/mediainfo`:
405
-
406
- 1. The bot downloads the file (whole file — the point of this command is complete
407
- information) and runs `ffprobe` with `-show_format -show_streams -show_chapters
408
- -show_programs`.
409
- 2. The raw JSON and a readable summary are turned into an HTML page.
410
- 3. The page is uploaded to telegra.ph and the bot replies with the link.
411
-
412
- **Telegraph.** No new dependency: `aiohttp` is already installed, so a small
413
- `bot/telegraph.py` posts directly to the public API — `createAccount` to get an access
414
- token, then `uploadPage`. The access token is cached in the `kv` table so a new
415
- account is not created for every request. If telegra.ph is unreachable from the host,
416
- the bot says so and shows the readable summary in the chat instead, so the command is
417
- never a dead end.
418
-
419
- **Page content.** A summary section (container, duration, size, bitrate, tags), a
420
- stream table (index, type, codec, language, resolution, channels, sample rate, bitrate,
421
- title, disposition flags), the chapters, and finally the raw ffprobe JSON in a `<pre>`
422
- block. Raw JSON is truncated if it would exceed roughly 100 KB.
423
-
424
- ### 8. `/smedia` and removing `START_IMAGE`
425
-
426
- `/smedia` is **owner-only** (it joins `addadmin`, `deladmin`, `admins` in
427
- `OWNER_COMMANDS`).
428
-
429
- 1. `/smedia` → the bot replies with "🖼 Image" and "🎞 GIF" buttons, plus "👁 View" and
430
- "🗑 Delete" for the current banner.
431
- 2. Picking Image or GIF asks the owner to send the file. The next photo, animation or
432
- document that arrives is stored.
433
- 3. The banner is stored in a new `bot_media` table (one row):
434
-
435
- ```
436
- bot_media
437
- id integer primary key -- always 1
438
- kind varchar(8) -- 'image' | 'gif'
439
- data bytea not null
440
- ```
441
-
442
- 4. The received file is **validated against the kind that was chosen**: picking GIF
443
- and sending a JPEG is rejected with a clear message ("that's a photo — send me an
444
- animated GIF"), and the stored `kind` always matches the bytes that were stored. A
445
- JPEG stored as `kind='gif'` would be sent back as an animation with a still first
446
- frame, so the mismatch is caught rather than accepted.
447
- 5. A photo is re-encoded to JPEG for storage. A GIF's bytes are stored exactly as
448
- they were sent, so it still animates.
449
- 6. `/start` sends the stored banner instead of the environment variable. A GIF is sent
450
- with `DocumentAttributeAnimated` so Telegram renders it as an animation; an image is
451
- sent as a photo.
452
-
453
- **Removal.** `START_IMAGE` is deleted from `bot/config.py` (the `Config` dataclass
454
- field and the `load_config` call), from `.env.example` and from the README. The
455
- banner now survives a restart because it lives in the database.
456
-
457
- ## What the builder has to decide, and what is already decided
458
-
459
- **Already decided (do not re-litigate):** the flow of every command above, the tri-state
460
- inheritance model, copy-not-forward for `/sort`, originals kept, per-user formats, the
461
- 12-slot limit, Ookla for `/speed`, no chapter field in `/check`, `/rename <text>`
462
- removed, `/resize` replies to an image.
463
-
464
- **Left to the builder (any sensible choice is fine):**
465
-
466
- - The exact layout of the new panels — button order, wording, how the format editor
467
- presents a field that is currently inheriting.
468
- - Where the new code lives: the design names `bot/formats.py`, `bot/sequence.py`,
469
- `bot/speedtest.py` and `bot/telegraph.py` as suggestions, not requirements. Keep new
470
- pure logic out of `app.py`, which is already 774 lines.
471
- - Whether the small-caps helper lives in `utils.py` or its own module.
472
- - The exact wording of every user-facing string, beyond the `/check` layout that is
473
- given above.
474
- - How the `ffmpeg` frame-extraction call is factored — `media.py` already has
475
- `make_video_thumbnail`, which grabs a 320px-wide frame; the `/ethumb` version needs
476
- the source width, so it is a new function rather than a changed one.
477
-
478
- ## Constraints
479
-
480
- - Telegram: bot uploads cap at 2000 MB (`MAX_FILE_MB`); a chat message is 4096
481
- characters; an inline-button callback payload is 64 bytes; inline keyboards carry at
482
- most 100 buttons.
483
- - The bot only reacts in private chats. Unknown users are ignored.
484
- - The Space is free-tier CPU with 50 GB of scratch disk. `/mediainfo` and `/ethumb`
485
- download whole files, so they respect the same size limit as a rename.
486
- - The existing test suite (266 tests) must keep passing and must not need the network.
487
-
488
- ## Risks
489
-
490
- - **Copying 25 files in order** needs 25 API calls. Telegram may rate-limit; the copy
491
- loop must handle `FloodWaitError` by waiting, and must not reorder the output.
492
- - **telegra.ph** may be blocked or slow from some hosts; the fallback to a chat
493
- summary covers it.
494
- - **Ookla** needs outbound access to the speedtest servers and moves a few tens of
495
- megabytes; it is a manual command, never automatic.
496
- - **A format's thumbnail** is stored per format; 12 formats × a JPEG each is small, but
497
- the table is binary and must not be loaded into the settings cache.
498
-
499
- ## Testing
500
-
501
- New tests, no network:
502
-
503
- - Format CRUD, the 12-slot limit, name uniqueness, and the tri-state overlay
504
- producing the expected effective settings.
505
- - Sorting: ordering across seasons, zero-padded episode order, missing-episode
506
- detection, duplicate detection, and files with no episode number.
507
- - The small-caps mapping, including the exact `/check` layout.
508
- - The size parser for `/resize` (`800`, `800x450`, invalid input).
509
- - The Ookla JSON parser.
510
- - The Telegraph page builder.
511
-
512
- The existing 266 tests must still pass, **except** the ones that cover behaviour this
513
- design deliberately removes. Those are rewritten to cover the new behaviour:
514
-
515
- - `test_rename_command_uses_the_given_name_and_keeps_the_extension` and
516
- `test_rename_usage_messages` assert the removed `/rename <text>` flow → rewritten
517
- for the format picker.
518
- - `test_start_image_is_sent_as_a_photo_with_the_panel_as_caption` builds its config
519
- with `dataclasses.replace(ctx.cfg, start_image=...)`, which raises `TypeError` once
520
- the `Config` field is gone → rewritten for the database-stored banner.
521
-
522
- "Still passing" means no regressions outside the deliberately removed features.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/conftest.py CHANGED
@@ -41,17 +41,6 @@ def ffprobe_json(path, *extra):
41
  return json.loads(out)
42
 
43
 
44
- @pytest.fixture
45
- async def db(tmp_path):
46
- """A fresh SQLite database for the tests that need one."""
47
- from bot.db import Database
48
-
49
- database = Database(f"sqlite+aiosqlite:///{tmp_path}/t.db")
50
- await database.init()
51
- yield database
52
- await database.close()
53
-
54
-
55
  @pytest.fixture(scope="session")
56
  def media_dir(tmp_path_factory):
57
  d = tmp_path_factory.mktemp("media")
 
41
  return json.loads(out)
42
 
43
 
 
 
 
 
 
 
 
 
 
 
 
44
  @pytest.fixture(scope="session")
45
  def media_dir(tmp_path_factory):
46
  d = tmp_path_factory.mktemp("media")
tests/test_app.py CHANGED
@@ -1,5 +1,6 @@
1
  """Handler-level tests: real Telethon event builders, fake events and client."""
2
  import asyncio
 
3
  import io
4
  import shutil
5
  import time
@@ -185,119 +186,16 @@ async def test_start_variants(ctx, text):
185
  assert (await say(ctx, OWNER, text)).out
186
 
187
 
188
- async def test_start_sends_the_stored_banner(ctx):
189
- await ctx.db.set_start_media("image", jpeg())
190
  ev = await say(ctx, OWNER, "/start")
191
  assert ev.out == []
192
  (chat, file, kw), = ctx.client.files
193
- assert chat == OWNER and "Auto Rename Bot" in kw["caption"]
194
- assert kw["buttons"] and kw["parse_mode"] == "html"
195
-
196
-
197
- async def test_start_falls_back_to_text_when_there_is_no_banner(ctx):
198
  ev = await say(ctx, OWNER, "/start")
199
- assert "Auto Rename Bot" in ev.texts[0] and ctx.client.files == []
200
-
201
-
202
- async def test_a_stored_gif_is_sent_as_an_animation(ctx):
203
- await ctx.db.set_start_media("gif", b"\x00gif")
204
- await say(ctx, OWNER, "/start")
205
- (chat, file, kw), = ctx.client.files
206
- assert chat == OWNER
207
- assert kw.get("force_document") is True
208
- names = [getattr(a, "file_name", None) for a in kw.get("attributes") or []]
209
- assert "start.gif" in names
210
- assert any(type(a).__name__ == "DocumentAttributeAnimated" for a in kw.get("attributes") or [])
211
-
212
-
213
- async def test_a_stored_image_is_sent_as_a_photo(ctx):
214
- await ctx.db.set_start_media("image", jpeg())
215
- await say(ctx, OWNER, "/start")
216
- (chat, file, kw), = ctx.client.files
217
- assert kw.get("force_document") is not True # a photo, not a document
218
- assert not kw.get("attributes")
219
-
220
-
221
- async def test_smedia_is_owner_only(ctx):
222
- ev = await say(ctx, ADMIN_CFG, "/smedia")
223
- assert "owner" in ev.texts[0].lower()
224
-
225
-
226
- async def test_smedia_stores_an_image_and_sends_it_on_start(ctx):
227
- ev = await say(ctx, OWNER, "/smedia")
228
- assert "smedia:image" in data_of(ev.out[0][1])
229
- await click(ctx, OWNER, "smedia:image")
230
- assert ctx.app.pending[OWNER].kind == "smedia_image"
231
- photo = FakeEvent(OWNER, message=FakeMessage(mid=70, photo=object()), photo_bytes=jpeg())
232
- await dispatch(ctx.client, photo)
233
- media_row = await ctx.db.get_start_media()
234
- assert media_row["kind"] == "image" and media_row["data"]
235
-
236
-
237
- async def test_smedia_stores_a_gif(ctx):
238
- await click(ctx, OWNER, "smedia:gif")
239
- assert ctx.app.pending[OWNER].kind == "smedia_gif"
240
- gif = FakeEvent(OWNER, message=FakeMessage(mid=71, gif=object()), photo_bytes=b"\x00gifdata")
241
- await dispatch(ctx.client, gif)
242
- media_row = await ctx.db.get_start_media()
243
- assert media_row["kind"] == "gif" and media_row["data"] == b"\x00gifdata"
244
-
245
-
246
- async def test_the_start_screen_can_be_viewed_and_deleted(ctx):
247
- await ctx.db.set_start_media("image", jpeg())
248
- await click(ctx, OWNER, "smedia:view")
249
- assert ctx.client.files
250
- await click(ctx, OWNER, "smedia:del")
251
- assert await ctx.db.get_start_media() is None
252
-
253
-
254
- async def test_a_format_thumbnail_is_set_by_sending_a_photo(ctx):
255
- fmt = await ctx.db.create_format(OWNER, "Poster")
256
- await click(ctx, OWNER, f"fthumb:{fmt['id']}")
257
- assert ctx.app.pending[OWNER].kind == "fmt_thumb"
258
- photo = FakeEvent(OWNER, message=FakeMessage(mid=72, photo=object()), photo_bytes=jpeg())
259
- await dispatch(ctx.client, photo)
260
- stored = await ctx.db.get_format(OWNER, fmt["id"])
261
- assert stored["thumb"] # a JPEG was stored
262
- assert OWNER not in ctx.app.pending
263
-
264
-
265
- async def test_renaming_a_format(ctx):
266
- fmt = await ctx.db.create_format(OWNER, "Old Name")
267
- await click(ctx, OWNER, f"frename:{fmt['id']}")
268
- assert ctx.app.pending[OWNER].kind == "fmt_name"
269
- await say(ctx, OWNER, "New Name")
270
- assert (await ctx.db.get_format(OWNER, fmt["id"]))["name"] == "New Name"
271
-
272
-
273
- async def test_the_delete_asks_first(ctx):
274
- fmt = await ctx.db.create_format(OWNER, "Doomed")
275
- cb = await click(ctx, OWNER, f"fdel:{fmt['id']}")
276
- assert "Delete" in cb.edits[0][0]
277
- assert await ctx.db.get_format(OWNER, fmt["id"]) is not None # not gone yet
278
- await click(ctx, OWNER, f"fdelok:{fmt['id']}")
279
- assert await ctx.db.get_format(OWNER, fmt["id"]) is None
280
-
281
-
282
- async def test_speed_reports_a_missing_tool(ctx, monkeypatch):
283
- async def missing(*_a, **_kw):
284
- raise FileNotFoundError("ookla-speedtest is not installed")
285
-
286
- monkeypatch.setattr(appmod, "run_speedtest", missing)
287
- await say(ctx, OWNER, "/speed")
288
- assert "installed" in str(ctx.client.edited)
289
-
290
-
291
- async def test_mediainfo_needs_a_file(ctx):
292
- ev = await say(ctx, OWNER, "/mediainfo")
293
- assert "Reply to a file" in ev.texts[0]
294
-
295
-
296
- async def test_every_new_command_is_registered(ctx):
297
- for command in ("rename", "formats", "sequence", "sort", "check", "ethumb", "resize", "speed",
298
- "mediainfo", "smedia"):
299
- assert command in [name for name, _d in COMMANDS], command
300
- assert (await say(ctx, OWNER, f"/{command}")).out or command == "smedia"
301
 
302
 
303
  async def test_start_and_help(ctx):
@@ -472,7 +370,7 @@ async def test_a_file_is_queued_with_the_template_name_and_a_cancel_button(ctx):
472
  ev = await send_doc(ctx, OWNER, mid=10)
473
  (job,) = ctx.app.queue.jobs.values()
474
  assert job.new_name == EPISODE_NEW and (job.user_id, job.chat_id, job.msg_id) == (OWNER, OWNER, 10)
475
- assert job.file_name == EPISODE and job.size == 50 * 1024 * 1024 and job.state == "queued"
476
  text, buttons, kw = ev.out[0]
477
  assert "Queued" in text and "starting soon" in text and EPISODE_NEW in text
478
  assert flat_buttons(buttons) == [("✖ Cancel", f"job:x:{job.id}")]
@@ -517,9 +415,12 @@ async def test_a_template_variable_that_cannot_be_filled_is_flagged(ctx):
517
  assert "in the caption" in ev.texts[0]
518
 
519
 
520
- async def test_the_default_template_never_warns(ctx):
521
  ev = await send_doc(ctx, OWNER, "Plain Movie.mkv")
522
  assert "⚠️" not in ev.texts[0] # bracket groups vanish on purpose
 
 
 
523
 
524
 
525
  class SlowEvent(FakeEvent):
@@ -537,9 +438,7 @@ class SlowEvent(FakeEvent):
537
  async def test_jobs_are_ordered_by_the_message_that_triggered_them(ctx):
538
  await send_doc(ctx, OWNER, "B.mkv", mid=31)
539
  await send_doc(ctx, OWNER, "A.mkv", mid=30)
540
- fmt = await ctx.db.create_format(OWNER, "One Piece")
541
- await click(ctx, OWNER, f"fm:{fmt['id']}")
542
- await send_doc(ctx, OWNER, "old.mkv", mid=34)
543
  assert sorted(j.order for j in ctx.app.queue.jobs.values()) == [30, 31, 34]
544
  assert [j.file_name for j in ctx.app.queue.for_user(OWNER)] == ["A.mkv", "B.mkv", "old.mkv"]
545
 
@@ -568,206 +467,28 @@ async def test_a_forwarded_batch_comes_out_in_the_order_it_was_sent(ctx):
568
  assert ran == [names[1145], names[1146], names[1147]]
569
 
570
 
571
- async def test_rename_picks_a_format_then_renames_the_next_file(ctx):
572
- ev = await say(ctx, OWNER, "/rename")
573
- data = data_of(ev.out[0][1])
574
- assert "fnew" in data # with no formats yet, only "new format" is offered
575
- assert not any(d.startswith("fm:") for d in data)
576
-
577
- fmt = await ctx.db.create_format(OWNER, "One Piece format")
578
- ev = await say(ctx, OWNER, "/rename")
579
- assert "One Piece format" in ev.out[0][0]
580
- assert f"fm:{fmt['id']}" in data_of(ev.out[0][1])
581
-
582
- await click(ctx, OWNER, f"fm:{fmt['id']}")
583
- assert ctx.app.pending[OWNER].kind == "rename_format"
584
- await send_doc(ctx, OWNER, "[SubsPlease] Tokyo Revengers - 05 (1080p) [ABCD1234].mkv")
585
-
586
- (job,) = ctx.app.queue.jobs.values()
587
- assert job.new_name == EPISODE_NEW # the format's template was applied
588
- assert OWNER not in ctx.app.pending # one file per /rename
589
-
590
-
591
- async def test_rename_no_longer_takes_a_name(ctx):
592
- ev = await say(ctx, OWNER, "/rename My Custom Name")
593
- assert "doesn't take a name" in ev.texts[0]
594
- assert not ctx.app.queue.jobs
595
-
596
-
597
- async def test_a_formats_template_reaches_the_file_name(ctx):
598
- fmt = await ctx.db.create_format(OWNER, "One Piece")
599
- await ctx.db.update_format(OWNER, fmt["id"], template="TR - {title} - E{episode}")
600
- await click(ctx, OWNER, f"fm:{fmt['id']}")
601
- await send_doc(ctx, OWNER, "Tokyo Revengers - 07 (1080p).mkv")
602
- (job,) = ctx.app.queue.jobs.values()
603
- assert job.new_name == "TR - Tokyo Revengers - E07.mkv"
604
- assert job.settings_override["template"] == "TR - {title} - E{episode}"
605
-
606
-
607
- async def test_a_format_can_suppress_the_thumbnail_entirely(ctx):
608
- fmt = await ctx.db.create_format(OWNER, "No Thumb")
609
- await ctx.db.update_format(OWNER, fmt["id"], thumb=b"")
610
- await click(ctx, OWNER, f"fm:{fmt['id']}")
611
- await send_doc(ctx, OWNER, "Movie.mkv")
612
- (job,) = ctx.app.queue.jobs.values()
613
- assert job.thumbnail_mode == "none" and job.thumbnail is None
614
-
615
-
616
- async def test_a_format_carries_its_own_thumbnail(ctx):
617
- fmt = await ctx.db.create_format(OWNER, "Poster")
618
- await ctx.db.update_format(OWNER, fmt["id"], thumb=b"\xff\xd8jpeg")
619
- await click(ctx, OWNER, f"fm:{fmt['id']}")
620
- await send_doc(ctx, OWNER, "Movie.mkv")
621
  (job,) = ctx.app.queue.jobs.values()
622
- assert job.thumbnail_mode == "custom" and job.thumbnail == b"\xff\xd8jpeg"
623
-
624
-
625
- async def test_deleting_a_format_does_not_break_a_queued_job(ctx):
626
- fmt = await ctx.db.create_format(OWNER, "Temp")
627
- await click(ctx, OWNER, f"fm:{fmt['id']}")
628
- await send_doc(ctx, OWNER, "Movie.mkv")
629
- (job,) = ctx.app.queue.jobs.values()
630
- await ctx.db.delete_format(OWNER, fmt["id"])
631
- assert job.settings_override is not None # the job carries resolved settings, not an id
632
-
633
-
634
- async def test_the_format_editor_saves_every_field(ctx):
635
- fmt = await ctx.db.create_format(OWNER, "One Piece")
636
- cb = await click(ctx, OWNER, f"fl:{fmt['id']}")
637
- assert "One Piece" in cb.edits[0][0]
638
- await click(ctx, OWNER, f"fset:fmt_title:{fmt['id']}")
639
- assert ctx.app.pending[OWNER].kind == "fmt_title"
640
- await say(ctx, OWNER, "One Piece")
641
- got = await ctx.db.get_format(OWNER, fmt["id"])
642
- assert got["meta"]["title"] == "One Piece"
643
-
644
- await click(ctx, OWNER, f"fset:fmt_title:{fmt['id']}")
645
- await say(ctx, OWNER, "default")
646
- assert (await ctx.db.get_format(OWNER, fmt["id"]))["meta"]["title"] is None # inherit
647
-
648
- await click(ctx, OWNER, f"fthumbnone:{fmt['id']}")
649
- assert (await ctx.db.get_format(OWNER, fmt["id"]))["thumb"] == b""
650
- await click(ctx, OWNER, f"fthumbdef:{fmt['id']}")
651
- assert (await ctx.db.get_format(OWNER, fmt["id"]))["thumb"] is None
652
-
653
-
654
- async def test_creating_and_deleting_a_format(ctx):
655
- await click(ctx, OWNER, "fnew")
656
- assert ctx.app.pending[OWNER].kind == "fmt_name"
657
- await say(ctx, OWNER, "Dragon Ball")
658
- formats = await ctx.db.list_formats(OWNER)
659
- assert [f["name"] for f in formats] == ["Dragon Ball"]
660
- fmt_id = formats[0]["id"]
661
-
662
- await click(ctx, OWNER, f"fdel:{fmt_id}")
663
- await click(ctx, OWNER, f"fdelok:{fmt_id}")
664
- assert await ctx.db.list_formats(OWNER) == []
665
-
666
-
667
- async def test_a_format_belongs_to_one_user(ctx):
668
- fmt = await ctx.db.create_format(OWNER, "Mine")
669
- ev = await click(ctx, ADMIN_CFG, f"fl:{fmt['id']}")
670
- assert "MY FORMATS" in ev.edits[0][0]
671
- assert await ctx.db.list_formats(ADMIN_CFG) == []
672
-
673
-
674
- async def test_an_open_sequence_collects_files_instead_of_renaming_them(ctx):
675
- ev = await say(ctx, OWNER, "/sequence")
676
- assert "Send me the files" in ev.texts[0]
677
- await send_doc(ctx, OWNER, "Show S01E02.mkv")
678
- await send_doc(ctx, OWNER, "Show S01E01.mkv")
679
- assert not ctx.app.queue.jobs # nothing was renamed
680
- seq = await ctx.db.get_sequence(OWNER)
681
- assert [i["name"] for i in seq["items"]] == ["Show S01E02.mkv", "Show S01E01.mkv"]
682
-
683
-
684
- async def test_sort_copies_the_files_back_in_order(ctx):
685
- await say(ctx, OWNER, "/sequence")
686
- await send_doc(ctx, OWNER, "Show S01E02.mkv", mid=11)
687
- await send_doc(ctx, OWNER, "Show S01E01.mkv", mid=12)
688
- await say(ctx, OWNER, "/sort")
689
- # the files go back one at a time, in episode order, with no forward header
690
- assert [ids for _to, _from, ids, _kw in ctx.client.copies] == [[12], [11]]
691
- assert all(kw.get("drop_author") is True for _to, _from, _ids, kw in ctx.client.copies)
692
- assert await ctx.db.get_sequence(OWNER) is None # the sequence closed
693
-
694
-
695
- async def test_sort_reports_missing_and_duplicate_episodes(ctx):
696
- await say(ctx, OWNER, "/sequence")
697
- await send_doc(ctx, OWNER, "Show S01E01.mkv", mid=21)
698
- await send_doc(ctx, OWNER, "Show S01E03.mkv", mid=22)
699
- await send_doc(ctx, OWNER, "Show S01E03 (copy).mkv", mid=23)
700
- await say(ctx, OWNER, "/sort")
701
- report = [t for _e, t, _k in ctx.client.edited if t and "Sorted" in t]
702
- assert report and "S01E02" in report[0]
703
- assert "Duplicate S01E03" in report[0]
704
-
705
-
706
- async def test_a_picked_format_wins_over_an_open_sequence(ctx):
707
- await say(ctx, OWNER, "/sequence")
708
- fmt = await ctx.db.create_format(OWNER, "One Piece")
709
- await click(ctx, OWNER, f"fm:{fmt['id']}")
710
- await send_doc(ctx, OWNER, "Show S01E01.mkv")
711
- (job,) = ctx.app.queue.jobs.values()
712
- assert job.new_name == "[S01E01] Show.mkv" # renamed, not collected
713
- seq = await ctx.db.get_sequence(OWNER)
714
- assert seq["items"] == []
715
-
716
-
717
- async def test_a_sequence_can_be_cancelled(ctx):
718
- await say(ctx, OWNER, "/sequence")
719
- await send_doc(ctx, OWNER, "Show S01E01.mkv")
720
- await click(ctx, OWNER, "sq:cancel")
721
- assert await ctx.db.get_sequence(OWNER) is None
722
-
723
-
724
- async def test_check_reports_what_the_bot_extracted(ctx):
725
- reply = document_message("Dragon Ball Super Beerus S01E02 1080p.mkv", mid=40, caption="")
726
- ev = await say(ctx, OWNER, "/check", reply=reply)
727
- text = ev.texts[0]
728
- assert "Title ⭆ Dragon Ball Super Beerus" in text
729
- assert "Season ⭆ 01" in text and "Episode ⭆ 02" in text
730
- assert "Quality ⭆ 1080p" in text
731
- assert text.count("➦") == 8 # the name and the caption sections
732
-
733
-
734
- async def test_check_shows_the_caption_section(ctx):
735
- reply = document_message("video.mkv", mid=41, caption="Tokyo Revengers S02E05 720p")
736
- ev = await say(ctx, OWNER, "/check", reply=reply)
737
- text = ev.texts[0]
738
- assert "Episode ⭆ 05" in text and "Quality ⭆ 720p" in text
739
-
740
-
741
- async def test_check_needs_a_file(ctx):
742
- ev = await say(ctx, OWNER, "/check")
743
  assert "Reply to a file" in ev.texts[0]
744
-
745
-
746
- async def test_resize_answers_with_size_buttons(ctx):
747
- reply = FakeMessage(mid=60, photo=object(), media_bytes=jpeg())
748
- ev = await say(ctx, OWNER, "/resize", reply=reply)
749
- data = data_of(ev.out[0][1])
750
- assert "rz:320" in data and "rz:640" in data and "rz:1280" in data and "rz:orig" in data
751
-
752
-
753
- async def test_resize_needs_an_image(ctx):
754
- ev = await say(ctx, OWNER, "/resize")
755
- assert "Reply to an image" in ev.texts[0]
756
-
757
-
758
- async def test_resizing_sends_the_new_image(ctx):
759
- reply = FakeMessage(mid=61, photo=object(), media_bytes=jpeg())
760
- await say(ctx, OWNER, "/resize", reply=reply)
761
- await click(ctx, OWNER, "rz:320")
762
- assert ctx.client.files
763
- _chat, file, kw = ctx.client.files[-1]
764
- assert isinstance(file, io.BytesIO)
765
- assert "Set as my thumbnail" in [label for label, _d in flat_buttons(kw.get("buttons"))]
766
-
767
-
768
- async def test_ethumb_needs_a_file(ctx):
769
- ev = await say(ctx, OWNER, "/ethumb")
770
- assert "Reply to a video" in ev.texts[0]
771
 
772
 
773
  async def test_oversized_files_are_refused(ctx):
 
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
 
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):
 
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}")]
 
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
  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
  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):
tests/test_check.py DELETED
@@ -1,119 +0,0 @@
1
- from bot.parser import Info, effective_info
2
- from bot.panels import check_report
3
- from bot.utils import to_small_caps
4
-
5
- # the small-capital glyph of every ASCII letter
6
- SMALL_CAP_TABLE = {
7
- "a": "\u1d43", "b": "\u0299", "c": "\u1d04", "d": "\u1d05", "e": "\u1d07",
8
- "f": "\ua730", "g": "\u0262", "h": "\u029c", "i": "\u026a", "j": "\u1d0a",
9
- "k": "\u1d0b", "l": "\u029f", "m": "\u1d0d", "n": "\u0274", "o": "\u1d0f",
10
- "p": "\u1d18", "q": "\u01eb", "r": "\u0280", "s": "\u0073", "t": "\u1d1b",
11
- "u": "\u1d1c", "v": "\u1d20", "w": "\u1d21", "x": "\u0078", "y": "\u02bf",
12
- "z": "\u1d22",
13
- }
14
-
15
-
16
- def test_to_small_caps_maps_every_letter_and_leaves_the_rest():
17
- assert to_small_caps("Your") == "\u02bf\u1d0f\u1d1c\u0280" # ʏ ᴏ ᴜ ʀ
18
- assert to_small_caps("FileName") == to_small_caps("filename")
19
- # The brief wrote this line with the literal "123 /mode!" on both sides, but every
20
- # ASCII letter maps, so "mode" must come out small-capped; the literal was a
21
- # Unicode-normalisation artefact. The correct expected value is spelled out below.
22
- assert to_small_caps("123 /mode!") == "123 /\u1d0d\u1d0f\u1d05\u1d07!"
23
-
24
-
25
- def test_to_small_caps_covers_the_whole_alphabet():
26
- for letter, glyph in SMALL_CAP_TABLE.items():
27
- assert to_small_caps(letter) == glyph
28
- assert to_small_caps(letter.upper()) == glyph # both cases share one glyph
29
- assert to_small_caps("0123456789 .,!?-_") == "0123456789 .,!?-_"
30
-
31
-
32
- def test_effective_info_uses_the_caption_only_when_it_is_structured():
33
- stem = "Dragon Ball Super Beerus S01E02 1080p"
34
- assert effective_info(stem, "", "filename").title == "Dragon Ball Super Beerus"
35
- # an unstructured caption is discarded entirely
36
- assert effective_info(stem, "My Show", "caption").title == "Dragon Ball Super Beerus"
37
- # a structured caption wins, and the filename fills in what it lacks
38
- info = effective_info(stem, "Beerus 1080p S01E02", "caption")
39
- assert info.season == "01" and info.episode == "02" and info.quality == "1080p"
40
-
41
-
42
- def test_effective_info_in_filename_mode_ignores_the_caption():
43
- stem = "Dragon Ball Super Beerus S01E02 1080p"
44
- info = effective_info(stem, "Beerus 1080p S01E02", "filename")
45
- assert info.title == "Dragon Ball Super Beerus"
46
- assert info.season == "01" and info.episode == "02" and info.quality == "1080p"
47
-
48
-
49
- def test_effective_info_fills_only_the_fields_the_caption_left_empty():
50
- stem = "Dragon Ball Super Beerus S01E02 1080p"
51
- info = effective_info(stem, "Beerus 720p", "caption")
52
- assert info.title == "Beerus" # the caption wins
53
- assert info.quality == "720p" # ... here too
54
- assert info.season == "01" and info.episode == "02" # the filename fills the rest
55
-
56
-
57
- def test_effective_info_ignores_a_blank_caption():
58
- stem = "Dragon Ball Super Beerus S01E02 1080p"
59
- for blank in ("", " ", "\n"):
60
- info = effective_info(stem, blank, "caption")
61
- assert info.title == "Dragon Ball Super Beerus"
62
- assert info.season == "01" and info.episode == "02" and info.quality == "1080p"
63
-
64
-
65
- def test_check_report_has_the_agreed_layout():
66
- info = Info(title="Dragon Ball Super Beerus", season="01", episode="02", quality="1080p")
67
- text = check_report(info, info)
68
- assert "\u02bf\u1d0f\u1d1c\u0280" in text # ʏ ᴏ ᴜ ʀ — the small-capped header
69
- assert "\ua730\u026a\u029f\u1d07\u0274\u1d43\u1d0d\u1d07:" in text # ꜰɪʟᴇɴᴀᴍᴇ
70
- assert "\u27a6 Title \u2b46 Dragon Ball Super Beerus" in text
71
- assert "\u27a6 Season \u2b46 01" in text
72
- assert "\u27a6 Episode \u2b46 02" in text
73
- assert "\u27a6 Quality \u2b46 1080p" in text
74
- assert text.count("\u27a6") == 8 # four fields in each of the two sections
75
- assert "Chapter" not in text
76
-
77
-
78
- def test_check_report_matches_the_agreed_layout_exactly():
79
- info = Info(title="Dragon Ball Super Beerus", season="01", episode="02", quality="1080p")
80
- assert check_report(info, info) == (
81
- "\u02bf\u1d0f\u1d1c\u0280 \u1d04\u1d43\u0274 \u1d04\u029c\u1d43\u0274\u0262\u1d07 "
82
- "\u1d0d\u1d0f\u1d05\u1d07 \u0299\u02bf /mode\n"
83
- "\n"
84
- "\ua730\u026a\u029f\u1d07\u0274\u1d43\u1d0d\u1d07:\n"
85
- "\u27a6 Title \u2b46 Dragon Ball Super Beerus\n"
86
- "\u27a6 Season \u2b46 01\n"
87
- "\u27a6 Episode \u2b46 02\n"
88
- "\u27a6 Quality \u2b46 1080p\n"
89
- "\n"
90
- "\u1d04\u1d43\u1d18\u1d1b\u026a\u1d0f\u0274 \u1d0d\u1d0f\u1d05\u1d07:\n"
91
- "\u27a6 Title \u2b46 Dragon Ball Super Beerus\n"
92
- "\u27a6 Season \u2b46 01\n"
93
- "\u27a6 Episode \u2b46 02\n"
94
- "\u27a6 Quality \u2b46 1080p"
95
- )
96
-
97
-
98
- def test_check_report_shows_none_for_empty_values_and_extra_fields_only_when_present():
99
- text = check_report(Info(title="X"), Info(title="X"))
100
- assert "\u27a6 Season \u2b46 None" in text
101
- assert "Year" not in text and "Codec" not in text
102
- text = check_report(Info(title="X", year="2023", codec="HEVC"), Info(title="X"))
103
- assert "\u27a6 Year \u2b46 2023" in text and "\u27a6 Codec \u2b46 HEVC" in text
104
-
105
-
106
- def test_check_report_shows_year_and_codec_only_in_the_section_that_has_them():
107
- text = check_report(Info(title="X"), Info(title="X", year="2023", codec="HEVC"))
108
- name_section, caption_section = text.split(
109
- "\u1d04\u1d43\u1d18\u1d1b\u026a\u1d0f\u0274 \u1d0d\u1d0f\u1d05\u1d07:"
110
- )
111
- assert "Year" not in name_section and "Codec" not in name_section
112
- assert "\u27a6 Year \u2b46 2023" in caption_section
113
- assert "\u27a6 Codec \u2b46 HEVC" in caption_section
114
-
115
-
116
- def test_check_report_escapes_values_that_come_from_the_file_name():
117
- text = check_report(Info(title="<b>Bold</b> & Co"), Info(title="X"))
118
- assert "<b>Bold</b>" not in text
119
- assert "&lt;b&gt;Bold&lt;/b&gt; &amp; Co" in text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_db.py CHANGED
@@ -3,6 +3,14 @@ import pytest
3
  from bot.db import DEFAULT_SETTINGS, Database, normalize_database_url
4
 
5
 
 
 
 
 
 
 
 
 
6
  def test_normalize_postgres_urls():
7
  url, args = normalize_database_url("postgres://u:p@host/db?sslmode=require&channel_binding=require")
8
  assert url == "postgresql+asyncpg://u:p@host/db"
@@ -77,65 +85,3 @@ async def test_first_use_by_many_concurrent_handlers_does_not_collide(db):
77
  # the same goes for the other writers that create the row on first use
78
  await asyncio.gather(*(db.record_job(78, 10) for _ in range(5)), *(db.set_thumb(79, b"x") for _ in range(5)))
79
  assert await db.count_users() == 3
80
-
81
-
82
- async def test_formats_crud_and_the_twelve_slot_limit(db):
83
- assert await db.count_formats(7) == 0
84
- first = await db.create_format(7, "One Piece format")
85
- assert first["id"] and first["name"] == "One Piece format"
86
- assert first["template"] is None and first["keep_audio"] is None
87
- assert first["meta"]["title"] is None
88
- assert await db.create_format(7, "One Piece format") is None # the name is taken
89
- assert await db.create_format(7, " ") is None # a blank name
90
- for i in range(11):
91
- assert await db.create_format(7, f"F{i}") is not None
92
- assert await db.count_formats(7) == 12
93
- assert await db.create_format(7, "Thirteenth") is None # the limit
94
- assert await db.count_formats(8) == 0 # per user
95
- assert [f["name"] for f in await db.list_formats(7)][0] == "One Piece format"
96
-
97
-
98
- async def test_format_fields_round_trip_through_the_database(db):
99
- fmt = await db.create_format(7, "Dragon Ball")
100
- assert await db.update_format(7, fmt["id"], template="[S{season}E{episode}] {title}",
101
- meta={"title": "", "author": "Kuririn"},
102
- keep_audio=[], keep_subtitle=["english"])
103
- got = await db.get_format(7, fmt["id"])
104
- assert got["template"] == "[S{season}E{episode}] {title}"
105
- assert got["meta"]["title"] == "" and got["meta"]["author"] == "Kuririn"
106
- assert got["meta"]["artist"] is None
107
- assert got["keep_audio"] == [] and got["keep_subtitle"] == ["english"]
108
- assert await db.get_format(9, fmt["id"]) is None # another user
109
- assert await db.delete_format(7, fmt["id"]) is True
110
- assert await db.delete_format(7, fmt["id"]) is False
111
-
112
-
113
- async def test_format_thumbnail_distinguishes_inherit_from_none(db):
114
- fmt = await db.create_format(7, "Posters")
115
- assert (await db.get_format(7, fmt["id"]))["thumb"] is None # inherit
116
- await db.update_format(7, fmt["id"], thumb=b"")
117
- assert (await db.get_format(7, fmt["id"]))["thumb"] == b"" # explicitly none
118
- await db.update_format(7, fmt["id"], thumb=b"\xff\xd8jpeg")
119
- assert (await db.get_format(7, fmt["id"]))["thumb"] == b"\xff\xd8jpeg"
120
- await db.update_format(7, fmt["id"], thumb=None)
121
- assert (await db.get_format(7, fmt["id"]))["thumb"] is None # back to inherit
122
-
123
-
124
- async def test_sequence_and_start_media(db):
125
- assert await db.get_sequence(3) is None
126
- await db.set_sequence(3, [{"msg_id": 1, "name": "a.mkv", "caption": ""}])
127
- seq = await db.get_sequence(3)
128
- assert seq["items"][0]["name"] == "a.mkv" and seq["copied"] == 0
129
- await db.set_sequence(3, seq["items"], copied=2)
130
- assert (await db.get_sequence(3))["copied"] == 2
131
- await db.clear_sequence(3)
132
- assert await db.get_sequence(3) is None
133
-
134
- assert await db.get_start_media() is None
135
- await db.set_start_media("gif", b"\x00gif")
136
- assert (await db.get_start_media())["kind"] == "gif"
137
- assert (await db.get_start_media())["data"] == b"\x00gif"
138
- await db.set_start_media("image", b"\xff\xd8")
139
- assert (await db.get_start_media())["kind"] == "image" # one row, replaced
140
- await db.clear_start_media()
141
- assert await db.get_start_media() is None
 
3
  from bot.db import DEFAULT_SETTINGS, Database, normalize_database_url
4
 
5
 
6
+ @pytest.fixture
7
+ async def db(tmp_path):
8
+ database = Database(f"sqlite+aiosqlite:///{tmp_path}/t.db")
9
+ await database.init()
10
+ yield database
11
+ await database.close()
12
+
13
+
14
  def test_normalize_postgres_urls():
15
  url, args = normalize_database_url("postgres://u:p@host/db?sslmode=require&channel_binding=require")
16
  assert url == "postgresql+asyncpg://u:p@host/db"
 
85
  # the same goes for the other writers that create the row on first use
86
  await asyncio.gather(*(db.record_job(78, 10) for _ in range(5)), *(db.set_thumb(79, b"x") for _ in range(5)))
87
  assert await db.count_users() == 3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_formats.py DELETED
@@ -1,67 +0,0 @@
1
- from bot.db import DEFAULT_SETTINGS
2
- from bot.formats import apply_format_field, resolve_format, resolve_thumbnail
3
-
4
-
5
- def test_a_format_that_inherits_everything_changes_nothing():
6
- fmt = {"template": None, "meta": {f: None for f in ("title", "author", "artist", "audio", "subtitle", "video")},
7
- "keep_audio": None, "keep_subtitle": None}
8
- settings = dict(DEFAULT_SETTINGS, template="GLOBAL", keep_audio=["english"])
9
- assert resolve_format(fmt, settings) == settings
10
-
11
-
12
- def test_a_format_overlays_only_the_fields_it_sets():
13
- fmt = {"template": "[S{season}E{episode}] {title}",
14
- "meta": {"title": "One Piece", "author": None, "artist": None, "audio": None, "subtitle": None, "video": None},
15
- "keep_audio": [], "keep_subtitle": None}
16
- settings = dict(DEFAULT_SETTINGS, template="GLOBAL", keep_audio=["english"], keep_subtitle=["japanese"])
17
- out = resolve_format(fmt, settings)
18
- assert out["template"] == "[S{season}E{episode}] {title}"
19
- assert out["meta"]["title"] == "One Piece" and out["meta"]["author"] == ""
20
- assert out["keep_audio"] == [] and out["keep_subtitle"] == ["japanese"] # inherit
21
- assert settings["template"] == "GLOBAL" # the input is not mutated
22
-
23
-
24
- def test_resolve_thumbnail_states():
25
- inherit = {"thumb": None}
26
- custom = {"thumb": b"\xff\xd8jpeg"}
27
- none = {"thumb": b""}
28
- assert resolve_thumbnail(inherit, b"GLOBAL") == ("auto", b"GLOBAL")
29
- assert resolve_thumbnail(inherit, None) == ("auto", None) # a video frame may still be used
30
- assert resolve_thumbnail(custom, b"GLOBAL") == ("custom", b"\xff\xd8jpeg")
31
- assert resolve_thumbnail(none, b"GLOBAL") == ("none", None) # explicitly none: never a frame
32
-
33
-
34
- async def test_apply_format_field_clearing_and_inheriting(db):
35
- fmt = await db.create_format(1, "One Piece")
36
- ok, msg = await apply_format_field(db, 1, fmt["id"], "fmt_title", "One Piece")
37
- assert ok and "One Piece" in msg
38
- ok, _ = await apply_format_field(db, 1, fmt["id"], "fmt_title", "off")
39
- assert ok
40
- assert (await db.get_format(1, fmt["id"]))["meta"]["title"] == "" # explicitly empty
41
- ok, _ = await apply_format_field(db, 1, fmt["id"], "fmt_keep_audio", "off")
42
- assert ok
43
- assert (await db.get_format(1, fmt["id"]))["keep_audio"] == [] # keep every track
44
- ok, msg = await apply_format_field(db, 1, fmt["id"], "fmt_name", "off")
45
- assert not ok # a format must have a name
46
-
47
-
48
- async def test_apply_format_field_saves_a_template_and_a_keep_list(db):
49
- fmt = await db.create_format(1, "Dragon Ball")
50
- ok, msg = await apply_format_field(db, 1, fmt["id"], "fmt_template", "[S{season}E{episode}] {title}")
51
- assert ok and "Template saved" in msg
52
- assert (await db.get_format(1, fmt["id"]))["template"] == "[S{season}E{episode}] {title}"
53
- ok, _ = await apply_format_field(db, 1, fmt["id"], "fmt_template", "off")
54
- assert ok
55
- assert (await db.get_format(1, fmt["id"]))["template"] is None # back to inherit
56
-
57
- ok, msg = await apply_format_field(db, 1, fmt["id"], "fmt_keep_subtitle", "English Japanese")
58
- assert ok and "English" in msg
59
- assert (await db.get_format(1, fmt["id"]))["keep_subtitle"] == ["english", "japanese"]
60
-
61
- ok, msg = await apply_format_field(db, 1, fmt["id"], "fmt_keep_subtitle", "0")
62
- assert not ok and "start at 1" in msg
63
-
64
-
65
- async def test_apply_format_field_rejects_a_missing_format(db):
66
- ok, msg = await apply_format_field(db, 1, 999, "fmt_title", "x")
67
- assert not ok and "gone" in msg
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_media.py CHANGED
@@ -9,15 +9,12 @@ from bot.media import (
9
  apply_plan,
10
  build_plan,
11
  canonical_lang,
12
- extract_frame,
13
  format_tracks,
14
  make_video_thumbnail,
15
  parse_probe,
16
- parse_size,
17
  parse_track_tokens,
18
  prepare_thumb,
19
  probe,
20
- resize_image,
21
  resolve_tokens,
22
  validate_track_tokens,
23
  )
@@ -322,41 +319,3 @@ async def test_thumbnail_and_prepare_thumb(media_dir, tmp_path):
322
  img = Image.open(io.BytesIO(jpg))
323
  assert max(img.size) <= 320 and len(jpg) <= 200 * 1024 and img.format == "JPEG"
324
  assert not await make_video_thumbnail(tmp_path / "missing.mkv", tmp_path / "x.jpg", 1.0)
325
-
326
-
327
- @needs_ffmpeg
328
- async def test_extract_frame_keeps_the_source_width(media_dir, tmp_path):
329
- out = tmp_path / "frame.jpg"
330
- assert await extract_frame(media_dir / "sample.mkv", out, 2.0)
331
- from PIL import Image
332
- import io
333
-
334
- img = Image.open(io.BytesIO(out.read_bytes()))
335
- assert img.size[0] == 320 # the sample video is 320 wide
336
- assert not await extract_frame(tmp_path / "missing.mkv", tmp_path / "x.jpg", 1.0)
337
-
338
-
339
- def test_parse_size_accepts_a_width_or_an_exact_size():
340
- assert parse_size("800") == (800, None, None)
341
- assert parse_size("800x450") == (800, 450, None)
342
- assert parse_size(" 640 ") == (640, None, None)
343
- w, h, err = parse_size("huge")
344
- assert (w, h) == (None, None) and "size" in err
345
- w, h, err = parse_size("0")
346
- assert (w, h) == (None, None) and "1 pixel" in err
347
-
348
-
349
- def test_resize_image_keeps_the_aspect_ratio_or_forces_the_exact_size():
350
- import io
351
-
352
- from PIL import Image
353
-
354
- buf = io.BytesIO()
355
- Image.new("RGB", (1000, 500), (10, 20, 30)).save(buf, "PNG")
356
- data = buf.getvalue()
357
- out = Image.open(io.BytesIO(resize_image(data, width=200)))
358
- assert out.size == (200, 100)
359
- out = Image.open(io.BytesIO(resize_image(data, width=200, height=200)))
360
- assert out.size == (200, 200)
361
- out = Image.open(io.BytesIO(resize_image(data, height=250)))
362
- assert out.size == (500, 250)
 
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
  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)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_sequence.py DELETED
@@ -1,57 +0,0 @@
1
- from bot.sequence import detect_episodes, format_report, plan_sort
2
-
3
-
4
- def items(*names):
5
- return [{"msg_id": i, "name": n, "caption": ""} for i, n in enumerate(names)]
6
-
7
-
8
- def test_episodes_are_detected_from_the_file_name():
9
- out = detect_episodes(items("Show S01E02.mkv", "Show S01E10.mkv"), "filename")
10
- assert [(i["season"], i["episode"]) for i in out] == [("01", "02"), ("01", "10")]
11
-
12
-
13
- def test_the_caption_is_used_in_caption_mode():
14
- out = detect_episodes(
15
- [{"msg_id": 1, "name": "video.mkv", "caption": "Show S02E05 1080p"}], "caption"
16
- )
17
- assert (out[0]["season"], out[0]["episode"]) == ("02", "05")
18
- out = detect_episodes(
19
- [{"msg_id": 1, "name": "video.mkv", "caption": "Show S02E05 1080p"}], "filename"
20
- )
21
- assert out[0]["episode"] == ""
22
-
23
-
24
- def test_sorting_is_by_season_then_episode_ascending():
25
- out = plan_sort(detect_episodes(
26
- items("S01E10.mkv", "S02E01.mkv", "S01E02.mkv", "S01E03.mkv"), "filename"))
27
- assert [i["name"] for i in out.ordered] == ["S01E02.mkv", "S01E03.mkv", "S01E10.mkv", "S02E01.mkv"]
28
-
29
-
30
- def test_missing_episodes_are_reported_per_season():
31
- out = plan_sort(detect_episodes(items("S01E01.mkv", "S01E04.mkv", "S02E01.mkv"), "filename"))
32
- assert out.missing == ["S01E02", "S01E03"]
33
-
34
-
35
- def test_duplicate_episodes_are_reported_with_their_file_names():
36
- out = plan_sort(detect_episodes(items("S01E02.mkv", "S01E02 (copy).mkv", "S01E03.mkv"), "filename"))
37
- assert len(out.duplicates) == 1
38
- assert out.duplicates[0].episode == "02"
39
- assert out.duplicates[0].names == ["S01E02.mkv", "S01E02 (copy).mkv"]
40
-
41
-
42
- def test_a_file_without_an_episode_is_kept_and_reported():
43
- out = plan_sort(detect_episodes(items("S01E02.mkv", "bonus.mkv", "extra.mkv"), "filename"))
44
- assert [i["name"] for i in out.ordered] == ["S01E02.mkv", "bonus.mkv", "extra.mkv"]
45
- assert out.no_episode == ["bonus.mkv", "extra.mkv"]
46
-
47
-
48
- def test_the_report_lists_only_what_is_wrong():
49
- out = plan_sort(detect_episodes(items("S01E01.mkv", "S01E03.mkv"), "filename"))
50
- text = format_report(out, 2)
51
- assert "S01E02" in text and "Duplicate" not in text and "No episode" not in text
52
-
53
-
54
- def test_the_report_names_the_duplicates_and_the_odds():
55
- out = plan_sort(detect_episodes(items("S01E01.mkv", "S01E01.mkv", "bonus.mkv"), "filename"))
56
- text = format_report(out, 3)
57
- assert "Duplicate S01E01" in text and "No episode number" in text and "bonus.mkv" in text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_settings_ops.py CHANGED
@@ -1,11 +1,19 @@
1
  import pytest
2
 
3
- from bot.db import DEFAULT_SETTINGS
4
  from bot.settings_ops import META_COMMANDS, META_KINDS, apply_setting, parse_channel, preview_template
5
 
6
  UID = 7
7
 
8
 
 
 
 
 
 
 
 
 
9
  async def get(db):
10
  return await db.get_settings(UID)
11
 
 
1
  import pytest
2
 
3
+ from bot.db import DEFAULT_SETTINGS, Database
4
  from bot.settings_ops import META_COMMANDS, META_KINDS, apply_setting, parse_channel, preview_template
5
 
6
  UID = 7
7
 
8
 
9
+ @pytest.fixture
10
+ async def db(tmp_path):
11
+ d = Database(f"sqlite+aiosqlite:///{tmp_path}/s.db")
12
+ await d.init()
13
+ yield d
14
+ await d.close()
15
+
16
+
17
  async def get(db):
18
  return await db.get_settings(UID)
19
 
tests/test_speedtest.py DELETED
@@ -1,51 +0,0 @@
1
- import pytest
2
-
3
- from bot.speedtest import format_speed, parse_ookla_json
4
-
5
- # A real `ookla-speedtest --accept-license --accept-gdpr --format=json` run.
6
- RAW = """{"type":"result","timestamp":"2026-10-20T10:45:15Z",
7
- "ping":{"jitter":0.273,"latency":2.998,"low":2.833,"high":3.315},
8
- "download":{"bandwidth":11034552,"bytes":39707592,"elapsed":3602,
9
- "latency":{"iqm":16.372,"low":6.344,"high":48.044,"jitter":3.565}},
10
- "upload":{"bandwidth":11886628,"bytes":162534754,"elapsed":14821,
11
- "latency":{"iqm":128.627,"low":5.8,"high":523.355,"jitter":48.431}},
12
- "packetLoss":0,"isp":"ACT Fibernet",
13
- "interface":{"internalIp":"192.168.32.3","name":"wlan0","externalIp":"49.204.136.88"},
14
- "server":{"id":60836,"host":"chn.infonetcomm.com","port":8080,"name":"ACT FIBERNET",
15
- "location":"Chennai","country":"India","ip":"2406:7400:b0:1::2"},
16
- "result":{"id":"18dc","url":"https://www.speedtest.net/result/c/18dc","persisted":true}}"""
17
-
18
-
19
- def test_parse_ookla_json_reads_the_speeds():
20
- r = parse_ookla_json(RAW)
21
- assert r.download_mbps == pytest.approx(11.034552, rel=1e-4)
22
- assert r.upload_mbps == pytest.approx(11.886628, rel=1e-4)
23
- assert r.ping_ms == 2.998 # the CLI reports the round trip as "latency"
24
- assert r.server == "ACT FIBERNET — Chennai, India"
25
- assert r.isp == "ACT Fibernet"
26
-
27
-
28
- def test_parse_ookla_json_survives_a_leading_licence_notice():
29
- r = parse_ookla_json("You may only use this Speedtest software...\n" + RAW)
30
- assert r.download_mbps == pytest.approx(11.034552, rel=1e-4)
31
-
32
-
33
- def test_parse_ookla_json_rejects_rubbish():
34
- with pytest.raises(ValueError):
35
- parse_ookla_json("not json")
36
- with pytest.raises(ValueError):
37
- parse_ookla_json("{}")
38
- with pytest.raises(ValueError):
39
- parse_ookla_json("[]")
40
-
41
-
42
- def test_format_speed_shows_the_numbers():
43
- text = format_speed(parse_ookla_json(RAW))
44
- assert "11.03" in text and "11.89" in text and "3" in text
45
- assert "ACT FIBERNET" in text and "Chennai" in text and "ACT Fibernet" in text
46
-
47
-
48
- def test_format_speed_copes_with_missing_details():
49
- text = format_speed(parse_ookla_json('{"download":{"bandwidth":1000000},"upload":{"bandwidth":2000000}}'))
50
- assert "1.00 Mbps" in text and "2.00 Mbps" in text
51
- assert "Server" not in text and "ISP" not in text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_telegraph.py DELETED
@@ -1,44 +0,0 @@
1
- from bot.telegraph import build_page, build_summary
2
-
3
- PROBE = {
4
- "format": {"filename": "movie.mkv", "format_name": "matroska,webm", "duration": "120.0",
5
- "size": "1048576", "bit_rate": "8000000",
6
- "tags": {"title": "Movie Title"}},
7
- "streams": [
8
- {"index": 0, "codec_type": "video", "codec_name": "h264", "width": 1920, "height": 1080,
9
- "r_frame_rate": "24/1", "bit_rate": "7000000", "disposition": {"default": 1}},
10
- {"index": 1, "codec_type": "audio", "codec_name": "aac", "channels": 2, "sample_rate": "48000",
11
- "tags": {"language": "eng", "title": "English"}},
12
- {"index": 2, "codec_type": "subtitle", "codec_name": "subrip",
13
- "tags": {"language": "eng"}},
14
- ],
15
- "chapters": [{"id": 0, "start_time": "0.0", "end_time": "60.0", "tags": {"title": "Intro"}}],
16
- }
17
-
18
-
19
- def test_build_page_lists_the_format_the_streams_and_the_chapters():
20
- html = build_page(PROBE, "movie.mkv")
21
- assert "movie.mkv" in html and "matroska" in html
22
- assert "h264" in html and "1920x1080" in html
23
- assert "aac" in html and "English" in html
24
- assert "subrip" in html
25
- assert "Intro" in html
26
- assert "Movie Title" in html
27
- assert "<pre" in html
28
-
29
-
30
- def test_build_page_includes_the_raw_json():
31
- html = build_page(PROBE, "movie.mkv")
32
- assert '"streams"' in html # the raw ffprobe output is in the page
33
- assert "h264" in html.split("<pre")[1] # ...inside the <pre> block
34
-
35
-
36
- def test_build_page_survives_an_empty_probe():
37
- html = build_page({}, "nothing.mkv")
38
- assert "nothing.mkv" in html
39
-
40
-
41
- def test_the_plain_summary_has_no_html():
42
- text = build_summary(PROBE, "movie.mkv")
43
- assert "movie.mkv" in text and "1920x1080" in text and "Intro" in text
44
- assert "<" not in text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/tgfakes.py CHANGED
@@ -23,8 +23,7 @@ def make_file(name: Optional[str] = "Movie.mkv", size: int = 5 * 1024 * 1024, mi
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
- media_bytes: Optional[bytes] = None):
28
  self.id = mid
29
  self.message = text
30
  self.raw_text = text
@@ -41,10 +40,6 @@ class FakeMessage:
41
  self.voice = object() if voice else None
42
  self.video_note = object() if video_note else None
43
  self.media = object() if (file is not None or photo) else None
44
- self.media_bytes = media_bytes
45
-
46
- async def download_media(self, file: Any = None) -> Optional[bytes]:
47
- return self.media_bytes
48
 
49
 
50
  def document_message(name: str = "Movie.mkv", size: int = 5 * 1024 * 1024, *, mid: int = 10, caption: str = "",
@@ -137,7 +132,6 @@ class FakeClient:
137
  self.sent: List[tuple] = [] # send_message(entity, text, **kw)
138
  self.files: List[tuple] = [] # send_file(entity, file, **kw)
139
  self.edited: List[tuple] = [] # edit_message(entity, text, **kw)
140
- self.copies: List[tuple] = [] # forward_messages(to, messages, from_peer, **kw)
141
  self.requests: List[Any] = [] # raw MTProto requests
142
  self.bad_entities: set = set()
143
  self.fail_send_file = False
@@ -169,10 +163,6 @@ class FakeClient:
169
  text, message = message, None
170
  self.edited.append((entity, text, {**kw, "message": message}))
171
 
172
- async def forward_messages(self, to: Any, messages: Any, from_peer: Any = None, **kw: Any) -> List[SimpleNamespace]:
173
- self.copies.append((to, from_peer, list(messages), kw))
174
- return [SimpleNamespace(id=4000 + len(self.copies))]
175
-
176
 
177
  async def dispatch(client: FakeClient, event: Any) -> None:
178
  """Run ``event`` through the registered handlers like Telethon's dispatcher."""
 
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
 
40
  self.voice = object() if voice else None
41
  self.video_note = object() if video_note else None
42
  self.media = object() if (file is not None or photo) else None
 
 
 
 
43
 
44
 
45
  def document_message(name: str = "Movie.mkv", size: int = 5 * 1024 * 1024, *, mid: int = 10, caption: str = "",
 
132
  self.sent: List[tuple] = [] # send_message(entity, text, **kw)
133
  self.files: List[tuple] = [] # send_file(entity, file, **kw)
134
  self.edited: List[tuple] = [] # edit_message(entity, text, **kw)
 
135
  self.requests: List[Any] = [] # raw MTProto requests
136
  self.bad_entities: set = set()
137
  self.fail_send_file = False
 
163
  text, message = message, None
164
  self.edited.append((entity, text, {**kw, "message": message}))
165
 
 
 
 
 
166
 
167
  async def dispatch(client: FakeClient, event: Any) -> None:
168
  """Run ``event`` through the registered handlers like Telethon's dispatcher."""