github-actions[bot] commited on
Commit
6c3a31d
·
1 Parent(s): 21cde0c

Automated sync from GitHub

Browse files
.gitattributes CHANGED
@@ -1,4 +1,3 @@
1
- *.otf filter=lfs diff=lfs merge=lfs -text
2
  *.webp filter=lfs diff=lfs merge=lfs -text
3
  *.mp4 filter=lfs diff=lfs merge=lfs -text
4
  *.mp3 filter=lfs diff=lfs merge=lfs -text
@@ -6,3 +5,4 @@
6
  *.jpeg filter=lfs diff=lfs merge=lfs -text
7
  *.png filter=lfs diff=lfs merge=lfs -text
8
  *.ttf filter=lfs diff=lfs merge=lfs -text
 
 
 
1
  *.webp filter=lfs diff=lfs merge=lfs -text
2
  *.mp4 filter=lfs diff=lfs merge=lfs -text
3
  *.mp3 filter=lfs diff=lfs merge=lfs -text
 
5
  *.jpeg filter=lfs diff=lfs merge=lfs -text
6
  *.png filter=lfs diff=lfs merge=lfs -text
7
  *.ttf filter=lfs diff=lfs merge=lfs -text
8
+ *.otf filter=lfs diff=lfs merge=lfs -text
bot/app.py CHANGED
@@ -14,7 +14,7 @@ import time
14
  from dataclasses import dataclass
15
  from datetime import timezone
16
  from pathlib import Path
17
- from typing import Any, Awaitable, Callable, Dict, List, Optional, Set
18
 
19
  import telethon
20
  from telethon import errors, events, functions, types
@@ -25,7 +25,18 @@ from . import errorlog, media, mediainfo, panels, richtext, speed, sysinfo
25
  from .config import Config
26
  from .db import MAX_FORMATS, Database
27
  from .errors import FormatError
28
- from .formats import FIELD_KINDS, NAME_MAX, FormatSettings, validate_name
 
 
 
 
 
 
 
 
 
 
 
29
  from .parser import compute_new_name, missing_variables, parse_caption, parse_info, split_ext
30
  from .pipeline import PROBE_EXTS, Renamer, embedded_thumb, original_name_of, source_ext
31
  from .queue import Job, JobQueue
@@ -39,13 +50,13 @@ log = logging.getLogger(__name__)
39
  COMMANDS = [
40
  ("start", "Open the main menu"),
41
  ("help", "How to use the bot"),
42
- ("rename", "Rename files with a saved format"),
43
- ("formats", "Create and edit your saved formats"),
44
  ("sequence", "Collect files to sort by episode"),
45
  ("sort", "Sort the collected files by episode"),
46
  ("mode", "Choose Filename or Caption mode"),
47
- ("template", "Set the filename template"),
48
- ("caption", "Set the caption template"),
49
  ("filetype", "Choose how files are sent"),
50
  ("metadata", "Metadata and track settings"),
51
  ("keepaudio", "Keep audio tracks by language or number"),
@@ -55,8 +66,8 @@ COMMANDS = [
55
  ("mediainfo", "Full media details of a file on Telegraph (reply to it)"),
56
  ("ethumb", "Extract the thumbnail of a file or video"),
57
  ("resize", "Resize an image, for thumbnails"),
58
- ("thumb", "View your thumbnail"),
59
- ("delthumb", "Delete your thumbnail"),
60
  ("queue", "Show your queued files"),
61
  ("cancel", "Cancel your current job"),
62
  ("speed", "Test the bot's internet speed (Ookla)"),
@@ -70,7 +81,7 @@ BACK = {
70
  "mt_audio": "meta", "mt_subtitle": "meta", "mt_video": "meta",
71
  "keep_audio": "tool", "keep_subtitle": "tool",
72
  }
73
- PANEL_NAMES = {"home", "rename", "cap", "meta", "tool", "ftype", "help", "admin", "formats"}
74
  QUEUE_SETTLE = 1.5 # seconds an idle worker waits so files sent together are queued in message order
75
  ONE_FILE_WAIT = 600 # seconds the bot waits for the file after /rename (or /ethumb ...) before forgetting about it
76
  ONE_SHOT_GRACE = 8 # seconds after a one-file command during which further files get a hint, not an auto-rename
@@ -107,6 +118,7 @@ class Pending:
107
  is_photo: bool
108
  expires: float
109
  fid: int = 0 # the saved format a "fmt:*" prompt is about (0 = one that is being created)
 
110
 
111
 
112
  @dataclass
@@ -334,7 +346,7 @@ class App:
334
 
335
  def _command_table(self) -> Dict[str, Callable[[Any], Awaitable[None]]]:
336
  table: Dict[str, Callable[[Any], Awaitable[None]]] = {
337
- "start": self.cmd_start, "help": self.cmd_help, "rename": self.cmd_rename, "formats": self.cmd_formats,
338
  "sequence": self.cmd_sequence, "sort": self.cmd_sort, "mode": self.cmd_mode,
339
  "template": self.cmd_template, "caption": self.cmd_caption, "filetype": self.cmd_filetype,
340
  "metadata": self.cmd_metadata, "keepaudio": self._cmd_keep("keep_audio", "Keep Audio", "/keepaudio English Japanese"),
@@ -380,11 +392,12 @@ class App:
380
  async def panel(self, uid: int, name: str) -> panels.Panel:
381
  s = await self.db.get_settings(uid)
382
  if name == "home":
383
- return panels.home(s, owner=self.is_owner(uid))
384
  if name == "rename":
385
- return panels.rename(s)
386
  if name == "cap":
387
- return panels.caption(s, await self.db.has_thumb(uid))
 
388
  if name == "meta":
389
  return panels.metadata(s)
390
  if name == "tool":
@@ -394,7 +407,12 @@ class App:
394
  if name == "help":
395
  return panels.help_panel()
396
  if name == "formats":
397
- return panels.formats_menu(await self.db.list_formats(uid), MAX_FORMATS)
 
 
 
 
 
398
  if name == "admin":
399
  return panels.admin(
400
  self.cfg.owner_id, sorted(self.admins), self.queue.running, self.queue.waiting, self.cfg.log_channel
@@ -529,33 +547,45 @@ class App:
529
  text, rows = await self.panel(uid, "meta")
530
  await self._reply(event, text, rows)
531
 
 
 
 
 
532
  async def cmd_template(self, event: Any) -> None:
533
  arg = self._args(event)
534
  uid = event.sender_id
535
  if arg:
536
- _ok, msg = await apply_setting(self.db, uid, "template", arg)
537
  await self._reply(event, msg)
538
  return
539
- s = await self.db.get_settings(uid)
540
  await self._reply(
541
  event,
542
- f"<b>Current template:</b>\n<code>{esc(s['template'])}</code>\n\n{preview_template(s['template'])}\n\n"
 
543
  f"Variables: {panels.TEMPLATE_VARIABLES}\n"
 
544
  "A [bracket] group is dropped when a variable inside it is empty.\n\n"
545
  "Change it: <code>/template [S{season}E{episode}] {title} [{quality}]</code>",
546
  )
547
 
 
 
 
 
 
548
  async def cmd_caption(self, event: Any) -> None:
549
  arg = self._args(event)
550
  uid = event.sender_id
551
  if arg:
552
- _ok, msg = await apply_setting(self.db, uid, "caption", self._caption_input(event, event.pattern_match.start(1)))
 
553
  await self._reply(event, msg)
554
  return
555
- s = await self.db.get_settings(uid)
556
  await self._reply(
557
  event,
558
- f"<b>Current caption template:</b>\n{richtext.preview(s['caption'], 600)}\n\n"
559
  f"Variables: {panels.CAPTION_VARIABLES}\n"
560
  "Change it: send <code>/caption</code> and your caption in one message. Format it the way you do in any message "
561
  "(<b>bold</b>, <i>italic</i>, links…) and I'll keep it exactly like that.\n"
@@ -629,26 +659,49 @@ class App:
629
  await self.db.update_settings(uid, target_channel=cid)
630
  await self._reply(event, f"✅ Renamed files will go to <code>{cid}</code>." if cid else "✅ Renamed files will come back to this chat.")
631
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
632
  async def cmd_thumb(self, event: Any) -> None:
633
- data = await self.db.get_thumb(event.sender_id)
634
  if not data:
635
- await self._reply(event, "You have no thumbnail. Send me a photo to set one.")
636
  return
637
- await self._send_thumb(event.chat_id, data)
638
 
639
- async def _send_thumb(self, chat_id: int, data: bytes) -> None:
640
  bio = io.BytesIO(data)
641
  bio.name = "thumbnail.jpg"
642
  await self.client.send_file(
643
- chat_id, bio, caption="Your thumbnail", buttons=panels.to_buttons([[("🗑 Delete", "th:rm"), ("✖ Close", "x:close")]])
 
644
  )
645
 
646
  async def cmd_delthumb(self, event: Any) -> None:
647
- if await self.db.has_thumb(event.sender_id):
648
- await self.db.set_thumb(event.sender_id, None)
649
  await self._reply(event, "🗑 Thumbnail deleted.")
650
  else:
651
- await self._reply(event, "You have no thumbnail.")
652
 
653
  async def on_photo(self, event: Any) -> None:
654
  uid = event.sender_id
@@ -673,8 +726,12 @@ class App:
673
  if p is not None and p.kind == "fmt:thumb" and p.expires >= time.time():
674
  await self._format_thumb_saved(event, p, thumb)
675
  return
676
- await self.db.set_thumb(event.sender_id, thumb)
677
- await self._reply(event, "✅ Thumbnail saved. It will be used for the files I send back.", [[("🗑 Delete", "th:rm"), ("✖ Close", "x:close")]])
 
 
 
 
678
 
679
  async def on_gif(self, event: Any) -> None:
680
  if await self._image_mode(event):
@@ -692,7 +749,7 @@ class App:
692
  async def cmd_rename(self, event: Any) -> None:
693
  uid = event.sender_id
694
  self.pending.pop(uid, None)
695
- text, rows = panels.rename_picker(await self.db.list_formats(uid))
696
  await self._reply(event, text, rows)
697
 
698
  async def on_media(self, event: Any) -> None:
@@ -720,11 +777,11 @@ class App:
720
  if hint:
721
  await self._reply(event, hint)
722
  return
723
- await self.enqueue(event, event.message)
724
 
725
  async def _media_rename(self, event: Any, mode: Mode) -> None:
726
  fmt = await self.db.get_format(event.sender_id, mode.data["fid"])
727
- if fmt is None:
728
  await self._reply(event, "⚠️ That format was deleted. Use /rename to pick another one, then send the file again.")
729
  return
730
  await self.enqueue(event, event.message, fmt=fmt)
@@ -892,7 +949,7 @@ class App:
892
  s = await self.db.get_settings(uid)
893
  orig = original_name_of(msg)
894
  ext = source_ext(msg, orig)
895
- template = fmt["template"] if fmt else s["template"]
896
  new_name, info = compute_new_name(
897
  template=template, mode=s["mode"], original_name=orig, original_ext=ext,
898
  caption=msg.raw_text or "", remove_words=s["remove_words"],
@@ -911,7 +968,7 @@ class App:
911
  )
912
  ahead = self.queue.running + self.queue.waiting
913
  where = "starting soon" if ahead == 0 else f"{ahead} ahead of it"
914
- used = f"\n🗂 Format: <b>{esc(clip(fmt['name'], 30))}</b>" if fmt else ""
915
  job.status_msg = await event.reply(
916
  f"📥 <b>Queued</b> ({where}){used}\n<code>{esc(clip(new_name, 120))}</code>" + "".join(f"\n⚠️ {esc(n)}" for n in notes),
917
  buttons=panels.to_buttons([[("✖ Cancel", f"job:x:{job.id}")]]),
@@ -984,10 +1041,11 @@ class App:
984
  await self._reply(event, "Reply to a file with /check to see the details I can read from its name and caption.")
985
  return
986
  s = await self.db.get_settings(event.sender_id)
 
987
  name = original_name_of(reply)
988
  caption = reply.raw_text or ""
989
  new_name, _info = compute_new_name(
990
- template=s["template"], mode=s["mode"], original_name=name, original_ext=source_ext(reply, name),
991
  caption=caption, remove_words=s["remove_words"],
992
  )
993
  text = panels.check_text(s["mode"], parse_info(split_ext(name)[0]), parse_caption(caption), new_name)
@@ -1515,7 +1573,7 @@ class App:
1515
  await self._on_resize_text(event, p)
1516
  return
1517
  value = self._caption_input(event) if p.kind == "caption" else event.raw_text
1518
- ok, msg = await apply_setting(self.db, uid, p.kind, value)
1519
  if not ok:
1520
  await self._reply(event, msg + "\n\nTry again, or tap Cancel.")
1521
  return
@@ -1567,6 +1625,10 @@ class App:
1567
  await self._cb_shared(event, uid, parts)
1568
  return
1569
 
 
 
 
 
1570
  if head == "rn":
1571
  await self._cb_rename(event, uid, parts)
1572
  return
@@ -1596,7 +1658,7 @@ class App:
1596
 
1597
  if head == "clr" and len(parts) == 2:
1598
  if parts[1] == "caption":
1599
- _ok, msg = await apply_setting(self.db, uid, "caption", "reset")
1600
  panel = "cap"
1601
  elif parts[1] in ("keep_audio", "keep_subtitle"):
1602
  _ok, msg = await apply_setting(self.db, uid, parts[1], "off")
@@ -1609,9 +1671,13 @@ class App:
1609
  await event.answer(clip(plain(msg), 190))
1610
  return
1611
 
1612
- if head == "th" and len(parts) == 2:
 
 
 
 
1613
  if parts[1] in ("del", "rm"):
1614
- await self.db.set_thumb(uid, None)
1615
  if parts[1] == "rm": # button on a standalone thumbnail message
1616
  await event.answer("Thumbnail deleted.")
1617
  await self._remove_message(event)
@@ -1621,12 +1687,12 @@ class App:
1621
  await event.answer("Thumbnail deleted.")
1622
  return
1623
  if parts[1] == "view":
1624
- data_bytes = await self.db.get_thumb(uid)
1625
  if not data_bytes:
1626
- await event.answer("You have no thumbnail yet — send me a photo.", alert=True)
1627
  return
1628
  await event.answer()
1629
- await self._send_thumb(event.chat_id, data_bytes)
1630
  return
1631
 
1632
  if data == "help:meta":
@@ -1701,24 +1767,61 @@ class App:
1701
  await event.answer()
1702
 
1703
  # ------------------------------------------------------------ saved formats
 
 
 
 
 
 
 
 
 
 
 
1704
  async def _show_format(self, event: Any, uid: int, fid: int, note: str = "") -> bool:
1705
  fmt = await self.db.get_format(uid, fid)
1706
  if fmt is None:
1707
  return False
1708
- text, rows = panels.format_view(fmt, share_inline=await self._check_inline())
1709
  await self._edit_panel(event, f"{note}\n\n{text}" if note else text, rows)
1710
  return True
1711
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1712
  async def _cb_format(self, event: Any, uid: int, parts: List[str]) -> None:
1713
  self.pending.pop(uid, None) # leaving a prompt open by tapping something else abandons it
1714
  action = parts[1] if len(parts) > 1 else ""
1715
  fid = int(parts[2]) if len(parts) > 2 and parts[2].isdigit() else 0
1716
 
1717
- if action == "new":
1718
- if await self.db.count_formats(uid) >= MAX_FORMATS:
1719
- await event.answer(f"You already have {MAX_FORMATS} formats. Delete one first.", alert=True)
 
 
 
1720
  return
1721
- await self._format_prompt(event, uid, "new", 0)
 
 
 
 
 
 
1722
  return
1723
 
1724
  fmt = await self.db.get_format(uid, fid) if fid else None
@@ -1743,8 +1846,35 @@ class App:
1743
  await apply_setting(FormatSettings(self.db, fid), uid, "caption", "reset")
1744
  await self._show_format(event, uid, fid)
1745
  await event.answer("Caption reset.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1746
  elif action == "sh": # Share format, shown while inline mode wasn't known to be on: just try the real button
1747
- text, rows = panels.format_view(fmt, share_inline=True)
1748
  try:
1749
  await event.edit(text, buttons=panels.to_buttons(rows), parse_mode="html", link_preview=False)
1750
  except errors.MessageNotModifiedError:
@@ -1761,20 +1891,19 @@ class App:
1761
  await event.answer()
1762
  elif action == "ok":
1763
  await self.db.delete_format(uid, fid)
1764
- text, rows = await self.panel(uid, "formats")
1765
- await self._edit_panel(event, text, rows)
1766
  await event.answer(f"Deleted {clip(fmt['name'], 40)}.")
1767
  else:
1768
  await event.answer()
1769
 
1770
- async def _format_prompt(self, event: Any, uid: int, field: str, fid: int) -> None:
1771
  msg_obj = await event.get_message()
1772
  self.pending[uid] = Pending(
1773
  kind=f"fmt:{field}", chat_id=event.chat_id, msg_id=event.message_id, back="formats",
1774
  is_photo=bool(msg_obj is not None and (msg_obj.photo is not None or getattr(msg_obj, "media", None) is not None)),
1775
- expires=time.time() + self.PENDING_TTL, fid=fid,
1776
  )
1777
- text, rows = panels.format_prompt(field, fid)
1778
  await self._edit_panel(event, text, rows)
1779
  await event.answer()
1780
 
@@ -1789,8 +1918,11 @@ class App:
1789
  if field in ("new", "name"):
1790
  ok, value = validate_name(text)
1791
  if ok and field == "new":
1792
- fmt = await self.db.create_format(uid, value, template=(await self.db.get_settings(uid))["template"])
1793
- msg = f"✅ Format <b>{esc(value)}</b> created. Now set its template, caption, thumbnail and metadata."
 
 
 
1794
  p.fid = fmt["id"]
1795
  elif ok:
1796
  if await self.db.update_format(uid, p.fid, name=value) is None:
@@ -1804,7 +1936,7 @@ class App:
1804
  ok = False
1805
  if exc.code == "limit":
1806
  self.pending.pop(uid, None)
1807
- await self._reply(event, f"❌ You already have {MAX_FORMATS} formats. Delete one first.")
1808
  return
1809
  msg = "❌ You already have a format with that name."
1810
  except LookupError:
@@ -1835,7 +1967,7 @@ class App:
1835
  if fmt is None:
1836
  await self._reply(event, msg)
1837
  return
1838
- text, rows = panels.format_view(fmt, share_inline=await self._check_inline())
1839
  limit = 1000 if p.is_photo else 3900
1840
  full = f"{msg}\n\n{text}" if visible_len(msg) + visible_len(text) < limit else f"{msg}\n\n(Open the format to see the update.)"
1841
  try:
@@ -1945,7 +2077,7 @@ class App:
1945
  try:
1946
  mine = await self.db.copy_format(uid, shared[0], shared[1]["id"], name_max=NAME_MAX)
1947
  except FormatError: # only "limit" can be raised here
1948
- await event.answer(f"You already have {MAX_FORMATS} formats. Delete one first.", alert=True)
1949
  return
1950
  if mine is None: # the owner deleted it after sharing
1951
  await self._edit_panel(event, panels.SHARE_GONE, [[("✖ Close", "x:close")]])
@@ -1961,8 +2093,8 @@ class App:
1961
  action = parts[1] if len(parts) > 1 else ""
1962
  if action == "p" and len(parts) == 3 and parts[2].isdigit():
1963
  fmt = await self.db.get_format(uid, int(parts[2]))
1964
- if fmt is None:
1965
- text, rows = panels.rename_picker(await self.db.list_formats(uid))
1966
  await self._edit_panel(event, text, rows)
1967
  await event.answer("That format no longer exists.", alert=True)
1968
  return
@@ -1973,7 +2105,7 @@ class App:
1973
  elif action == "menu":
1974
  if self._mode(uid, "rename") is not None:
1975
  self._end_mode(uid)
1976
- text, rows = panels.rename_picker(await self.db.list_formats(uid))
1977
  await self._edit_panel(event, text, rows)
1978
  await event.answer()
1979
  elif action == "stop":
 
14
  from dataclasses import dataclass
15
  from datetime import timezone
16
  from pathlib import Path
17
+ from typing import Any, Awaitable, Callable, Dict, List, Optional, Set, Tuple
18
 
19
  import telethon
20
  from telethon import errors, events, functions, types
 
25
  from .config import Config
26
  from .db import MAX_FORMATS, Database
27
  from .errors import FormatError
28
+ from .formats import (
29
+ FIELD_KINDS,
30
+ KIND_LABELS,
31
+ NAME_MAX,
32
+ AutoFormatSettings,
33
+ FormatSettings,
34
+ default_auto_format,
35
+ default_metadata_changes,
36
+ has_default_metadata,
37
+ other_kind,
38
+ validate_name,
39
+ )
40
  from .parser import compute_new_name, missing_variables, parse_caption, parse_info, split_ext
41
  from .pipeline import PROBE_EXTS, Renamer, embedded_thumb, original_name_of, source_ext
42
  from .queue import Job, JobQueue
 
50
  COMMANDS = [
51
  ("start", "Open the main menu"),
52
  ("help", "How to use the bot"),
53
+ ("rename", "Rename one file with a manual format"),
54
+ ("formats", "Auto and manual rename formats"),
55
  ("sequence", "Collect files to sort by episode"),
56
  ("sort", "Sort the collected files by episode"),
57
  ("mode", "Choose Filename or Caption mode"),
58
+ ("template", "Set the template of your auto format"),
59
+ ("caption", "Set the caption of your auto format"),
60
  ("filetype", "Choose how files are sent"),
61
  ("metadata", "Metadata and track settings"),
62
  ("keepaudio", "Keep audio tracks by language or number"),
 
66
  ("mediainfo", "Full media details of a file on Telegraph (reply to it)"),
67
  ("ethumb", "Extract the thumbnail of a file or video"),
68
  ("resize", "Resize an image, for thumbnails"),
69
+ ("thumb", "View the thumbnail of your auto format"),
70
+ ("delthumb", "Delete the thumbnail of your auto format"),
71
  ("queue", "Show your queued files"),
72
  ("cancel", "Cancel your current job"),
73
  ("speed", "Test the bot's internet speed (Ookla)"),
 
81
  "mt_audio": "meta", "mt_subtitle": "meta", "mt_video": "meta",
82
  "keep_audio": "tool", "keep_subtitle": "tool",
83
  }
84
+ PANEL_NAMES = {"home", "rename", "cap", "meta", "tool", "ftype", "help", "admin", "formats", "auto"}
85
  QUEUE_SETTLE = 1.5 # seconds an idle worker waits so files sent together are queued in message order
86
  ONE_FILE_WAIT = 600 # seconds the bot waits for the file after /rename (or /ethumb ...) before forgetting about it
87
  ONE_SHOT_GRACE = 8 # seconds after a one-file command during which further files get a hint, not an auto-rename
 
118
  is_photo: bool
119
  expires: float
120
  fid: int = 0 # the saved format a "fmt:*" prompt is about (0 = one that is being created)
121
+ fmt_kind: str = "" # the kind (auto | manual) of the format that is being created
122
 
123
 
124
  @dataclass
 
346
 
347
  def _command_table(self) -> Dict[str, Callable[[Any], Awaitable[None]]]:
348
  table: Dict[str, Callable[[Any], Awaitable[None]]] = {
349
+ "start": self.cmd_start, "help": self.cmd_help, "rename": self.cmd_rename, "formats": self.cmd_formats, "format": self.cmd_formats,
350
  "sequence": self.cmd_sequence, "sort": self.cmd_sort, "mode": self.cmd_mode,
351
  "template": self.cmd_template, "caption": self.cmd_caption, "filetype": self.cmd_filetype,
352
  "metadata": self.cmd_metadata, "keepaudio": self._cmd_keep("keep_audio", "Keep Audio", "/keepaudio English Japanese"),
 
392
  async def panel(self, uid: int, name: str) -> panels.Panel:
393
  s = await self.db.get_settings(uid)
394
  if name == "home":
395
+ return panels.home(s, owner=self.is_owner(uid), auto=await self.db.get_auto_format(uid))
396
  if name == "rename":
397
+ return panels.rename(s, await self.db.get_auto_format(uid))
398
  if name == "cap":
399
+ auto = await self.db.get_auto_format(uid)
400
+ return panels.caption(auto, bool(auto and auto["has_thumb"]))
401
  if name == "meta":
402
  return panels.metadata(s)
403
  if name == "tool":
 
407
  if name == "help":
408
  return panels.help_panel()
409
  if name == "formats":
410
+ return panels.formats_home(
411
+ await self.db.count_formats(uid, "auto"), await self.db.count_formats(uid, "manual"), MAX_FORMATS
412
+ )
413
+ if name == "auto":
414
+ active = await self.db.get_auto_format(uid)
415
+ return panels.auto_menu(await self.db.list_formats(uid, "auto"), active["id"] if active else 0, MAX_FORMATS)
416
  if name == "admin":
417
  return panels.admin(
418
  self.cfg.owner_id, sorted(self.admins), self.queue.running, self.queue.waiting, self.cfg.log_channel
 
547
  text, rows = await self.panel(uid, "meta")
548
  await self._reply(event, text, rows)
549
 
550
+ def _store(self, kind: str) -> Any:
551
+ """What keeps a setting: the template and the caption are those of the auto format in use, the rest are the user's own."""
552
+ return AutoFormatSettings(self.db) if kind in ("template", "caption") else self.db
553
+
554
  async def cmd_template(self, event: Any) -> None:
555
  arg = self._args(event)
556
  uid = event.sender_id
557
  if arg:
558
+ _ok, msg = await apply_setting(self._store("template"), uid, "template", arg)
559
  await self._reply(event, msg)
560
  return
561
+ auto = await self.db.get_auto_format(uid) or default_auto_format()
562
  await self._reply(
563
  event,
564
+ f"<b>Template of your auto format{self._named(auto)}:</b>\n<code>{esc(auto['template'])}</code>\n\n"
565
+ f"{preview_template(auto['template'])}\n\n"
566
  f"Variables: {panels.TEMPLATE_VARIABLES}\n"
567
+ f"{panels.EPISODE_ZEROS}\n"
568
  "A [bracket] group is dropped when a variable inside it is empty.\n\n"
569
  "Change it: <code>/template [S{season}E{episode}] {title} [{quality}]</code>",
570
  )
571
 
572
+ @staticmethod
573
+ def _named(auto: Dict[str, Any]) -> str:
574
+ """`` “One Piece”`` for a saved auto format, nothing for the built-in default."""
575
+ return f" “{esc(clip(auto['name'], 30))}”" if auto.get("name") else ""
576
+
577
  async def cmd_caption(self, event: Any) -> None:
578
  arg = self._args(event)
579
  uid = event.sender_id
580
  if arg:
581
+ value = self._caption_input(event, event.pattern_match.start(1))
582
+ _ok, msg = await apply_setting(self._store("caption"), uid, "caption", value)
583
  await self._reply(event, msg)
584
  return
585
+ auto = await self.db.get_auto_format(uid) or default_auto_format()
586
  await self._reply(
587
  event,
588
+ f"<b>Caption of your auto format{self._named(auto)}:</b>\n{richtext.preview(auto['caption'], 600)}\n\n"
589
  f"Variables: {panels.CAPTION_VARIABLES}\n"
590
  "Change it: send <code>/caption</code> and your caption in one message. Format it the way you do in any message "
591
  "(<b>bold</b>, <i>italic</i>, links…) and I'll keep it exactly like that.\n"
 
659
  await self.db.update_settings(uid, target_channel=cid)
660
  await self._reply(event, f"✅ Renamed files will go to <code>{cid}</code>." if cid else "✅ Renamed files will come back to this chat.")
661
 
662
+ async def _thumb_format(self, uid: int, fid: Optional[int] = None) -> Optional[Dict[str, Any]]:
663
+ """The auto format a thumbnail command or button is about.
664
+
665
+ A button names its format (``th:rm:12``), so an old message never reaches the thumbnail of whichever format is in use
666
+ now; a command, and the buttons of messages from before that, mean the one in use. ``None``: there is no such format.
667
+ """
668
+ if fid is None:
669
+ return await self.db.get_auto_format(uid)
670
+ fmt = await self.db.get_format(uid, fid)
671
+ return fmt if fmt is not None and fmt["kind"] == "auto" else None
672
+
673
+ async def _auto_thumb(self, uid: int, fid: Optional[int] = None) -> Tuple[Optional[Dict[str, Any]], Optional[bytes]]:
674
+ """``(the auto format, its thumbnail)``; the thumbnail is ``None`` when the format has none."""
675
+ auto = await self._thumb_format(uid, fid)
676
+ return auto, (await self.db.get_format_thumb(uid, auto["id"]) if auto and auto["has_thumb"] else None)
677
+
678
+ async def _delete_auto_thumb(self, uid: int, fid: Optional[int] = None) -> bool:
679
+ """Remove the thumbnail of an auto format (the one in use unless ``fid`` names it); ``False`` when there is none."""
680
+ auto = await self._thumb_format(uid, fid)
681
+ if auto is None or not auto["has_thumb"]:
682
+ return False
683
+ return await self.db.set_format_thumb(uid, auto["id"], None)
684
+
685
  async def cmd_thumb(self, event: Any) -> None:
686
+ auto, data = await self._auto_thumb(event.sender_id)
687
  if not data:
688
+ await self._reply(event, "Your auto format has no thumbnail. Send me a photo to set one.")
689
  return
690
+ await self._send_thumb(event.chat_id, data, auto["id"])
691
 
692
+ async def _send_thumb(self, chat_id: int, data: bytes, fid: int) -> None:
693
  bio = io.BytesIO(data)
694
  bio.name = "thumbnail.jpg"
695
  await self.client.send_file(
696
+ chat_id, bio, caption="Thumbnail of your auto format",
697
+ buttons=panels.to_buttons([[("🗑 Delete", f"th:rm:{fid}"), ("✖ Close", "x:close")]]),
698
  )
699
 
700
  async def cmd_delthumb(self, event: Any) -> None:
701
+ if await self._delete_auto_thumb(event.sender_id):
 
702
  await self._reply(event, "🗑 Thumbnail deleted.")
703
  else:
704
+ await self._reply(event, "Your auto format has no thumbnail.")
705
 
706
  async def on_photo(self, event: Any) -> None:
707
  uid = event.sender_id
 
726
  if p is not None and p.kind == "fmt:thumb" and p.expires >= time.time():
727
  await self._format_thumb_saved(event, p, thumb)
728
  return
729
+ auto = await self.db.ensure_auto_format(uid)
730
+ await self.db.set_format_thumb(uid, auto["id"], thumb)
731
+ await self._reply(
732
+ event, f"✅ Thumbnail saved for your auto format <b>{esc(clip(auto['name'], 30))}</b>. It will be used for the files I send back.",
733
+ [[("🗑 Delete", f"th:rm:{auto['id']}"), ("✖ Close", "x:close")]],
734
+ )
735
 
736
  async def on_gif(self, event: Any) -> None:
737
  if await self._image_mode(event):
 
749
  async def cmd_rename(self, event: Any) -> None:
750
  uid = event.sender_id
751
  self.pending.pop(uid, None)
752
+ text, rows = panels.rename_picker(await self.db.list_formats(uid, "manual"))
753
  await self._reply(event, text, rows)
754
 
755
  async def on_media(self, event: Any) -> None:
 
777
  if hint:
778
  await self._reply(event, hint)
779
  return
780
+ await self.enqueue(event, event.message, fmt=await self.db.get_auto_format(uid)) # None: no auto format, the defaults
781
 
782
  async def _media_rename(self, event: Any, mode: Mode) -> None:
783
  fmt = await self.db.get_format(event.sender_id, mode.data["fid"])
784
+ if fmt is None or fmt["kind"] != "manual":
785
  await self._reply(event, "⚠️ That format was deleted. Use /rename to pick another one, then send the file again.")
786
  return
787
  await self.enqueue(event, event.message, fmt=fmt)
 
949
  s = await self.db.get_settings(uid)
950
  orig = original_name_of(msg)
951
  ext = source_ext(msg, orig)
952
+ template = fmt["template"] if fmt else default_auto_format()["template"]
953
  new_name, info = compute_new_name(
954
  template=template, mode=s["mode"], original_name=orig, original_ext=ext,
955
  caption=msg.raw_text or "", remove_words=s["remove_words"],
 
968
  )
969
  ahead = self.queue.running + self.queue.waiting
970
  where = "starting soon" if ahead == 0 else f"{ahead} ahead of it"
971
+ used = f"\n🗂 {'Auto format' if fmt['kind'] == 'auto' else 'Format'}: <b>{esc(clip(fmt['name'], 30))}</b>" if fmt else ""
972
  job.status_msg = await event.reply(
973
  f"📥 <b>Queued</b> ({where}){used}\n<code>{esc(clip(new_name, 120))}</code>" + "".join(f"\n⚠️ {esc(n)}" for n in notes),
974
  buttons=panels.to_buttons([[("✖ Cancel", f"job:x:{job.id}")]]),
 
1041
  await self._reply(event, "Reply to a file with /check to see the details I can read from its name and caption.")
1042
  return
1043
  s = await self.db.get_settings(event.sender_id)
1044
+ auto = await self.db.get_auto_format(event.sender_id) or default_auto_format()
1045
  name = original_name_of(reply)
1046
  caption = reply.raw_text or ""
1047
  new_name, _info = compute_new_name(
1048
+ template=auto["template"], mode=s["mode"], original_name=name, original_ext=source_ext(reply, name),
1049
  caption=caption, remove_words=s["remove_words"],
1050
  )
1051
  text = panels.check_text(s["mode"], parse_info(split_ext(name)[0]), parse_caption(caption), new_name)
 
1573
  await self._on_resize_text(event, p)
1574
  return
1575
  value = self._caption_input(event) if p.kind == "caption" else event.raw_text
1576
+ ok, msg = await apply_setting(self._store(p.kind), uid, p.kind, value)
1577
  if not ok:
1578
  await self._reply(event, msg + "\n\nTry again, or tap Cancel.")
1579
  return
 
1625
  await self._cb_shared(event, uid, parts)
1626
  return
1627
 
1628
+ if head == "au":
1629
+ await self._cb_auto(event, uid, parts)
1630
+ return
1631
+
1632
  if head == "rn":
1633
  await self._cb_rename(event, uid, parts)
1634
  return
 
1658
 
1659
  if head == "clr" and len(parts) == 2:
1660
  if parts[1] == "caption":
1661
+ _ok, msg = await apply_setting(self._store("caption"), uid, "caption", "reset")
1662
  panel = "cap"
1663
  elif parts[1] in ("keep_audio", "keep_subtitle"):
1664
  _ok, msg = await apply_setting(self.db, uid, parts[1], "off")
 
1671
  await event.answer(clip(plain(msg), 190))
1672
  return
1673
 
1674
+ if head == "th" and len(parts) in (2, 3) and parts[1] in ("del", "rm", "view") and (len(parts) == 2 or parts[2].isdigit()):
1675
+ fid = int(parts[2]) if len(parts) == 3 else None # the format the button belongs to (none: older buttons, the one in use)
1676
+ if fid is not None and await self._thumb_format(uid, fid) is None:
1677
+ await event.answer("That format no longer exists.", alert=True)
1678
+ return
1679
  if parts[1] in ("del", "rm"):
1680
+ await self._delete_auto_thumb(uid, fid)
1681
  if parts[1] == "rm": # button on a standalone thumbnail message
1682
  await event.answer("Thumbnail deleted.")
1683
  await self._remove_message(event)
 
1687
  await event.answer("Thumbnail deleted.")
1688
  return
1689
  if parts[1] == "view":
1690
+ auto, data_bytes = await self._auto_thumb(uid, fid)
1691
  if not data_bytes:
1692
+ await event.answer("Your auto format has no thumbnail yet — send me a photo.", alert=True)
1693
  return
1694
  await event.answer()
1695
+ await self._send_thumb(event.chat_id, data_bytes, auto["id"])
1696
  return
1697
 
1698
  if data == "help:meta":
 
1767
  await event.answer()
1768
 
1769
  # ------------------------------------------------------------ saved formats
1770
+ async def _format_panel(self, uid: int, fmt: Dict[str, Any], share_inline: Optional[bool] = None) -> panels.Panel:
1771
+ """A format's screen: for an auto format it says whether it is the one in use. ``share_inline`` defaults to whether
1772
+ inline mode is known to be on."""
1773
+ active = False
1774
+ if fmt["kind"] == "auto":
1775
+ current = await self.db.get_auto_format(uid)
1776
+ active = current is not None and current["id"] == fmt["id"]
1777
+ if share_inline is None:
1778
+ share_inline = await self._check_inline()
1779
+ return panels.format_view(fmt, share_inline=share_inline, active=active)
1780
+
1781
  async def _show_format(self, event: Any, uid: int, fid: int, note: str = "") -> bool:
1782
  fmt = await self.db.get_format(uid, fid)
1783
  if fmt is None:
1784
  return False
1785
+ text, rows = await self._format_panel(uid, fmt)
1786
  await self._edit_panel(event, f"{note}\n\n{text}" if note else text, rows)
1787
  return True
1788
 
1789
+ async def _show_format_list(self, event: Any, uid: int, kind: str) -> None:
1790
+ formats = await self.db.list_formats(uid, kind)
1791
+ active = await self.db.get_auto_format(uid) if kind == "auto" else None
1792
+ text, rows = panels.formats_menu(formats, MAX_FORMATS, kind, active["id"] if active else 0)
1793
+ await self._edit_panel(event, text, rows)
1794
+
1795
+ async def _cb_auto(self, event: Any, uid: int, parts: List[str]) -> None:
1796
+ """A button of the /start menu's Auto Format screen: use that auto format from now on."""
1797
+ self.pending.pop(uid, None)
1798
+ fmt = await self.db.select_auto_format(uid, int(parts[1])) if len(parts) == 2 and parts[1].isdigit() else None
1799
+ text, rows = await self.panel(uid, "auto")
1800
+ await self._edit_panel(event, text, rows)
1801
+ if fmt is None:
1802
+ await event.answer("That format no longer exists.", alert=True)
1803
+ else:
1804
+ await event.answer(f"Auto format: {clip(fmt['name'], 40)}")
1805
+
1806
  async def _cb_format(self, event: Any, uid: int, parts: List[str]) -> None:
1807
  self.pending.pop(uid, None) # leaving a prompt open by tapping something else abandons it
1808
  action = parts[1] if len(parts) > 1 else ""
1809
  fid = int(parts[2]) if len(parts) > 2 and parts[2].isdigit() else 0
1810
 
1811
+ if action in ("l", "new"):
1812
+ kind = parts[2] if len(parts) > 2 else ""
1813
+ if kind not in ("auto", "manual"): # a button of a message from before there were two kinds: start from the choice
1814
+ text, rows = await self.panel(uid, "formats")
1815
+ await self._edit_panel(event, text, rows)
1816
+ await event.answer()
1817
  return
1818
+ if action == "l":
1819
+ await self._show_format_list(event, uid, kind)
1820
+ await event.answer()
1821
+ elif await self.db.count_formats(uid, kind) >= MAX_FORMATS:
1822
+ await event.answer(f"You already have {MAX_FORMATS} {kind} formats. Delete one first.", alert=True)
1823
+ else:
1824
+ await self._format_prompt(event, uid, "new", 0, kind)
1825
  return
1826
 
1827
  fmt = await self.db.get_format(uid, fid) if fid else None
 
1846
  await apply_setting(FormatSettings(self.db, fid), uid, "caption", "reset")
1847
  await self._show_format(event, uid, fid)
1848
  await event.answer("Caption reset.")
1849
+ elif action == "use":
1850
+ if await self.db.select_auto_format(uid, fid) is None:
1851
+ await event.answer("Only an auto format can be used for auto rename.", alert=True)
1852
+ return
1853
+ await self._show_format(event, uid, fid, note=f"✅ <b>{esc(clip(fmt['name'], 30))}</b> now renames your files.")
1854
+ await event.answer(f"Auto format: {clip(fmt['name'], 40)}")
1855
+ elif action == "dm":
1856
+ settings = await self.db.get_settings(uid)
1857
+ if not has_default_metadata(settings):
1858
+ await event.answer("Your default metadata is empty. Set it in /metadata first, then apply it.", alert=True)
1859
+ return
1860
+ await self.db.update_format(uid, fid, **default_metadata_changes(settings))
1861
+ await self._show_format(event, uid, fid, note=panels.format_metadata_applied(fmt))
1862
+ await event.answer("Default metadata applied.")
1863
+ elif action == "cp":
1864
+ target = other_kind(fmt["kind"])
1865
+ try:
1866
+ made = await self.db.copy_format(uid, uid, fid, name_max=NAME_MAX, kind=target)
1867
+ except FormatError: # only "limit" can be raised here
1868
+ await event.answer(f"You already have {MAX_FORMATS} {target} formats. Delete one first.", alert=True)
1869
+ return
1870
+ if made is None:
1871
+ await event.answer("That format no longer exists.", alert=True)
1872
+ return
1873
+ text, rows = panels.format_copied(fmt, made)
1874
+ await self._edit_panel(event, text, rows)
1875
+ await event.answer(f"Copied to your {target} formats.")
1876
  elif action == "sh": # Share format, shown while inline mode wasn't known to be on: just try the real button
1877
+ text, rows = await self._format_panel(uid, fmt, share_inline=True)
1878
  try:
1879
  await event.edit(text, buttons=panels.to_buttons(rows), parse_mode="html", link_preview=False)
1880
  except errors.MessageNotModifiedError:
 
1891
  await event.answer()
1892
  elif action == "ok":
1893
  await self.db.delete_format(uid, fid)
1894
+ await self._show_format_list(event, uid, fmt["kind"])
 
1895
  await event.answer(f"Deleted {clip(fmt['name'], 40)}.")
1896
  else:
1897
  await event.answer()
1898
 
1899
+ async def _format_prompt(self, event: Any, uid: int, field: str, fid: int, kind: str = "") -> None:
1900
  msg_obj = await event.get_message()
1901
  self.pending[uid] = Pending(
1902
  kind=f"fmt:{field}", chat_id=event.chat_id, msg_id=event.message_id, back="formats",
1903
  is_photo=bool(msg_obj is not None and (msg_obj.photo is not None or getattr(msg_obj, "media", None) is not None)),
1904
+ expires=time.time() + self.PENDING_TTL, fid=fid, fmt_kind=kind,
1905
  )
1906
+ text, rows = panels.format_prompt(field, fid, kind)
1907
  await self._edit_panel(event, text, rows)
1908
  await event.answer()
1909
 
 
1918
  if field in ("new", "name"):
1919
  ok, value = validate_name(text)
1920
  if ok and field == "new":
1921
+ fmt = await self.db.create_format(uid, value, kind=p.fmt_kind or "manual")
1922
+ msg = (
1923
+ f"✅ {KIND_LABELS[fmt['kind']]} format <b>{esc(value)}</b> created. Set its template, caption, "
1924
+ "thumbnail and metadata, or tap Apply default metadata."
1925
+ )
1926
  p.fid = fmt["id"]
1927
  elif ok:
1928
  if await self.db.update_format(uid, p.fid, name=value) is None:
 
1936
  ok = False
1937
  if exc.code == "limit":
1938
  self.pending.pop(uid, None)
1939
+ await self._reply(event, f"❌ You already have {MAX_FORMATS} {p.fmt_kind or 'manual'} formats. Delete one first.")
1940
  return
1941
  msg = "❌ You already have a format with that name."
1942
  except LookupError:
 
1967
  if fmt is None:
1968
  await self._reply(event, msg)
1969
  return
1970
+ text, rows = await self._format_panel(event.sender_id, fmt)
1971
  limit = 1000 if p.is_photo else 3900
1972
  full = f"{msg}\n\n{text}" if visible_len(msg) + visible_len(text) < limit else f"{msg}\n\n(Open the format to see the update.)"
1973
  try:
 
2077
  try:
2078
  mine = await self.db.copy_format(uid, shared[0], shared[1]["id"], name_max=NAME_MAX)
2079
  except FormatError: # only "limit" can be raised here
2080
+ await event.answer(f"You already have {MAX_FORMATS} {shared[1]['kind']} formats. Delete one first.", alert=True)
2081
  return
2082
  if mine is None: # the owner deleted it after sharing
2083
  await self._edit_panel(event, panels.SHARE_GONE, [[("✖ Close", "x:close")]])
 
2093
  action = parts[1] if len(parts) > 1 else ""
2094
  if action == "p" and len(parts) == 3 and parts[2].isdigit():
2095
  fmt = await self.db.get_format(uid, int(parts[2]))
2096
+ if fmt is None or fmt["kind"] != "manual":
2097
+ text, rows = panels.rename_picker(await self.db.list_formats(uid, "manual"))
2098
  await self._edit_panel(event, text, rows)
2099
  await event.answer("That format no longer exists.", alert=True)
2100
  return
 
2105
  elif action == "menu":
2106
  if self._mode(uid, "rename") is not None:
2107
  self._end_mode(uid)
2108
+ text, rows = panels.rename_picker(await self.db.list_formats(uid, "manual"))
2109
  await self._edit_panel(event, text, rows)
2110
  await event.answer()
2111
  elif action == "stop":
bot/db.py CHANGED
@@ -5,6 +5,7 @@ external Postgres (Neon, Supabase, ...). SQLite works for local testing.
5
  """
6
  from __future__ import annotations
7
 
 
8
  import copy
9
  import json
10
  import os
@@ -22,10 +23,12 @@ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
22
  from .errors import FormatError
23
  from .parser import DEFAULT_TEMPLATE
24
 
 
 
 
 
25
  DEFAULT_SETTINGS: Dict[str, Any] = {
26
  "mode": "filename", # filename | caption
27
- "template": DEFAULT_TEMPLATE,
28
- "caption": "<b>{filename}</b>",
29
  "file_type": "same", # same | document | video | audio
30
  "remove_words": [],
31
  "metadata_on": False,
@@ -33,19 +36,27 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
33
  "keep_audio": [],
34
  "keep_subtitle": [],
35
  "target_channel": None,
 
36
  }
37
 
38
 
39
  # Captions used to be plain. Whoever still has that default stored gets the bold one, once (see Database._migrate).
40
  OLD_DEFAULT_CAPTION = "{filename}"
41
  BOLD_CAPTION_MIGRATION = "migrated:bold_caption"
42
-
43
- MAX_FORMATS = 12
44
- # what a saved /rename format holds; everything else (mode, file type, ...) comes from the user's settings. A format never
45
- # borrows the user's own caption: without a caption of its own it sends the new file name in bold.
 
 
 
 
 
 
 
46
  DEFAULT_FORMAT: Dict[str, Any] = {
47
  "template": DEFAULT_TEMPLATE,
48
- "caption": DEFAULT_SETTINGS["caption"],
49
  "meta": {"title": "", "author": "", "artist": "", "audio": "", "subtitle": "", "video": ""},
50
  "keep_audio": [],
51
  "keep_subtitle": [],
@@ -65,6 +76,8 @@ class User(Base):
65
 
66
  id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False)
67
  settings: Mapped[str] = mapped_column(Text, default="{}")
 
 
68
  thumb: Mapped[Optional[bytes]] = mapped_column(LargeBinary, nullable=True)
69
  files_renamed: Mapped[int] = mapped_column(Integer, default=0)
70
  bytes_processed: Mapped[int] = mapped_column(BigInteger, default=0)
@@ -144,6 +157,7 @@ def _merge_settings(stored: Dict[str, Any]) -> Dict[str, Any]:
144
 
145
  def _merge_format(stored: Dict[str, Any]) -> Dict[str, Any]:
146
  merged = copy.deepcopy(DEFAULT_FORMAT)
 
147
  for key in ("template", "caption"):
148
  if isinstance(stored.get(key), str) and stored[key].strip():
149
  merged[key] = stored[key]
@@ -155,16 +169,30 @@ def _merge_format(stored: Dict[str, Any]) -> Dict[str, Any]:
155
  return merged
156
 
157
 
158
- def _format_dict(fid: int, name: str, raw: Optional[str], has_thumb: bool) -> Dict[str, Any]:
159
  try:
160
  stored = json.loads(raw or "{}")
161
  except ValueError:
162
- stored = {}
163
- out = _merge_format(stored if isinstance(stored, dict) else {})
 
 
 
 
 
 
 
 
 
164
  out.update(id=int(fid), name=name, has_thumb=bool(has_thumb))
165
  return out
166
 
167
 
 
 
 
 
 
168
  def _free_name(name: str, taken: Iterable[str], name_max: int) -> str:
169
  """``name``, or ``name (2)``, ``name (3)`` ... when the user already has a format called that (case aside)."""
170
  used = {n.casefold() for n in taken}
@@ -190,6 +218,7 @@ class Database:
190
  self.engine: AsyncEngine = create_async_engine(self.url, pool_pre_ping=True, connect_args=connect_args)
191
  self._sessions = async_sessionmaker(self.engine, expire_on_commit=False)
192
  self._cache: Dict[int, Dict[str, Any]] = {}
 
193
 
194
  async def init(self) -> None:
195
  async with self.engine.begin() as conn:
@@ -198,19 +227,56 @@ class Database:
198
 
199
  async def _migrate(self) -> None:
200
  """Changes to what earlier versions stored, each made once: a marker in the kv table says it was done."""
201
- if await self.kv_get(BOLD_CAPTION_MIGRATION) is not None:
202
- return
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
203
  async with self._sessions() as session:
204
  for row in (await session.execute(select(User))).scalars().all():
205
  try:
206
  stored = json.loads(row.settings or "{}")
207
  except ValueError:
208
  continue
209
- if isinstance(stored, dict) and stored.get("caption") == OLD_DEFAULT_CAPTION:
210
- stored["caption"] = DEFAULT_SETTINGS["caption"]
211
- row.settings = json.dumps(stored, ensure_ascii=False)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
  await session.commit()
213
- await self.kv_set(BOLD_CAPTION_MIGRATION, "done")
214
 
215
  async def close(self) -> None:
216
  await self.engine.dispose()
@@ -255,22 +321,6 @@ class Database:
255
  self._cache[uid] = current
256
  return copy.deepcopy(current)
257
 
258
- async def set_thumb(self, uid: int, data: Optional[bytes]) -> None:
259
- async with self._sessions() as session:
260
- row = await self._get_or_create(session, uid)
261
- row.thumb = data
262
- await session.commit()
263
-
264
- async def get_thumb(self, uid: int) -> Optional[bytes]:
265
- async with self._sessions() as session:
266
- row = await session.get(User, uid)
267
- return bytes(row.thumb) if row is not None and row.thumb else None
268
-
269
- async def has_thumb(self, uid: int) -> bool:
270
- async with self._sessions() as session:
271
- result = await session.execute(select(User.thumb.isnot(None)).where(User.id == uid))
272
- return bool(result.scalar())
273
-
274
  async def record_job(self, uid: int, size: int) -> None:
275
  async with self._sessions() as session:
276
  row = await self._get_or_create(session, uid)
@@ -291,17 +341,18 @@ class Database:
291
  ).one()
292
  return int(row[0]), int(row[1])
293
 
294
- # ----- saved /rename formats ------------------------------------------------------
295
- async def list_formats(self, uid: int) -> List[Dict[str, Any]]:
 
296
  async with self._sessions() as session:
297
  result = await session.execute(
298
  select(Format.id, Format.name, Format.data, Format.thumb.isnot(None)).where(Format.user_id == uid).order_by(Format.id)
299
  )
300
- return [_format_dict(r[0], r[1], r[2], r[3]) for r in result.all()]
 
301
 
302
- async def count_formats(self, uid: int) -> int:
303
- async with self._sessions() as session:
304
- return int((await session.execute(select(func.count()).select_from(Format).where(Format.user_id == uid))).scalar() or 0)
305
 
306
  async def get_format(self, uid: int, fid: int) -> Optional[Dict[str, Any]]:
307
  async with self._sessions() as session:
@@ -311,23 +362,33 @@ class Database:
311
  r = result.first()
312
  return _format_dict(r[0], r[1], r[2], r[3]) if r else None
313
 
314
- async def create_format(self, uid: int, name: str, template: Optional[str] = None) -> Dict[str, Any]:
315
- """Add a format; raises ``FormatError`` when the user already has ``MAX_FORMATS`` or the name is taken."""
 
 
 
 
 
 
 
 
 
 
316
  async with self._sessions() as session:
317
- names = list((await session.execute(select(Format.name).where(Format.user_id == uid))).scalars())
318
  if len(names) >= MAX_FORMATS:
319
  raise FormatError("limit")
320
  if name.casefold() in {n.casefold() for n in names}:
321
  raise FormatError("taken")
322
- data = copy.deepcopy(DEFAULT_FORMAT)
323
  if template:
324
  data["template"] = template
325
- row = Format(user_id=uid, name=name, data=json.dumps(data, ensure_ascii=False))
326
  session.add(row)
327
  await session.flush()
328
- fid = row.id
329
  await session.commit()
330
- return _format_dict(fid, name, json.dumps(data, ensure_ascii=False), False)
331
 
332
  async def update_format(self, uid: int, fid: int, **changes: Any) -> Optional[Dict[str, Any]]:
333
  """Change ``name``, ``template``, ``caption``, ``keep_audio``, ``keep_subtitle`` or merge a ``meta`` dict; ``None`` if it is gone."""
@@ -338,10 +399,7 @@ class Database:
338
  current = _format_dict(row.id, row.name, row.data, False)
339
  for key, value in changes.items():
340
  if key == "name":
341
- others = (
342
- await session.execute(select(Format.name).where(Format.user_id == uid, Format.id != fid))
343
- ).scalars()
344
- if value.casefold() in {n.casefold() for n in others}:
345
  raise FormatError("taken")
346
  row.name = value
347
  elif key == "meta":
@@ -350,7 +408,7 @@ class Database:
350
  current[key] = value
351
  else:
352
  raise KeyError(key)
353
- row.data = json.dumps({k: current[k] for k in DEFAULT_FORMAT}, ensure_ascii=False)
354
  has_thumb = row.thumb is not None
355
  await session.commit()
356
  return _format_dict(row.id, row.name, row.data, has_thumb)
@@ -415,25 +473,73 @@ class Database:
415
  ).first()
416
  return (int(share.user_id), _format_dict(r[0], r[1], r[2], r[3])) if r else None
417
 
418
- async def copy_format(self, uid: int, owner_id: int, fid: int, name_max: int = 64) -> Optional[Dict[str, Any]]:
 
 
419
  """Give ``uid`` a copy of ``owner_id``'s format (thumbnail included), under a free name.
420
 
421
- ``None`` when that format is gone; ``FormatError("limit")`` when ``uid`` already has ``MAX_FORMATS``.
 
 
422
  """
 
 
423
  async with self._sessions() as session:
424
  source = await session.get(Format, fid)
425
  if source is None or source.user_id != owner_id:
426
  return None
427
- names = list((await session.execute(select(Format.name).where(Format.user_id == uid))).scalars())
 
428
  if len(names) >= MAX_FORMATS:
429
  raise FormatError("limit")
430
  name = _free_name(source.name, names, name_max)
431
- row = Format(user_id=uid, name=name, data=source.data, thumb=source.thumb)
 
432
  session.add(row)
433
  await session.flush()
434
- new_id, raw, has_thumb = row.id, source.data, source.thumb is not None
435
  await session.commit()
436
- return _format_dict(new_id, name, raw, has_thumb)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
437
 
438
  # ----- admins ------------------------------------------------------------------
439
  async def list_admins(self) -> List[int]:
 
5
  """
6
  from __future__ import annotations
7
 
8
+ import asyncio
9
  import copy
10
  import json
11
  import os
 
23
  from .errors import FormatError
24
  from .parser import DEFAULT_TEMPLATE
25
 
26
+ DEFAULT_CAPTION = "<b>{filename}</b>" # the new file name in bold
27
+ # The user's own settings. What names, captions and thumbnails a file gets is not here: it is in the saved formats (below).
28
+ # The default metadata (``metadata_on``, ``meta``, ``keep_*``) is a preset: it applies to auto renames whose format has no
29
+ # metadata of its own, and a format can copy it ("Apply default metadata").
30
  DEFAULT_SETTINGS: Dict[str, Any] = {
31
  "mode": "filename", # filename | caption
 
 
32
  "file_type": "same", # same | document | video | audio
33
  "remove_words": [],
34
  "metadata_on": False,
 
36
  "keep_audio": [],
37
  "keep_subtitle": [],
38
  "target_channel": None,
39
+ "auto_format": 0, # id of the auto format that renames the files sent without /rename (0 = none saved: the built-in defaults)
40
  }
41
 
42
 
43
  # Captions used to be plain. Whoever still has that default stored gets the bold one, once (see Database._migrate).
44
  OLD_DEFAULT_CAPTION = "{filename}"
45
  BOLD_CAPTION_MIGRATION = "migrated:bold_caption"
46
+ # The global template, caption and thumbnail used to be the settings of auto renames. They are now an auto format of their own.
47
+ AUTO_FORMATS_MIGRATION = "migrated:auto_formats"
48
+ MIGRATED_FORMAT_NAME = "Default"
49
+
50
+ MAX_FORMATS = 12 # per kind: 12 auto formats and 12 manual ones
51
+ # Two kinds of format: ``auto`` renames every file the user sends (the one in use is picked from the /start menu), ``manual``
52
+ # is picked with /rename for one file. Formats saved before there were two kinds are manual.
53
+ FORMAT_KINDS = ("auto", "manual")
54
+ DEFAULT_KIND = "manual"
55
+ # what a saved format holds; everything else (mode, file type, ...) comes from the user's settings. A format never
56
+ # borrows a caption from anywhere else: without a caption of its own it sends the new file name in bold.
57
  DEFAULT_FORMAT: Dict[str, Any] = {
58
  "template": DEFAULT_TEMPLATE,
59
+ "caption": DEFAULT_CAPTION,
60
  "meta": {"title": "", "author": "", "artist": "", "audio": "", "subtitle": "", "video": ""},
61
  "keep_audio": [],
62
  "keep_subtitle": [],
 
76
 
77
  id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False)
78
  settings: Mapped[str] = mapped_column(Text, default="{}")
79
+ # The old global thumbnail. Thumbnails belong to the formats now; this column is only read once, by the migration
80
+ # (create_all() never alters a table, so the column stays).
81
  thumb: Mapped[Optional[bytes]] = mapped_column(LargeBinary, nullable=True)
82
  files_renamed: Mapped[int] = mapped_column(Integer, default=0)
83
  bytes_processed: Mapped[int] = mapped_column(BigInteger, default=0)
 
157
 
158
  def _merge_format(stored: Dict[str, Any]) -> Dict[str, Any]:
159
  merged = copy.deepcopy(DEFAULT_FORMAT)
160
+ merged["kind"] = stored["kind"] if stored.get("kind") in FORMAT_KINDS else DEFAULT_KIND
161
  for key in ("template", "caption"):
162
  if isinstance(stored.get(key), str) and stored[key].strip():
163
  merged[key] = stored[key]
 
169
  return merged
170
 
171
 
172
+ def _stored(raw: Optional[str]) -> Dict[str, Any]:
173
  try:
174
  stored = json.loads(raw or "{}")
175
  except ValueError:
176
+ return {}
177
+ return stored if isinstance(stored, dict) else {}
178
+
179
+
180
+ def _kind_of(raw: Optional[str]) -> str:
181
+ """The kind of the format a row holds (``auto`` | ``manual``)."""
182
+ return _merge_format(_stored(raw))["kind"]
183
+
184
+
185
+ def _format_dict(fid: int, name: str, raw: Optional[str], has_thumb: bool) -> Dict[str, Any]:
186
+ out = _merge_format(_stored(raw))
187
  out.update(id=int(fid), name=name, has_thumb=bool(has_thumb))
188
  return out
189
 
190
 
191
+ def _format_json(fmt: Dict[str, Any]) -> str:
192
+ """What is stored for a format: its fields and its kind."""
193
+ return json.dumps({**{k: fmt[k] for k in DEFAULT_FORMAT}, "kind": fmt["kind"]}, ensure_ascii=False)
194
+
195
+
196
  def _free_name(name: str, taken: Iterable[str], name_max: int) -> str:
197
  """``name``, or ``name (2)``, ``name (3)`` ... when the user already has a format called that (case aside)."""
198
  used = {n.casefold() for n in taken}
 
218
  self.engine: AsyncEngine = create_async_engine(self.url, pool_pre_ping=True, connect_args=connect_args)
219
  self._sessions = async_sessionmaker(self.engine, expire_on_commit=False)
220
  self._cache: Dict[int, Dict[str, Any]] = {}
221
+ self._default_locks: Dict[int, asyncio.Lock] = {}
222
 
223
  async def init(self) -> None:
224
  async with self.engine.begin() as conn:
 
227
 
228
  async def _migrate(self) -> None:
229
  """Changes to what earlier versions stored, each made once: a marker in the kv table says it was done."""
230
+ if await self.kv_get(BOLD_CAPTION_MIGRATION) is None:
231
+ async with self._sessions() as session:
232
+ for row in (await session.execute(select(User))).scalars().all():
233
+ stored = _stored(row.settings)
234
+ if stored.get("caption") == OLD_DEFAULT_CAPTION:
235
+ stored["caption"] = DEFAULT_CAPTION
236
+ row.settings = json.dumps(stored, ensure_ascii=False)
237
+ await session.commit()
238
+ await self.kv_set(BOLD_CAPTION_MIGRATION, "done")
239
+ if await self.kv_get(AUTO_FORMATS_MIGRATION) is None:
240
+ await self._migrate_auto_formats()
241
+ await self.kv_set(AUTO_FORMATS_MIGRATION, "done")
242
+
243
+ async def _migrate_auto_formats(self) -> None:
244
+ """The global template, caption and thumbnail become an auto format called "Default", which is the one in use.
245
+
246
+ Whoever never changed them has nothing to keep and gets no format (auto renames then use the built-in defaults). The
247
+ metadata is not copied: it stays where it is as the default metadata, and an auto format with no metadata of its own uses
248
+ it while it is On, which is how it always applied to auto renames (and edits to it still count). Formats that had no
249
+ thumbnail of their own used the global one: they keep using it.
250
+ A user is migrated once: the ``auto_format`` key marks it (the rows are rewritten in one transaction).
251
+ """
252
  async with self._sessions() as session:
253
  for row in (await session.execute(select(User))).scalars().all():
254
  try:
255
  stored = json.loads(row.settings or "{}")
256
  except ValueError:
257
  continue
258
+ if not isinstance(stored, dict) or "auto_format" in stored:
259
+ continue
260
+ old = _merge_format({"template": stored.get("template"), "caption": stored.get("caption")})
261
+ thumb = bytes(row.thumb) if row.thumb else None
262
+ keeps_something = old["template"] != DEFAULT_FORMAT["template"] or old["caption"] != DEFAULT_FORMAT["caption"] or thumb
263
+ fid = 0
264
+ if keeps_something:
265
+ made = Format(user_id=row.id, name=MIGRATED_FORMAT_NAME, data=_format_json({**old, "kind": "auto"}), thumb=thumb)
266
+ session.add(made)
267
+ await session.flush()
268
+ fid = made.id
269
+ if thumb:
270
+ for fmt in (await session.execute(select(Format).where(Format.user_id == row.id, Format.thumb.is_(None)))).scalars():
271
+ if _kind_of(fmt.data) == "manual":
272
+ fmt.thumb = thumb
273
+ row.thumb = None
274
+ stored.pop("template", None)
275
+ stored.pop("caption", None)
276
+ stored["auto_format"] = fid
277
+ row.settings = json.dumps(stored, ensure_ascii=False)
278
  await session.commit()
279
+ self._cache.clear()
280
 
281
  async def close(self) -> None:
282
  await self.engine.dispose()
 
321
  self._cache[uid] = current
322
  return copy.deepcopy(current)
323
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
324
  async def record_job(self, uid: int, size: int) -> None:
325
  async with self._sessions() as session:
326
  row = await self._get_or_create(session, uid)
 
341
  ).one()
342
  return int(row[0]), int(row[1])
343
 
344
+ # ----- saved formats (auto and manual) ----------------------------------------------
345
+ async def list_formats(self, uid: int, kind: Optional[str] = None) -> List[Dict[str, Any]]:
346
+ """The user's formats, oldest first: all of them, or only those of one ``kind``."""
347
  async with self._sessions() as session:
348
  result = await session.execute(
349
  select(Format.id, Format.name, Format.data, Format.thumb.isnot(None)).where(Format.user_id == uid).order_by(Format.id)
350
  )
351
+ formats = [_format_dict(r[0], r[1], r[2], r[3]) for r in result.all()]
352
+ return [f for f in formats if kind is None or f["kind"] == kind]
353
 
354
+ async def count_formats(self, uid: int, kind: Optional[str] = None) -> int:
355
+ return len(await self.list_formats(uid, kind))
 
356
 
357
  async def get_format(self, uid: int, fid: int) -> Optional[Dict[str, Any]]:
358
  async with self._sessions() as session:
 
362
  r = result.first()
363
  return _format_dict(r[0], r[1], r[2], r[3]) if r else None
364
 
365
+ @staticmethod
366
+ async def _names(session: Any, uid: int, kind: str, *, except_id: int = 0) -> List[str]:
367
+ """The names of the user's formats of one kind (a name only has to be free among its own kind)."""
368
+ rows = await session.execute(select(Format.id, Format.name, Format.data).where(Format.user_id == uid))
369
+ return [r[1] for r in rows.all() if r[0] != except_id and _kind_of(r[2]) == kind]
370
+
371
+ async def create_format(
372
+ self, uid: int, name: str, template: Optional[str] = None, kind: str = DEFAULT_KIND
373
+ ) -> Dict[str, Any]:
374
+ """Add a format of ``kind``; raises ``FormatError`` when the user already has ``MAX_FORMATS`` of that kind or the name is taken."""
375
+ if kind not in FORMAT_KINDS:
376
+ raise KeyError(kind)
377
  async with self._sessions() as session:
378
+ names = await self._names(session, uid, kind)
379
  if len(names) >= MAX_FORMATS:
380
  raise FormatError("limit")
381
  if name.casefold() in {n.casefold() for n in names}:
382
  raise FormatError("taken")
383
+ data = {**copy.deepcopy(DEFAULT_FORMAT), "kind": kind}
384
  if template:
385
  data["template"] = template
386
+ row = Format(user_id=uid, name=name, data=_format_json(data))
387
  session.add(row)
388
  await session.flush()
389
+ fid, raw = row.id, row.data
390
  await session.commit()
391
+ return _format_dict(fid, name, raw, False)
392
 
393
  async def update_format(self, uid: int, fid: int, **changes: Any) -> Optional[Dict[str, Any]]:
394
  """Change ``name``, ``template``, ``caption``, ``keep_audio``, ``keep_subtitle`` or merge a ``meta`` dict; ``None`` if it is gone."""
 
399
  current = _format_dict(row.id, row.name, row.data, False)
400
  for key, value in changes.items():
401
  if key == "name":
402
+ if value.casefold() in {n.casefold() for n in await self._names(session, uid, current["kind"], except_id=fid)}:
 
 
 
403
  raise FormatError("taken")
404
  row.name = value
405
  elif key == "meta":
 
408
  current[key] = value
409
  else:
410
  raise KeyError(key)
411
+ row.data = _format_json(current)
412
  has_thumb = row.thumb is not None
413
  await session.commit()
414
  return _format_dict(row.id, row.name, row.data, has_thumb)
 
473
  ).first()
474
  return (int(share.user_id), _format_dict(r[0], r[1], r[2], r[3])) if r else None
475
 
476
+ async def copy_format(
477
+ self, uid: int, owner_id: int, fid: int, name_max: int = 64, kind: Optional[str] = None
478
+ ) -> Optional[Dict[str, Any]]:
479
  """Give ``uid`` a copy of ``owner_id``'s format (thumbnail included), under a free name.
480
 
481
+ The copy is of the same kind as the original unless ``kind`` says otherwise: that is how a user copies an auto format
482
+ to the manual ones and the other way round (``owner_id`` is then the user themselves). ``None`` when that format is
483
+ gone; ``FormatError("limit")`` when ``uid`` already has ``MAX_FORMATS`` of that kind.
484
  """
485
+ if kind is not None and kind not in FORMAT_KINDS:
486
+ raise KeyError(kind)
487
  async with self._sessions() as session:
488
  source = await session.get(Format, fid)
489
  if source is None or source.user_id != owner_id:
490
  return None
491
+ target = kind or _kind_of(source.data)
492
+ names = await self._names(session, uid, target)
493
  if len(names) >= MAX_FORMATS:
494
  raise FormatError("limit")
495
  name = _free_name(source.name, names, name_max)
496
+ data = _format_json({**_merge_format(_stored(source.data)), "kind": target})
497
+ row = Format(user_id=uid, name=name, data=data, thumb=source.thumb)
498
  session.add(row)
499
  await session.flush()
500
+ new_id, has_thumb = row.id, source.thumb is not None
501
  await session.commit()
502
+ return _format_dict(new_id, name, data, has_thumb)
503
+
504
+ # ----- the auto format in use ----------------------------------------------------------
505
+ async def get_auto_format(self, uid: int) -> Optional[Dict[str, Any]]:
506
+ """The auto format that renames the user's files, or ``None`` when they have none (the built-in defaults are used then).
507
+
508
+ Having auto formats always means having one in use: when the chosen one is gone (deleted) or was never chosen, the oldest
509
+ takes over and is remembered.
510
+ """
511
+ chosen = (await self.get_settings(uid))["auto_format"]
512
+ formats = await self.list_formats(uid, "auto")
513
+ if not formats:
514
+ if chosen:
515
+ await self.update_settings(uid, auto_format=0)
516
+ return None
517
+ current = next((f for f in formats if f["id"] == chosen), formats[0])
518
+ if current["id"] != chosen:
519
+ await self.update_settings(uid, auto_format=current["id"])
520
+ return current
521
+
522
+ async def select_auto_format(self, uid: int, fid: int) -> Optional[Dict[str, Any]]:
523
+ """Use this auto format for the user's files from now on; ``None`` when there is no such auto format."""
524
+ fmt = await self.get_format(uid, fid)
525
+ if fmt is None or fmt["kind"] != "auto":
526
+ return None
527
+ await self.update_settings(uid, auto_format=fid)
528
+ return fmt
529
+
530
+ async def ensure_auto_format(self, uid: int) -> Dict[str, Any]:
531
+ """The auto format in use; the first time there is none, a format called "Default" is made and used.
532
+
533
+ That is what lets ``/template``, ``/caption`` and a thumbnail photo work for someone who never made a format. Several photos
534
+ sent at once ask for it at the same moment, so one user is served at a time: the first makes it, the others find it.
535
+ """
536
+ async with self._default_locks.setdefault(uid, asyncio.Lock()):
537
+ fmt = await self.get_auto_format(uid)
538
+ if fmt is not None:
539
+ return fmt
540
+ fmt = await self.create_format(uid, MIGRATED_FORMAT_NAME, kind="auto")
541
+ await self.update_settings(uid, auto_format=fmt["id"])
542
+ return fmt
543
 
544
  # ----- admins ------------------------------------------------------------------
545
  async def list_admins(self) -> List[int]:
bot/formats.py CHANGED
@@ -1,18 +1,24 @@
1
- """Saved /rename formats: the pieces shared by the menus, the handlers and the pipeline.
2
 
3
  A format is a named bundle of a naming template, a caption, a thumbnail, the six metadata titles and the Keep
4
  Audio / Keep Subtitle lists. Every other setting (mode, file type, remove words, target channel) still comes
5
- from the user's normal auto-rename settings. The caption never does: a format that was given none sends the new
6
- file name in bold, whatever caption the user set for their own renames.
 
 
 
 
7
  """
8
  from __future__ import annotations
9
 
10
  import copy
11
  from typing import Any, Dict, Optional, Tuple
12
 
13
- from .db import DEFAULT_SETTINGS, Database
14
 
15
  NAME_MAX = 30
 
 
16
  # format field -> the kind settings_ops.apply_setting() understands
17
  FIELD_KINDS: Dict[str, str] = {
18
  "template": "template", "caption": "caption",
@@ -23,6 +29,10 @@ FIELD_KINDS: Dict[str, str] = {
23
  _EDITABLE = ("template", "caption", "meta", "keep_audio", "keep_subtitle")
24
 
25
 
 
 
 
 
26
  def validate_name(text: Optional[str]) -> Tuple[bool, str]:
27
  """``(True, cleaned name)`` or ``(False, html error)``."""
28
  name = " ".join((text or "").split())
@@ -37,26 +47,59 @@ def has_metadata(fmt: Dict[str, Any]) -> bool:
37
  return any(fmt["meta"].values()) or bool(fmt["keep_audio"] or fmt["keep_subtitle"])
38
 
39
 
40
- def apply_format(settings: Dict[str, Any], fmt: Dict[str, Any]) -> Dict[str, Any]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  """The user's settings with the format's template, caption, metadata and keep lists laid over them.
42
 
43
- Metadata is on whenever the format sets anything, whatever the global Metadata switch says: the format
44
- carries its own metadata, and a format with none leaves the file alone (no ffmpeg pass).
 
 
 
45
  """
 
46
  out = copy.deepcopy(settings)
47
  out["template"] = fmt["template"]
48
- out["caption"] = fmt.get("caption") or DEFAULT_SETTINGS["caption"] # never the user's own caption
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  out["meta"] = dict(fmt["meta"])
50
- out["keep_audio"] = list(fmt["keep_audio"])
51
- out["keep_subtitle"] = list(fmt["keep_subtitle"])
52
- out["metadata_on"] = has_metadata(fmt)
53
  return out
54
 
55
 
56
  class FormatSettings:
57
  """Edits one saved format through the two methods ``apply_setting`` uses on ``Database``.
58
 
59
- That keeps a single copy of every validation rule and message for the global settings and the formats.
60
  Metadata is always "on" here, so the "metadata is off" notes never show for a format.
61
  """
62
 
@@ -68,16 +111,30 @@ class FormatSettings:
68
  fmt = await self.db.get_format(uid, self.fid)
69
  if fmt is None:
70
  raise LookupError("that format no longer exists")
71
- out = copy.deepcopy(DEFAULT_SETTINGS)
72
- out.update(
73
- template=fmt["template"], caption=fmt["caption"], keep_audio=list(fmt["keep_audio"]),
74
- keep_subtitle=list(fmt["keep_subtitle"]), metadata_on=True,
75
- )
76
- out["meta"] = dict(fmt["meta"])
77
- return out
78
 
79
  async def update_settings(self, uid: int, **changes: Any) -> Dict[str, Any]:
80
  wanted = {k: v for k, v in changes.items() if k in _EDITABLE}
81
  if wanted and await self.db.update_format(uid, self.fid, **wanted) is None:
82
  raise LookupError("that format no longer exists")
83
  return await self.get_settings(uid)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Saved formats (auto and manual): the pieces shared by the menus, the handlers and the pipeline.
2
 
3
  A format is a named bundle of a naming template, a caption, a thumbnail, the six metadata titles and the Keep
4
  Audio / Keep Subtitle lists. Every other setting (mode, file type, remove words, target channel) still comes
5
+ from the user's normal settings. The caption never does: a format that was given none sends the new
6
+ file name in bold.
7
+
8
+ There are two kinds, twelve of each. An **auto** format renames every file the user sends (the one in use is chosen from
9
+ the /start menu); a **manual** format is chosen with /rename and renames one file. The user's default metadata is a preset:
10
+ a format can copy it ("Apply default metadata"), and an auto format that has no metadata of its own uses it while it is On.
11
  """
12
  from __future__ import annotations
13
 
14
  import copy
15
  from typing import Any, Dict, Optional, Tuple
16
 
17
+ from .db import DEFAULT_CAPTION, DEFAULT_FORMAT, DEFAULT_KIND, DEFAULT_SETTINGS, Database
18
 
19
  NAME_MAX = 30
20
+ KIND_LABELS = {"auto": "Auto", "manual": "Manual"}
21
+ KIND_ICONS = {"auto": "🤖", "manual": "✋"}
22
  # format field -> the kind settings_ops.apply_setting() understands
23
  FIELD_KINDS: Dict[str, str] = {
24
  "template": "template", "caption": "caption",
 
29
  _EDITABLE = ("template", "caption", "meta", "keep_audio", "keep_subtitle")
30
 
31
 
32
+ def other_kind(kind: str) -> str:
33
+ return "manual" if kind == "auto" else "auto"
34
+
35
+
36
  def validate_name(text: Optional[str]) -> Tuple[bool, str]:
37
  """``(True, cleaned name)`` or ``(False, html error)``."""
38
  name = " ".join((text or "").split())
 
47
  return any(fmt["meta"].values()) or bool(fmt["keep_audio"] or fmt["keep_subtitle"])
48
 
49
 
50
+ def has_default_metadata(settings: Dict[str, Any]) -> bool:
51
+ """Has the user set any default metadata (a title or the like, or a Keep Audio / Keep Subtitle list)?"""
52
+ return any(settings["meta"].values()) or bool(settings["keep_audio"] or settings["keep_subtitle"])
53
+
54
+
55
+ def default_metadata_changes(settings: Dict[str, Any]) -> Dict[str, Any]:
56
+ """What ``Database.update_format`` takes to make a format's metadata the user's default metadata."""
57
+ return dict(meta=dict(settings["meta"]), keep_audio=list(settings["keep_audio"]), keep_subtitle=list(settings["keep_subtitle"]))
58
+
59
+
60
+ def default_auto_format() -> Dict[str, Any]:
61
+ """What renames the files of a user who has no auto format: the built-in template and caption, no thumbnail, no metadata."""
62
+ out = copy.deepcopy(DEFAULT_FORMAT)
63
+ out.update(kind="auto", id=0, name="", has_thumb=False)
64
+ return out
65
+
66
+
67
+ def apply_format(settings: Dict[str, Any], fmt: Optional[Dict[str, Any]]) -> Dict[str, Any]:
68
  """The user's settings with the format's template, caption, metadata and keep lists laid over them.
69
 
70
+ ``fmt`` is ``None`` for an auto rename of a user who has no auto format: the built-in defaults.
71
+
72
+ Metadata is on whenever the format sets anything, whatever the Metadata switch says: the format carries its own
73
+ metadata. A manual format with none leaves the file alone (no ffmpeg pass). An auto format with none leaves the user's
74
+ default metadata in charge: it applies while its switch is On, as it always did for auto renames.
75
  """
76
+ fmt = fmt or default_auto_format()
77
  out = copy.deepcopy(settings)
78
  out["template"] = fmt["template"]
79
+ out["caption"] = fmt.get("caption") or DEFAULT_CAPTION # never a caption from anywhere else
80
+ if has_metadata(fmt) or fmt.get("kind", DEFAULT_KIND) == "manual":
81
+ out["meta"] = dict(fmt["meta"])
82
+ out["keep_audio"] = list(fmt["keep_audio"])
83
+ out["keep_subtitle"] = list(fmt["keep_subtitle"])
84
+ out["metadata_on"] = has_metadata(fmt)
85
+ return out
86
+
87
+
88
+ def _as_settings(fmt: Dict[str, Any]) -> Dict[str, Any]:
89
+ """A format shaped like the user's settings, which is what ``apply_setting`` reads."""
90
+ out = copy.deepcopy(DEFAULT_SETTINGS)
91
+ out.update(
92
+ template=fmt["template"], caption=fmt["caption"], keep_audio=list(fmt["keep_audio"]),
93
+ keep_subtitle=list(fmt["keep_subtitle"]), metadata_on=True,
94
+ )
95
  out["meta"] = dict(fmt["meta"])
 
 
 
96
  return out
97
 
98
 
99
  class FormatSettings:
100
  """Edits one saved format through the two methods ``apply_setting`` uses on ``Database``.
101
 
102
+ That keeps a single copy of every validation rule and message for the user's settings and the formats.
103
  Metadata is always "on" here, so the "metadata is off" notes never show for a format.
104
  """
105
 
 
111
  fmt = await self.db.get_format(uid, self.fid)
112
  if fmt is None:
113
  raise LookupError("that format no longer exists")
114
+ return _as_settings(fmt)
 
 
 
 
 
 
115
 
116
  async def update_settings(self, uid: int, **changes: Any) -> Dict[str, Any]:
117
  wanted = {k: v for k, v in changes.items() if k in _EDITABLE}
118
  if wanted and await self.db.update_format(uid, self.fid, **wanted) is None:
119
  raise LookupError("that format no longer exists")
120
  return await self.get_settings(uid)
121
+
122
+
123
+ class AutoFormatSettings:
124
+ """The auto format in use, edited the way a setting is: ``/template``, ``/caption`` and their buttons go through here.
125
+
126
+ A user who has no auto format gets one called "Default" the first time they change something.
127
+ """
128
+
129
+ def __init__(self, db: Database):
130
+ self.db = db
131
+
132
+ async def get_settings(self, uid: int) -> Dict[str, Any]:
133
+ return _as_settings(await self.db.get_auto_format(uid) or default_auto_format())
134
+
135
+ async def update_settings(self, uid: int, **changes: Any) -> Dict[str, Any]:
136
+ wanted = {k: v for k, v in changes.items() if k in _EDITABLE}
137
+ fmt = await self.db.ensure_auto_format(uid)
138
+ if wanted:
139
+ fmt = await self.db.update_format(uid, fmt["id"], **wanted) or fmt
140
+ return _as_settings(fmt)
bot/panels.py CHANGED
@@ -6,10 +6,11 @@ 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, Sequence, Tuple
10
 
11
  from . import richtext
12
- from .db import DEFAULT_SETTINGS
 
13
  from .media import LANGUAGES, canonical_lang
14
  from .parser import CAPTION_VARS, TEMPLATE_VARS
15
  from .utils import clip, code_each, esc
@@ -22,6 +23,11 @@ DOT = "◈"
22
  # The variable lists shown in the panels and prompts: every variable is its own <code> block, so tapping one copies only it.
23
  TEMPLATE_VARIABLES = code_each("{%s}" % name for name in TEMPLATE_VARS)
24
  CAPTION_VARIABLES = code_each("{%s}" % name for name in CAPTION_VARS if name != "ext") # {ext} was never listed for captions
 
 
 
 
 
25
 
26
 
27
  INLINE = "inline:" # a button whose data starts with this opens Telegram's chat picker, with "@bot <the rest>" typed in the chat
@@ -80,26 +86,39 @@ def fmt_tokens(tokens: Iterable[str]) -> str:
80
  # ---------------------------------------------------------------------------
81
 
82
 
83
- def home(s: Dict[str, Any], *, owner: bool) -> Panel:
 
 
 
 
 
 
 
 
 
 
84
  channel = f"channel <code>{s['target_channel']}</code>" if s.get("target_channel") else "this chat"
 
85
  text = (
86
  "<b>🏷 Auto Rename Bot</b>\n\n"
87
- "Send me a file (document, video or audio) and I'll rename it with your template and send it back.\n\n"
88
  f"<b>Mode:</b> <code>{s['mode'].upper()}</code> · <b>Type:</b> <code>{s['file_type'].upper()}</code>"
89
- f" · <b>Metadata:</b> <code>{'ON' if s['metadata_on'] else 'OFF'}</code>\n"
90
- f"<b>Template:</b> {_code(s['template'], 120)}\n"
 
91
  f"<b>Send to:</b> {channel}"
92
  )
93
  rows: List[Row] = [
94
- [("Rename", "m:rename"), ("Caption & Thumbnail", "m:cap")],
95
- [("Metadata", "m:meta"), ("File Type", "m:ftype")],
96
- [("Formats", "m:formats"), ("Help", "m:help")] + ([("Admin", "m:admin")] if owner else []),
 
97
  [("✖ Close", "x:close")],
98
  ]
99
  return text, rows
100
 
101
 
102
- def rename(s: Dict[str, Any]) -> Panel:
103
  words = ", ".join(s["remove_words"]) or "—"
104
  text = (
105
  "<b>RENAME MODE</b>\n\n"
@@ -107,7 +126,7 @@ def rename(s: Dict[str, Any]) -> Panel:
107
  "Caption mode reads details from the message caption.\n\n"
108
  f"Current mode: <code>{s['mode'].upper()}</code>\n"
109
  "Command: /mode\n\n"
110
- f"<b>Template:</b> {_code(s['template'], 120)}\n"
111
  f"<b>Remove words:</b> {esc(clip(words, 100))}"
112
  )
113
  rows: List[Row] = [
@@ -118,19 +137,22 @@ def rename(s: Dict[str, Any]) -> Panel:
118
  return text, rows
119
 
120
 
121
- def caption(s: Dict[str, Any], has_thumb: bool) -> Panel:
 
122
  text = (
123
- "<b>CAPTION &amp; THUMBNAIL</b>\n\n"
124
- f"<b>Caption template:</b> {richtext.preview(s['caption'], 160)}\n"
 
125
  f"Variables: {CAPTION_VARIABLES}\n"
126
  "Tap Set Caption and format it the way you do in any message (<b>bold</b>, <i>italic</i>, links…): "
127
  "I keep it exactly like that.\n\n"
128
  f"<b>Thumbnail:</b> {'✅ saved' if has_thumb else '❌ none (a frame of the video is used)'}\n"
129
- "Send me a photo to set your thumbnail."
130
  )
 
131
  rows: List[Row] = [
132
  [("Set Caption", "in:caption"), ("Reset Caption", "clr:caption")],
133
- [("View Thumb", "th:view"), ("Delete Thumb", "th:del")],
134
  ] + nav("home")
135
  return text, rows
136
 
@@ -138,12 +160,14 @@ def caption(s: Dict[str, Any], has_thumb: bool) -> Panel:
138
  def metadata(s: Dict[str, Any]) -> Panel:
139
  m = s["meta"]
140
  on = s["metadata_on"]
141
- lines = [f"<b>🏷 Your metadata is currently: {'On' if on else 'Off'}</b>", ""]
142
  for label, key in (("Title", "title"), ("Author", "author"), ("Artist", "artist"),
143
  ("Audio", "audio"), ("Subtitle", "subtitle"), ("Video", "video")):
144
  lines.append(f"{DOT} <b>{label}</b> ▸ {_code(m.get(key, ''), 60)}")
145
  lines.append(f"{DOT} <b>Keep Audio</b> ▸ {esc(fmt_tokens(s['keep_audio']))}")
146
  lines.append(f"{DOT} <b>Keep Subtitle</b> ▸ {esc(fmt_tokens(s['keep_subtitle']))}")
 
 
147
  rows: List[Row] = [
148
  [(_check("On", on), "set:meta:on"), (_check("Off", not on), "set:meta:off")],
149
  [("Title", "in:mt_title"), ("Author", "in:mt_author"), ("Artist", "in:mt_artist")],
@@ -209,17 +233,17 @@ def filetype(s: Dict[str, Any]) -> Panel:
209
  def help_panel() -> Panel:
210
  text = (
211
  "<b>How to use</b>\n\n"
212
- "1. Send a file — I rename it with your template and send it back.\n"
213
- "2. /formats — save up to 12 named formats (template, caption, thumbnail, metadata, tracks) and share them; "
214
- "/rename — pick one, then send a file (send /rename again for the next one).\n"
215
- "3. /template and /caption — set the naming and caption templates.\n"
216
- "4. /mode — read details from the file name or the caption.\n"
217
- "5. /metadata, /keepaudio, /keepsubtitle — tags and track clean-up.\n"
218
- "6. /filetype — send as document, video or audio.\n"
219
- "7. Send a photo — it becomes your thumbnail.\n"
220
- "8. /sequence then /sort — put files in episode order.\n"
221
- "9. /check, /mediainfo, /ethumb, /resize, /speed — inspect files and images.\n"
222
- "10. /queue and /cancel — manage running jobs.\n\n"
223
  f"Template variables: {TEMPLATE_VARIABLES}\n"
224
  "A [bracket] group is dropped when a variable inside it is empty."
225
  )
@@ -254,11 +278,12 @@ FORMAT_FIELD_LABELS = (
254
  )
255
 
256
 
257
- def _format_buttons(formats: Sequence[Dict[str, Any]], prefix: str) -> List[Row]:
 
258
  rows: List[Row] = []
259
  row: Row = []
260
  for f in formats:
261
- row.append((clip(f["name"], 24), f"{prefix}:{f['id']}"))
262
  if len(row) == 2:
263
  rows.append(row)
264
  row = []
@@ -267,26 +292,64 @@ def _format_buttons(formats: Sequence[Dict[str, Any]], prefix: str) -> List[Row]
267
  return rows
268
 
269
 
270
- def formats_menu(formats: Sequence[Dict[str, Any]], limit: int) -> Panel:
 
271
  text = (
272
- f"<b>🗂 FORMATS</b> ({len(formats)}/{limit})\n\n"
273
  "A format saves its own template, caption, thumbnail, metadata titles and Keep Audio / Keep Subtitle lists "
274
- "under a name, like <i>One Piece</i> or <i>Dragon Ball</i>.\n"
275
- "Tap one to view or edit it, then use /rename to apply it to your files."
 
 
276
  )
 
 
 
 
 
 
 
 
 
 
 
277
  if not formats:
278
- text += "\n\nYou have no formats yet."
279
- rows = _format_buttons(formats, "fm:v")
280
  if len(formats) < limit:
281
- rows.append([("➕ New format", "fm:new")])
282
  else:
283
- text += f"\n\nYou have all {limit} formats. Delete one to add another."
284
- return text, rows + nav("home")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
285
 
286
 
287
  def _custom_caption(fmt: Dict[str, Any]) -> bool:
288
  """Has the format a caption of its own? Otherwise it sends the new file name in bold."""
289
- return fmt["caption"] != DEFAULT_SETTINGS["caption"]
 
 
 
290
 
291
 
292
  def _format_lines(fmt: Dict[str, Any], *, thumb_yes: str, thumb_no: str) -> List[str]:
@@ -304,12 +367,21 @@ def _format_lines(fmt: Dict[str, Any], *, thumb_yes: str, thumb_no: str) -> List
304
  return lines
305
 
306
 
307
- def format_view(fmt: Dict[str, Any], *, share_inline: bool = False) -> Panel:
308
- """A format's screen. ``share_inline``: the Share format button opens the chat picker straight away; that only works
309
- while inline mode is on for the bot (Telegram refuses the message otherwise), so without it the button explains how."""
310
- lines = [f"<b>🗂 {esc(clip(fmt['name'], 30))}</b>", ""]
311
- lines += _format_lines(fmt, thumb_yes="✅ saved", thumb_no="— (your default thumbnail is used)")
312
- lines += ["", "Use it with /rename. Mode, file type and the rest come from your normal settings."]
 
 
 
 
 
 
 
 
 
313
  fid = fmt["id"]
314
  thumb_row: Row = [("Thumbnail", f"fm:th:{fid}")]
315
  if fmt.get("has_thumb"):
@@ -324,41 +396,82 @@ def format_view(fmt: Dict[str, Any], *, share_inline: bool = False) -> Panel:
324
  [("Title", f"fm:in:{fid}:title"), ("Author", f"fm:in:{fid}:author"), ("Artist", f"fm:in:{fid}:artist")],
325
  [("Audio", f"fm:in:{fid}:audio"), ("Subtitle", f"fm:in:{fid}:subtitle"), ("Video", f"fm:in:{fid}:video")],
326
  [("Keep Audio", f"fm:in:{fid}:keep_audio"), ("Keep Subtitle", f"fm:in:{fid}:keep_subtitle")],
 
 
 
 
 
 
327
  [("📤 Share format", INLINE + share_query(fid) if share_inline else f"fm:sh:{fid}")],
328
  [("🗑 Delete format", f"fm:del:{fid}")],
329
- ] + nav("formats")
 
330
  return "\n".join(lines), rows
331
 
332
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
333
  INLINE_OFF = "Sharing needs inline mode. In @BotFather: /mybots → this bot → Bot Settings → Inline Mode → Turn on. Then tap Share format again."
334
  SHARE_GONE = "⚠️ <b>That shared format isn't available anymore.</b>\nThe person who shared it deleted it. Ask them to share it again."
335
 
336
 
 
 
 
 
 
337
  def share_card(fmt: Dict[str, Any]) -> Tuple[str, str, str]:
338
  """``(title, description, html text)`` of the card that is sent to the chat the owner picked."""
339
  name = clip(fmt["name"], 30)
 
340
  return (
341
  f"🗂 {name}",
342
  "Tap to send this format to the chat",
343
- f"📦 <b>{esc(name)}</b>\nA rename format for this bot. Open it to see what's in it and add it to your own formats.",
344
  )
345
 
346
 
347
  def shared_format(fmt: Dict[str, Any], token: str, *, mine: bool = False) -> Panel:
348
  """What opening a shared format shows: what is in it, and a button to add it to your own formats."""
349
- lines = [f"<b>📦 {esc(clip(fmt['name'], 30))}</b>", "This is your own format." if mine else "A rename format shared with you.", ""]
350
- lines += _format_lines(fmt, thumb_yes="✅ included", thumb_no="— (your default thumbnail is used)")
 
 
 
 
 
 
351
  if mine:
352
  lines += ["", "Whoever you send it to can look at it here and add a copy to their own formats."]
353
  rows: List[Row] = [[("🗂 Open format", f"fm:v:{fmt['id']}")]]
354
  else:
355
- lines += ["", "Add it to your formats to use it with /rename. You can change anything in your copy afterwards."]
356
  rows = [[("➕ Add to my formats", f"fs:add:{token}")]]
357
  return "\n".join(lines), rows + [[("✖ Close", "x:close")]]
358
 
359
 
360
  def shared_added(fmt: Dict[str, Any]) -> Panel:
361
- text = f"✅ <b>{esc(clip(fmt['name'], 30))}</b> is now in your formats.\nUse it with /rename, or open it to change anything."
 
 
362
  return text, [[("🗂 Open format", f"fm:v:{fmt['id']}")], [("✖ Close", "x:close")]]
363
 
364
 
@@ -368,14 +481,16 @@ def format_delete_confirm(fmt: Dict[str, Any]) -> Panel:
368
 
369
 
370
  def rename_picker(formats: Sequence[Dict[str, Any]]) -> Panel:
 
371
  if not formats:
372
  text = (
373
- "<b>🏷 RENAME</b>\n\nYou have no saved formats yet.\n"
374
- "A format keeps a template, caption, thumbnail and metadata under a name, like <i>One Piece</i>. Create one, then come back here."
 
375
  )
376
- return text, [[("➕ Create a format", "fm:new")], [("✖ Close", "x:close")]]
377
- text = "<b>🏷 RENAME</b>\n\nChoose the format to rename with. I'll ask for your files next."
378
- rows = _format_buttons(formats, "rn:p") + [[("⚙️ Manage formats", "m:formats"), ("✖ Close", "x:close")]]
379
  return text, rows
380
 
381
 
@@ -505,6 +620,7 @@ PROMPTS: Dict[str, str] = {
505
  "template": (
506
  "<b>Send the new filename template.</b>\n\n"
507
  f"Variables: {TEMPLATE_VARIABLES}\n"
 
508
  "A [bracket] or (paren) group disappears when a variable inside it is empty.\n"
509
  "If only an episode number is found, the season defaults to 01.\n\n"
510
  "Example: <code>[S{season}E{episode}] {title} [{quality}]</code>"
@@ -563,9 +679,9 @@ FORMAT_PROMPTS: Dict[str, str] = {
563
  }
564
 
565
 
566
- def format_prompt(field: str, fid: int) -> Panel:
567
- """The "send me the value" screen for one field of a format (``fid`` 0 = a format that is being created)."""
568
- cancel = f"fm:cx:{fid}" if fid else "m:formats"
569
  return FORMAT_PROMPTS[field], [[("✖ Cancel", cancel)]]
570
 
571
 
 
6
  """
7
  from __future__ import annotations
8
 
9
+ from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
10
 
11
  from . import richtext
12
+ from .db import DEFAULT_CAPTION, DEFAULT_FORMAT
13
+ from .formats import KIND_ICONS, KIND_LABELS, has_metadata, other_kind
14
  from .media import LANGUAGES, canonical_lang
15
  from .parser import CAPTION_VARS, TEMPLATE_VARS
16
  from .utils import clip, code_each, esc
 
23
  # The variable lists shown in the panels and prompts: every variable is its own <code> block, so tapping one copies only it.
24
  TEMPLATE_VARIABLES = code_each("{%s}" % name for name in TEMPLATE_VARS)
25
  CAPTION_VARIABLES = code_each("{%s}" % name for name in CAPTION_VARS if name != "ext") # {ext} was never listed for captions
26
+ # What {1episode} ... {3episode} do, in one line (they are the episode with zeros in front, 2 to 4 digits wide)
27
+ EPISODE_ZEROS = (
28
+ "Zeros in front of the episode: <code>{1episode}</code> makes it 2 digits (4 → 04), <code>{2episode}</code> 3 digits (4 → 004), "
29
+ "<code>{3episode}</code> 4 digits (4 → 0004). A number that already has that many digits stays as it is."
30
+ )
31
 
32
 
33
  INLINE = "inline:" # a button whose data starts with this opens Telegram's chat picker, with "@bot <the rest>" typed in the chat
 
86
  # ---------------------------------------------------------------------------
87
 
88
 
89
+ def _auto_name(auto: Optional[Dict[str, Any]]) -> str:
90
+ """The auto format in use, for a panel; ``auto`` is ``None`` for someone who has none."""
91
+ return f"<b>{esc(clip(auto['name'], 30))}</b>" if auto else "none saved (the default is used)"
92
+
93
+
94
+ def _template_of(auto: Optional[Dict[str, Any]]) -> str:
95
+ return auto["template"] if auto else DEFAULT_FORMAT["template"]
96
+
97
+
98
+ def home(s: Dict[str, Any], *, owner: bool, auto: Optional[Dict[str, Any]] = None) -> Panel:
99
+ """The main menu. ``auto`` is the auto format in use (``None``: the user has none)."""
100
  channel = f"channel <code>{s['target_channel']}</code>" if s.get("target_channel") else "this chat"
101
+ metadata_on = s["metadata_on"] or bool(auto and has_metadata(auto)) # a format with metadata of its own writes it, switch or not
102
  text = (
103
  "<b>🏷 Auto Rename Bot</b>\n\n"
104
+ "Send me a file (document, video or audio) and I'll rename it with your auto format and send it back.\n\n"
105
  f"<b>Mode:</b> <code>{s['mode'].upper()}</code> · <b>Type:</b> <code>{s['file_type'].upper()}</code>"
106
+ f" · <b>Metadata:</b> <code>{'ON' if metadata_on else 'OFF'}</code>\n"
107
+ f"<b>Auto format:</b> {_auto_name(auto)}\n"
108
+ f"<b>Template:</b> {_code(_template_of(auto), 120)}\n"
109
  f"<b>Send to:</b> {channel}"
110
  )
111
  rows: List[Row] = [
112
+ [("Auto Format", "m:auto"), ("Rename", "m:rename")],
113
+ [("Caption & Thumbnail", "m:cap"), ("Metadata", "m:meta")],
114
+ [("File Type", "m:ftype"), ("Formats", "m:formats")],
115
+ [("Help", "m:help")] + ([("Admin", "m:admin")] if owner else []),
116
  [("✖ Close", "x:close")],
117
  ]
118
  return text, rows
119
 
120
 
121
+ def rename(s: Dict[str, Any], auto: Optional[Dict[str, Any]] = None) -> Panel:
122
  words = ", ".join(s["remove_words"]) or "—"
123
  text = (
124
  "<b>RENAME MODE</b>\n\n"
 
126
  "Caption mode reads details from the message caption.\n\n"
127
  f"Current mode: <code>{s['mode'].upper()}</code>\n"
128
  "Command: /mode\n\n"
129
+ f"<b>Template</b> of the auto format {_auto_name(auto)}: {_code(_template_of(auto), 120)}\n"
130
  f"<b>Remove words:</b> {esc(clip(words, 100))}"
131
  )
132
  rows: List[Row] = [
 
137
  return text, rows
138
 
139
 
140
+ def caption(auto: Optional[Dict[str, Any]], has_thumb: bool) -> Panel:
141
+ """The caption and thumbnail of the auto format in use (``None``: the user has none, the default caption is shown)."""
142
  text = (
143
+ "<b>CAPTION &amp; THUMBNAIL</b>\n"
144
+ f"Of the auto format {_auto_name(auto)}\n\n"
145
+ f"<b>Caption template:</b> {richtext.preview(auto['caption'] if auto else DEFAULT_CAPTION, 160)}\n"
146
  f"Variables: {CAPTION_VARIABLES}\n"
147
  "Tap Set Caption and format it the way you do in any message (<b>bold</b>, <i>italic</i>, links…): "
148
  "I keep it exactly like that.\n\n"
149
  f"<b>Thumbnail:</b> {'✅ saved' if has_thumb else '❌ none (a frame of the video is used)'}\n"
150
+ "Send me a photo to set the thumbnail."
151
  )
152
+ thumb = f":{auto['id']}" if auto else "" # the thumbnail buttons name the format the panel was drawn for
153
  rows: List[Row] = [
154
  [("Set Caption", "in:caption"), ("Reset Caption", "clr:caption")],
155
+ [("View Thumb", f"th:view{thumb}"), ("Delete Thumb", f"th:del{thumb}")],
156
  ] + nav("home")
157
  return text, rows
158
 
 
160
  def metadata(s: Dict[str, Any]) -> Panel:
161
  m = s["meta"]
162
  on = s["metadata_on"]
163
+ lines = [f"<b>🏷 Your default metadata is currently: {'On' if on else 'Off'}</b>", ""]
164
  for label, key in (("Title", "title"), ("Author", "author"), ("Artist", "artist"),
165
  ("Audio", "audio"), ("Subtitle", "subtitle"), ("Video", "video")):
166
  lines.append(f"{DOT} <b>{label}</b> ▸ {_code(m.get(key, ''), 60)}")
167
  lines.append(f"{DOT} <b>Keep Audio</b> ▸ {esc(fmt_tokens(s['keep_audio']))}")
168
  lines.append(f"{DOT} <b>Keep Subtitle</b> ▸ {esc(fmt_tokens(s['keep_subtitle']))}")
169
+ lines += ["", "Auto renames use it when their format has no metadata of its own. To put it in a format, tap "
170
+ "<b>Apply default metadata</b> on the format."]
171
  rows: List[Row] = [
172
  [(_check("On", on), "set:meta:on"), (_check("Off", not on), "set:meta:off")],
173
  [("Title", "in:mt_title"), ("Author", "in:mt_author"), ("Artist", "in:mt_artist")],
 
233
  def help_panel() -> Panel:
234
  text = (
235
  "<b>How to use</b>\n\n"
236
+ "1. Send a file — I rename it with your <b>auto format</b> (pick it in the /start menu) and send it back.\n"
237
+ "2. /rename — one file with a <b>manual format</b>: pick one, then send the file.\n"
238
+ "3. /formats — 12 auto and 12 manual formats (template, caption, thumbnail, metadata): edit, copy between kinds, share.\n"
239
+ "4. /template, /caption — change those of your auto format.\n"
240
+ "5. /mode — read details from the file name or the caption.\n"
241
+ "6. /metadata, /keepaudio, /keepsubtitle — default metadata and tracks (formats can copy it).\n"
242
+ "7. /filetype — send as document, video or audio.\n"
243
+ "8. Send a photo — the thumbnail of your auto format.\n"
244
+ "9. /sequence then /sort — put files in episode order.\n"
245
+ "10. /check, /mediainfo, /ethumb, /resize, /speed — inspect files and images.\n"
246
+ "11. /queue and /cancel — manage jobs.\n\n"
247
  f"Template variables: {TEMPLATE_VARIABLES}\n"
248
  "A [bracket] group is dropped when a variable inside it is empty."
249
  )
 
278
  )
279
 
280
 
281
+ def _format_buttons(formats: Sequence[Dict[str, Any]], prefix: str, active_id: int = 0) -> List[Row]:
282
+ """One button per format, two to a row; the one in use (``active_id``) is ticked."""
283
  rows: List[Row] = []
284
  row: Row = []
285
  for f in formats:
286
+ row.append((_check(clip(f["name"], 24), f["id"] == active_id), f"{prefix}:{f['id']}"))
287
  if len(row) == 2:
288
  rows.append(row)
289
  row = []
 
292
  return rows
293
 
294
 
295
+ def formats_home(auto_count: int, manual_count: int, limit: int) -> Panel:
296
+ """/formats: the two kinds of format, to choose from."""
297
  text = (
298
+ "<b>🗂 FORMATS</b>\n\n"
299
  "A format saves its own template, caption, thumbnail, metadata titles and Keep Audio / Keep Subtitle lists "
300
+ f"under a name, like <i>One Piece</i>. You can keep {limit} of each kind:\n\n"
301
+ "🤖 <b>Auto rename</b> — renames every file you send me. Choose the one in use in the /start menu.\n"
302
+ "✋ <b>Manual rename</b> — you pick one with /rename, for a single file.\n\n"
303
+ "A format can be copied from one kind to the other."
304
  )
305
+ rows: List[Row] = [[(f"🤖 Auto rename ({auto_count}/{limit})", "fm:l:auto"), (f"✋ Manual rename ({manual_count}/{limit})", "fm:l:manual")]]
306
+ return text, rows + nav("home")
307
+
308
+
309
+ def formats_menu(formats: Sequence[Dict[str, Any]], limit: int, kind: str = "manual", active_id: int = 0) -> Panel:
310
+ """The formats of one kind (``active_id``: the auto format in use)."""
311
+ if kind == "auto":
312
+ intro = "These rename every file you send me. The one marked ✅ is in use: tap a format to view or edit it."
313
+ else:
314
+ intro = "You pick one of these with /rename, for a single file. Tap a format to view or edit it."
315
+ text = f"<b>{KIND_ICONS[kind]} {KIND_LABELS[kind].upper()} FORMATS</b> ({len(formats)}/{limit})\n\n{intro}"
316
  if not formats:
317
+ text += f"\n\nYou have no {kind} formats yet."
318
+ rows = _format_buttons(formats, "fm:v", active_id)
319
  if len(formats) < limit:
320
+ rows.append([("➕ New format", f"fm:new:{kind}")])
321
  else:
322
+ text += f"\n\nYou have all {limit} {kind} formats. Delete one to add another."
323
+ return text, rows + nav("formats")
324
+
325
+
326
+ def auto_menu(formats: Sequence[Dict[str, Any]], active_id: int, limit: int) -> Panel:
327
+ """The /start menu's Auto Format screen: choose the auto format that renames your files."""
328
+ text = (
329
+ "<b>🤖 AUTO RENAME FORMAT</b>\n\n"
330
+ "Every file you send me is renamed with the format marked ✅. Tap another one to use it instead.\n"
331
+ "For a single file with a manual format, use /rename."
332
+ )
333
+ if not formats:
334
+ text += (
335
+ "\n\nYou have no auto formats yet, so the default template is used. Create one to set your own template, "
336
+ "caption, thumbnail and metadata."
337
+ )
338
+ rows = _format_buttons(formats, "au", active_id)
339
+ if active_id:
340
+ rows.append([("✏️ Edit the one in use", f"fm:v:{active_id}")])
341
+ last: Row = [("⚙️ Manage", "fm:l:auto")]
342
+ if len(formats) < limit:
343
+ last.append(("➕ New format", "fm:new:auto"))
344
+ return text, rows + [last] + nav("home")
345
 
346
 
347
  def _custom_caption(fmt: Dict[str, Any]) -> bool:
348
  """Has the format a caption of its own? Otherwise it sends the new file name in bold."""
349
+ return fmt["caption"] != DEFAULT_CAPTION
350
+
351
+
352
+ THUMB_NONE = "— (a frame of the video is used)"
353
 
354
 
355
  def _format_lines(fmt: Dict[str, Any], *, thumb_yes: str, thumb_no: str) -> List[str]:
 
367
  return lines
368
 
369
 
370
+ def format_view(fmt: Dict[str, Any], *, share_inline: bool = False, active: bool = False) -> Panel:
371
+ """A format's screen. ``active``: it is the auto format in use. ``share_inline``: the Share format button opens the chat
372
+ picker straight away; that only works while inline mode is on for the bot (Telegram refuses the message otherwise), so
373
+ without it the button explains how."""
374
+ kind = fmt.get("kind", "manual")
375
+ lines = [f"<b>🗂 {esc(clip(fmt['name'], 30))}</b> · {KIND_ICONS[kind]} {KIND_LABELS[kind]}", ""]
376
+ lines += _format_lines(fmt, thumb_yes="✅ saved", thumb_no=THUMB_NONE)
377
+ if kind == "manual":
378
+ lines += ["", "Use it with /rename. Mode, file type and the rest come from your normal settings."]
379
+ elif active:
380
+ lines += ["", "✅ In use: every file you send is renamed with it."]
381
+ else:
382
+ lines += ["", "Not in use. Tap Use for auto rename to rename your files with it."]
383
+ if kind == "auto" and not has_metadata(fmt):
384
+ lines.append("No metadata of its own: your default metadata is used while it is On.")
385
  fid = fmt["id"]
386
  thumb_row: Row = [("Thumbnail", f"fm:th:{fid}")]
387
  if fmt.get("has_thumb"):
 
396
  [("Title", f"fm:in:{fid}:title"), ("Author", f"fm:in:{fid}:author"), ("Artist", f"fm:in:{fid}:artist")],
397
  [("Audio", f"fm:in:{fid}:audio"), ("Subtitle", f"fm:in:{fid}:subtitle"), ("Video", f"fm:in:{fid}:video")],
398
  [("Keep Audio", f"fm:in:{fid}:keep_audio"), ("Keep Subtitle", f"fm:in:{fid}:keep_subtitle")],
399
+ [("🏷 Apply default metadata", f"fm:dm:{fid}")],
400
+ [(f"📋 Copy to {other_kind(kind)} formats", f"fm:cp:{fid}")],
401
+ ]
402
+ if kind == "auto" and not active:
403
+ rows.append([("✅ Use for auto rename", f"fm:use:{fid}")])
404
+ rows += [
405
  [("📤 Share format", INLINE + share_query(fid) if share_inline else f"fm:sh:{fid}")],
406
  [("🗑 Delete format", f"fm:del:{fid}")],
407
+ [("◀ Back", f"fm:l:{kind}"), ("🏠 Home", "m:home"), ("✖ Close", "x:close")],
408
+ ]
409
  return "\n".join(lines), rows
410
 
411
 
412
+ def format_copied(source: Dict[str, Any], copy: Dict[str, Any]) -> Panel:
413
+ """What copying a format to the other kind shows."""
414
+ kind = copy["kind"]
415
+ text = (
416
+ f"✅ <b>{esc(clip(source['name'], 30))}</b> is copied to your {kind} formats as <b>{esc(clip(copy['name'], 30))}</b>.\n"
417
+ "The copy is a format of its own: change it without touching the original."
418
+ )
419
+ rows: List[Row] = [
420
+ [("📂 Open the copy", f"fm:v:{copy['id']}")],
421
+ [("◀ Back to the original", f"fm:v:{source['id']}")],
422
+ [("✖ Close", "x:close")],
423
+ ]
424
+ return text, rows
425
+
426
+
427
+ def format_metadata_applied(fmt: Dict[str, Any]) -> str:
428
+ """The note shown above a format after "Apply default metadata"."""
429
+ return f"✅ Your default metadata is now in <b>{esc(clip(fmt['name'], 30))}</b>."
430
+
431
+
432
  INLINE_OFF = "Sharing needs inline mode. In @BotFather: /mybots → this bot → Bot Settings → Inline Mode → Turn on. Then tap Share format again."
433
  SHARE_GONE = "⚠️ <b>That shared format isn't available anymore.</b>\nThe person who shared it deleted it. Ask them to share it again."
434
 
435
 
436
+ def _a(kind: str) -> str:
437
+ """``An auto`` / ``A manual``"""
438
+ return f"{'An' if kind == 'auto' else 'A'} {kind}"
439
+
440
+
441
  def share_card(fmt: Dict[str, Any]) -> Tuple[str, str, str]:
442
  """``(title, description, html text)`` of the card that is sent to the chat the owner picked."""
443
  name = clip(fmt["name"], 30)
444
+ kind = fmt.get("kind", "manual")
445
  return (
446
  f"🗂 {name}",
447
  "Tap to send this format to the chat",
448
+ f"📦 <b>{esc(name)}</b>\n{_a(kind)} rename format for this bot. Open it to see what's in it and add it to your own formats.",
449
  )
450
 
451
 
452
  def shared_format(fmt: Dict[str, Any], token: str, *, mine: bool = False) -> Panel:
453
  """What opening a shared format shows: what is in it, and a button to add it to your own formats."""
454
+ kind = fmt.get("kind", "manual")
455
+ lines = [
456
+ f"<b>📦 {esc(clip(fmt['name'], 30))}</b> · {KIND_ICONS[kind]} {KIND_LABELS[kind]}",
457
+ "This is your own format." if mine else f"{_a(kind)} rename format shared with you.",
458
+ "",
459
+ ]
460
+ lines += _format_lines(fmt, thumb_yes="✅ included", thumb_no=THUMB_NONE)
461
+ use = "/rename" if kind == "manual" else "auto rename"
462
  if mine:
463
  lines += ["", "Whoever you send it to can look at it here and add a copy to their own formats."]
464
  rows: List[Row] = [[("🗂 Open format", f"fm:v:{fmt['id']}")]]
465
  else:
466
+ lines += ["", f"Add it to your {kind} formats to use it for {use}. You can change anything in your copy afterwards."]
467
  rows = [[("➕ Add to my formats", f"fs:add:{token}")]]
468
  return "\n".join(lines), rows + [[("✖ Close", "x:close")]]
469
 
470
 
471
  def shared_added(fmt: Dict[str, Any]) -> Panel:
472
+ kind = fmt.get("kind", "manual")
473
+ use = "Use it with /rename" if kind == "manual" else "Choose it in the /start menu to rename your files with it"
474
+ text = f"✅ <b>{esc(clip(fmt['name'], 30))}</b> is now in your {kind} formats.\n{use}, or open it to change anything."
475
  return text, [[("🗂 Open format", f"fm:v:{fmt['id']}")], [("✖ Close", "x:close")]]
476
 
477
 
 
481
 
482
 
483
  def rename_picker(formats: Sequence[Dict[str, Any]]) -> Panel:
484
+ """/rename: the manual formats (an auto format is chosen in the /start menu)."""
485
  if not formats:
486
  text = (
487
+ "<b>🏷 MANUAL RENAME</b>\n\nYou have no manual formats yet.\n"
488
+ "A format keeps a template, caption, thumbnail and metadata under a name, like <i>One Piece</i>. Create one, then come back here.\n"
489
+ "Files you just send me are renamed with your auto format."
490
  )
491
+ return text, [[("➕ Create a format", "fm:new:manual")], [("✖ Close", "x:close")]]
492
+ text = "<b>🏷 MANUAL RENAME</b>\n\nChoose the manual format to rename with. I'll ask for your file next."
493
+ rows = _format_buttons(formats, "rn:p") + [[("⚙️ Manage formats", "fm:l:manual"), ("✖ Close", "x:close")]]
494
  return text, rows
495
 
496
 
 
620
  "template": (
621
  "<b>Send the new filename template.</b>\n\n"
622
  f"Variables: {TEMPLATE_VARIABLES}\n"
623
+ f"{EPISODE_ZEROS}\n"
624
  "A [bracket] or (paren) group disappears when a variable inside it is empty.\n"
625
  "If only an episode number is found, the season defaults to 01.\n\n"
626
  "Example: <code>[S{season}E{episode}] {title} [{quality}]</code>"
 
679
  }
680
 
681
 
682
+ def format_prompt(field: str, fid: int, kind: str = "") -> Panel:
683
+ """The "send me the value" screen for one field of a format (``fid`` 0 = a format of ``kind`` that is being created)."""
684
+ cancel = f"fm:cx:{fid}" if fid else (f"fm:l:{kind}" if kind else "m:formats")
685
  return FORMAT_PROMPTS[field], [[("✖ Cancel", cancel)]]
686
 
687
 
bot/parser.py CHANGED
@@ -10,7 +10,9 @@ from dataclasses import dataclass
10
  from typing import Dict, Iterable, List, Optional, Tuple
11
 
12
  DEFAULT_TEMPLATE = "[S{season}E{episode}] {title} [{quality}]"
13
- TEMPLATE_VARS = ("title", "season", "episode", "quality", "year", "codec", "filename", "ext")
 
 
14
  MAX_NAME_BYTES = 200
15
 
16
  KNOWN_EXTS = {
@@ -143,6 +145,13 @@ def _fmt_num(value: str) -> str:
143
  return f"{int(value):02d}"
144
 
145
 
 
 
 
 
 
 
 
146
  def _is_year(value: str) -> bool:
147
  return 1900 <= int(value) <= 2099
148
 
@@ -382,7 +391,8 @@ def missing_variables(template: str, info: Info) -> List[str]:
382
  """
383
  out: List[str] = []
384
  for name in _TOKEN_RE.findall(_GROUP_RE.sub("", template or "")):
385
- if name in ("season", "episode", "quality", "year", "codec") and not getattr(info, name) and name not in out:
 
386
  out.append(name)
387
  return out
388
 
@@ -407,6 +417,7 @@ def build_values(info: Info, original_stem: str, ext: str) -> Dict[str, str]:
407
  "title": info.title or _clean_title(original_stem),
408
  "season": info.season,
409
  "episode": info.episode,
 
410
  "quality": info.quality,
411
  "year": info.year,
412
  "codec": info.codec,
@@ -447,7 +458,9 @@ def compute_new_name(
447
  # captions
448
  # ---------------------------------------------------------------------------
449
 
450
- CAPTION_VARS = ("filename", "original", "title", "season", "episode", "quality", "year", "codec", "ext", "size", "duration")
 
 
451
 
452
 
453
  def render_caption(template: str, values: Dict[str, str]) -> str:
 
10
  from typing import Dict, Iterable, List, Optional, Tuple
11
 
12
  DEFAULT_TEMPLATE = "[S{season}E{episode}] {title} [{quality}]"
13
+ # {1episode} ... {3episode}: the episode with zeros in front until it is 2 ... 4 digits wide (4 -> 04 / 004 / 0004)
14
+ PADDED_EPISODE_VARS = tuple(f"{zeros}episode" for zeros in range(1, 4))
15
+ TEMPLATE_VARS = ("title", "season", "episode", *PADDED_EPISODE_VARS, "quality", "year", "codec", "filename", "ext")
16
  MAX_NAME_BYTES = 200
17
 
18
  KNOWN_EXTS = {
 
145
  return f"{int(value):02d}"
146
 
147
 
148
+ def padded_episode(episode: str, zeros: int) -> str:
149
+ """``episode`` with zeros in front until it is ``zeros + 1`` digits wide: with 2 zeros, ``4``, ``04`` and ``004`` all give
150
+ ``004``. A number that is already that wide stays exactly as it is (``1147`` with 2 zeros is ``1147``); no episode gives
151
+ no text. (``parse_info`` hands over the episode as at least two digits, without zeros it does not need: ``E004`` is ``04``.)"""
152
+ return episode.zfill(zeros + 1) if episode.isdigit() else episode
153
+
154
+
155
  def _is_year(value: str) -> bool:
156
  return 1900 <= int(value) <= 2099
157
 
 
391
  """
392
  out: List[str] = []
393
  for name in _TOKEN_RE.findall(_GROUP_RE.sub("", template or "")):
394
+ field = "episode" if name in PADDED_EPISODE_VARS else name # {2episode} is empty exactly when {episode} is
395
+ if field in ("season", "episode", "quality", "year", "codec") and not getattr(info, field) and name not in out:
396
  out.append(name)
397
  return out
398
 
 
417
  "title": info.title or _clean_title(original_stem),
418
  "season": info.season,
419
  "episode": info.episode,
420
+ **{name: padded_episode(info.episode, zeros) for zeros, name in enumerate(PADDED_EPISODE_VARS, 1)},
421
  "quality": info.quality,
422
  "year": info.year,
423
  "codec": info.codec,
 
458
  # captions
459
  # ---------------------------------------------------------------------------
460
 
461
+ CAPTION_VARS = (
462
+ "filename", "original", "title", "season", "episode", *PADDED_EPISODE_VARS, "quality", "year", "codec", "ext", "size", "duration"
463
+ )
464
 
465
 
466
  def render_caption(template: str, values: Dict[str, str]) -> str:
bot/pipeline.py CHANGED
@@ -129,9 +129,7 @@ class Renamer:
129
 
130
  # ------------------------------------------------------------------
131
  async def process(self, job: Job) -> None:
132
- s = await self.db.get_settings(job.user_id)
133
- if job.fmt:
134
- s = apply_format(s, job.fmt)
135
  work = Path(self.cfg.temp_dir) / f"job{job.id}"
136
  work.mkdir(parents=True, exist_ok=True)
137
  reporter = ProgressReporter(self.client, job.status_msg, job.id, job.new_name, self.cfg.progress_interval)
@@ -154,11 +152,8 @@ class Renamer:
154
  shutil.rmtree(work, ignore_errors=True)
155
 
156
  async def _custom_thumb(self, job: Job) -> Optional[bytes]:
157
- """The thumbnail the user chose: the one of the format being used, else their default one."""
158
- thumb = await self.db.get_format_thumb(job.user_id, job.fmt["id"]) if job.fmt else None
159
- if thumb is None:
160
- thumb = await self.db.get_thumb(job.user_id)
161
- return thumb
162
 
163
  async def _stream_plan(
164
  self, job: Job, s: Dict[str, Any], msg: Any, ext: str, send_type: str, size: int
 
129
 
130
  # ------------------------------------------------------------------
131
  async def process(self, job: Job) -> None:
132
+ s = apply_format(await self.db.get_settings(job.user_id), job.fmt) # no format: the built-in template and caption
 
 
133
  work = Path(self.cfg.temp_dir) / f"job{job.id}"
134
  work.mkdir(parents=True, exist_ok=True)
135
  reporter = ProgressReporter(self.client, job.status_msg, job.id, job.new_name, self.cfg.progress_interval)
 
152
  shutil.rmtree(work, ignore_errors=True)
153
 
154
  async def _custom_thumb(self, job: Job) -> Optional[bytes]:
155
+ """The thumbnail the user chose: the one of the format being used (``None``: a frame of the video is used)."""
156
+ return await self.db.get_format_thumb(job.user_id, job.fmt["id"]) if job.fmt else None
 
 
 
157
 
158
  async def _stream_plan(
159
  self, job: Job, s: Dict[str, Any], msg: Any, ext: str, send_type: str, size: int
bot/queue.py CHANGED
@@ -20,7 +20,7 @@ class Job:
20
  file_name: str
21
  size: int
22
  new_name: str
23
- fmt: Optional[Dict[str, Any]] = None # the saved /rename format this job uses (a snapshot taken when it was queued)
24
  notes: List[str] = field(default_factory=list) # warnings shown with the queued / finished message
25
  order: int = 0 # id of the message that triggered the job: files are processed oldest message first
26
  status_msg: Any = None
 
20
  file_name: str
21
  size: int
22
  new_name: str
23
+ fmt: Optional[Dict[str, Any]] = None # the saved format (auto or manual) this job uses, a snapshot taken when it was queued; None: no auto format
24
  notes: List[str] = field(default_factory=list) # warnings shown with the queued / finished message
25
  order: int = 0 # id of the message that triggered the job: files are processed oldest message first
26
  status_msg: Any = None
bot/settings_ops.py CHANGED
@@ -2,16 +2,20 @@
2
 
3
  Used by both the slash commands and the inline "send me the value" flow, so the
4
  two can never disagree. Functions return ``(ok, html_message)``.
 
 
 
5
  """
6
  from __future__ import annotations
7
 
8
  import re
9
  from typing import Optional, Tuple
10
 
11
- from .db import DEFAULT_SETTINGS, Database
12
  from .media import parse_track_tokens, validate_track_tokens
13
  from .parser import (
14
  CAPTION_VARS,
 
15
  TEMPLATE_VARS,
16
  finalize_name,
17
  render_template,
@@ -50,7 +54,7 @@ async def apply_setting(db: Database, uid: int, kind: str, text: str) -> Tuple[b
50
  if not text:
51
  return False, "❌ The template can't be empty."
52
  if _is_clear(text):
53
- text = DEFAULT_SETTINGS["template"]
54
  if len(text) > 200:
55
  return False, "❌ That template is too long (200 characters max)."
56
  unknown = sorted(set(_TOKEN_RE.findall(text)) - set(TEMPLATE_VARS))
@@ -64,7 +68,7 @@ async def apply_setting(db: Database, uid: int, kind: str, text: str) -> Tuple[b
64
  if not text:
65
  return False, "❌ The caption can't be empty. Send <code>reset</code> to restore the default."
66
  if _is_clear(text):
67
- text = DEFAULT_SETTINGS["caption"]
68
  if len(text) > 800:
69
  return False, "❌ That caption is too long (800 characters max, formatting and link addresses included)."
70
  unknown = sorted(set(_TOKEN_RE.findall(text)) - set(CAPTION_VARS))
 
2
 
3
  Used by both the slash commands and the inline "send me the value" flow, so the
4
  two can never disagree. Functions return ``(ok, html_message)``.
5
+
6
+ ``db`` is whatever keeps the setting: the ``Database`` for the user's own settings, a ``FormatSettings`` for a saved format and an
7
+ ``AutoFormatSettings`` for the auto format in use (the template and the caption live in formats, not in the user's settings).
8
  """
9
  from __future__ import annotations
10
 
11
  import re
12
  from typing import Optional, Tuple
13
 
14
+ from .db import DEFAULT_CAPTION, Database
15
  from .media import parse_track_tokens, validate_track_tokens
16
  from .parser import (
17
  CAPTION_VARS,
18
+ DEFAULT_TEMPLATE,
19
  TEMPLATE_VARS,
20
  finalize_name,
21
  render_template,
 
54
  if not text:
55
  return False, "❌ The template can't be empty."
56
  if _is_clear(text):
57
+ text = DEFAULT_TEMPLATE
58
  if len(text) > 200:
59
  return False, "❌ That template is too long (200 characters max)."
60
  unknown = sorted(set(_TOKEN_RE.findall(text)) - set(TEMPLATE_VARS))
 
68
  if not text:
69
  return False, "❌ The caption can't be empty. Send <code>reset</code> to restore the default."
70
  if _is_clear(text):
71
+ text = DEFAULT_CAPTION
72
  if len(text) > 800:
73
  return False, "❌ That caption is too long (800 characters max, formatting and link addresses included)."
74
  unknown = sorted(set(_TOKEN_RE.findall(text)) - set(CAPTION_VARS))
tests/appkit.py CHANGED
@@ -68,6 +68,24 @@ async def prefs(ctx, uid=OWNER):
68
  return await ctx.db.get_settings(uid)
69
 
70
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  def jpeg(size=(640, 480)):
72
  buf = io.BytesIO()
73
  Image.new("RGB", size, (200, 30, 30)).save(buf, "JPEG")
 
68
  return await ctx.db.get_settings(uid)
69
 
70
 
71
+ async def autofmt(ctx, uid=OWNER):
72
+ """The auto format that renames the user's files (``None`` when they have none)."""
73
+ return await ctx.db.get_auto_format(uid)
74
+
75
+
76
+ async def auto_thumb(ctx, uid=OWNER):
77
+ """The thumbnail of the user's auto format (``None`` when there is none)."""
78
+ fmt = await autofmt(ctx, uid)
79
+ return await ctx.db.get_format_thumb(uid, fmt["id"]) if fmt is not None and fmt["has_thumb"] else None
80
+
81
+
82
+ async def set_auto_thumb(ctx, data, uid=OWNER):
83
+ """Give the user's auto format a thumbnail, making the "Default" auto format first if they have none."""
84
+ fmt = await ctx.db.ensure_auto_format(uid)
85
+ await ctx.db.set_format_thumb(uid, fmt["id"], data)
86
+ return fmt
87
+
88
+
89
  def jpeg(size=(640, 480)):
90
  buf = io.BytesIO()
91
  Image.new("RGB", size, (200, 30, 30)).save(buf, "JPEG")
tests/test_app.py CHANGED
@@ -15,7 +15,10 @@ from bot.db import Database
15
  from bot.queue import JobQueue
16
 
17
  from .conftest import needs_ffmpeg
18
- from .appkit import ADMIN_CFG, ADMIN_DB, EPISODE, EPISODE_NEW, LOG, OWNER, STRANGER, click, data_of, env_for, jpeg, prefs, say, send_doc
 
 
 
19
  from .tgfakes import (
20
  FakeCallback, FakeClient, FakeEvent, FakeMessage, dispatch, document_message, flat_buttons, make_file, variables_line,
21
  )
@@ -115,7 +118,7 @@ async def test_start_shows_the_home_panel_and_hides_admin_from_admins(ctx):
115
  ev = await say(ctx, OWNER, "/start")
116
  text, buttons, kw = ev.out[0]
117
  assert "Auto Rename Bot" in text and kw["parse_mode"] == "html"
118
- assert {"m:rename", "m:cap", "m:meta", "m:ftype", "m:help", "m:admin"} <= set(data_of(buttons))
119
  assert "m:admin" not in data_of((await say(ctx, ADMIN_CFG, "/start")).out[0][1])
120
 
121
 
@@ -164,14 +167,23 @@ async def test_metadata_command(ctx):
164
 
165
 
166
  async def test_template_caption_and_removewords_commands(ctx):
 
 
 
 
167
  ev = await say(ctx, OWNER, "/template [S{season}E{episode}] {title} [{quality}]")
168
  assert "Template saved" in ev.texts[0]
169
- assert (await prefs(ctx))["template"] == "[S{season}E{episode}] {title} [{quality}]"
170
- assert "Current template" in (await say(ctx, OWNER, "/template")).texts[0]
 
 
 
 
171
 
172
  await say(ctx, OWNER, "/caption <b>{filename}</b> {size}")
173
- assert (await prefs(ctx))["caption"] == "<b>{filename}</b> {size}"
174
- assert "Current caption template" in (await say(ctx, OWNER, "/caption")).texts[0]
 
175
 
176
  await say(ctx, OWNER, "/removewords @Chan, [Group]")
177
  assert (await prefs(ctx))["remove_words"] == ["@Chan", "[Group]"]
@@ -182,9 +194,9 @@ async def test_template_caption_and_removewords_commands(ctx):
182
 
183
  async def test_template_and_caption_commands_list_each_variable_as_its_own_copyable_block(ctx):
184
  line, blocks = variables_line((await say(ctx, OWNER, "/template")).texts[0])
185
- assert blocks == line.split(": ", 1)[1].split() and "{quality}" in blocks and len(blocks) == 8
186
  line, blocks = variables_line((await say(ctx, OWNER, "/caption")).texts[0])
187
- assert blocks == line.split(": ", 1)[1].split() and "{size}" in blocks and len(blocks) == 10
188
 
189
 
190
  async def test_settings_are_per_user(ctx):
@@ -247,24 +259,48 @@ async def test_setchannel(ctx):
247
  # ---------------------------------------------------------------------------
248
 
249
 
250
- async def test_photo_becomes_the_thumbnail_and_can_be_viewed_and_deleted(ctx):
 
251
  ev = FakeEvent(OWNER, message=FakeMessage("", mid=20, photo=True), photo_bytes=jpeg())
252
  await dispatch(ctx.client, ev)
253
- assert "Thumbnail saved" in ev.texts[0] and "th:rm" in data_of(ev.out[0][1])
254
- stored = await ctx.db.get_thumb(OWNER)
 
 
 
255
  assert stored[:2] == b"\xff\xd8"
256
- assert await ctx.db.get_thumb(ADMIN_CFG) is None # per user
257
 
258
  await say(ctx, OWNER, "/thumb")
259
  (chat, file, kw), = ctx.client.files
260
- assert chat == OWNER and file.read() == stored and "th:rm" in data_of(kw["buttons"])
261
 
262
  assert "deleted" in (await say(ctx, OWNER, "/delthumb")).texts[0]
263
- assert await ctx.db.get_thumb(OWNER) is None
264
  assert "no thumbnail" in (await say(ctx, OWNER, "/delthumb")).texts[0]
265
  assert "no thumbnail" in (await say(ctx, OWNER, "/thumb")).texts[0]
266
 
267
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
268
  async def test_unreadable_photos_are_reported(ctx):
269
  ev = FakeEvent(OWNER, message=FakeMessage("", photo=True), photo_bytes=b"not an image")
270
  await dispatch(ctx.client, ev)
@@ -272,26 +308,75 @@ async def test_unreadable_photos_are_reported(ctx):
272
  ev = FakeEvent(OWNER, message=FakeMessage("", photo=True), photo_bytes=None)
273
  await dispatch(ctx.client, ev)
274
  assert "couldn't download" in ev.texts[0]
275
- assert await ctx.db.get_thumb(OWNER) is None
276
  stranger = FakeEvent(STRANGER, message=FakeMessage("", photo=True), photo_bytes=jpeg())
277
  await dispatch(ctx.client, stranger)
278
- assert stranger.out == [] and await ctx.db.get_thumb(STRANGER) is None
279
 
280
 
281
- async def test_thumbnail_buttons(ctx):
282
  cb = await click(ctx, OWNER, "th:view")
283
  assert cb.alerts and "no thumbnail" in cb.alerts[0]
284
- await ctx.db.set_thumb(OWNER, b"\xff\xd8thumb")
285
  cb = await click(ctx, OWNER, "th:view")
286
  assert cb.answers == [(None, False)] and len(ctx.client.files) == 1
287
 
288
  cb = await click(ctx, OWNER, "th:del") # inside the Caption & Thumbnail panel
289
- assert await ctx.db.get_thumb(OWNER) is None and "CAPTION" in cb.edits[-1][0] and "none" in cb.edits[-1][0]
290
  assert cb.toasts == ["Thumbnail deleted."] and not cb.deleted
291
 
292
- await ctx.db.set_thumb(OWNER, b"\xff\xd8thumb")
293
  cb = await click(ctx, OWNER, "th:rm") # under a standalone thumbnail message
294
- assert await ctx.db.get_thumb(OWNER) is None and cb.deleted and cb.edits == []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
295
 
296
 
297
  # ---------------------------------------------------------------------------
@@ -650,8 +735,9 @@ async def test_toggles_update_settings_and_refresh_the_panel(ctx):
650
 
651
  async def test_clear_buttons(ctx):
652
  await say(ctx, OWNER, "/caption custom {filename}")
 
653
  cb = await click(ctx, OWNER, "clr:caption")
654
- assert (await prefs(ctx))["caption"] == "<b>{filename}</b>" and "CAPTION" in cb.edits[-1][0]
655
  await say(ctx, OWNER, "/keepaudio 2 1")
656
  await say(ctx, OWNER, "/keepsubtitle 1")
657
  cb = await click(ctx, OWNER, "clr:keep_audio")
@@ -695,7 +781,9 @@ async def test_admin_panel_buttons(ctx):
695
 
696
  @pytest.mark.parametrize("panel", sorted(PANEL_NAMES))
697
  async def test_every_button_of_every_panel_is_handled(ctx, panel):
698
- await ctx.db.set_thumb(OWNER, b"\xff\xd8thumb")
 
 
699
  _text, rows = await ctx.app.panel(OWNER, panel)
700
  clicked = 0
701
  for row in rows:
@@ -727,7 +815,7 @@ async def test_template_prompt_flow(ctx):
727
  await click(ctx, OWNER, "in:template", msg_id=500)
728
  ev = await say(ctx, OWNER, "{title} [{quality}]", mid=60)
729
  assert ev.deleted and OWNER not in ctx.app.pending # the typed value is removed to keep the chat tidy
730
- assert (await prefs(ctx))["template"] == "{title} [{quality}]"
731
  (chat, text, kw), = ctx.client.edited
732
  assert chat == OWNER and kw["message"] == 500 and "Template saved" in text and "RENAME MODE" in text
733
  assert "rename" == "rename" and "set:mode:filename" in data_of(kw["buttons"])
@@ -748,7 +836,7 @@ async def test_invalid_input_keeps_the_prompt_open(ctx):
748
  assert "too long" in ev.texts[0] and "Try again" in ev.texts[0] and not ev.deleted
749
  assert OWNER in ctx.app.pending and ctx.client.edited == []
750
  ev = await say(ctx, OWNER, "{title}")
751
- assert OWNER not in ctx.app.pending and (await prefs(ctx))["template"] == "{title}"
752
 
753
 
754
  async def test_expired_prompts_and_other_users_text_are_ignored(ctx):
@@ -758,7 +846,7 @@ async def test_expired_prompts_and_other_users_text_are_ignored(ctx):
758
  ctx.app.pending[OWNER].expires = time.time() - 1
759
  ev = await say(ctx, OWNER, "{title}")
760
  assert ev.out == [] and not ev.deleted and OWNER not in ctx.app.pending
761
- assert (await prefs(ctx))["template"] != "{title}"
762
 
763
 
764
  async def test_plain_text_without_a_prompt_is_ignored(ctx):
 
15
  from bot.queue import JobQueue
16
 
17
  from .conftest import needs_ffmpeg
18
+ from .appkit import (
19
+ ADMIN_CFG, ADMIN_DB, EPISODE, EPISODE_NEW, LOG, OWNER, STRANGER, auto_thumb, autofmt, click, data_of, env_for, jpeg, prefs, say,
20
+ send_doc, set_auto_thumb,
21
+ )
22
  from .tgfakes import (
23
  FakeCallback, FakeClient, FakeEvent, FakeMessage, dispatch, document_message, flat_buttons, make_file, variables_line,
24
  )
 
118
  ev = await say(ctx, OWNER, "/start")
119
  text, buttons, kw = ev.out[0]
120
  assert "Auto Rename Bot" in text and kw["parse_mode"] == "html"
121
+ assert {"m:auto", "m:rename", "m:cap", "m:meta", "m:ftype", "m:formats", "m:help", "m:admin"} <= set(data_of(buttons))
122
  assert "m:admin" not in data_of((await say(ctx, ADMIN_CFG, "/start")).out[0][1])
123
 
124
 
 
167
 
168
 
169
  async def test_template_caption_and_removewords_commands(ctx):
170
+ assert await autofmt(ctx) is None
171
+ assert "Template of your auto format" in (await say(ctx, OWNER, "/template")).texts[0]
172
+ assert await autofmt(ctx) is None # only looking: no format is made
173
+
174
  ev = await say(ctx, OWNER, "/template [S{season}E{episode}] {title} [{quality}]")
175
  assert "Template saved" in ev.texts[0]
176
+ auto = await autofmt(ctx) # they are the template and caption of the auto format in use, made on the first change
177
+ assert auto["name"] == "Default" and auto["kind"] == "auto"
178
+ assert auto["template"] == "[S{season}E{episode}] {title} [{quality}]"
179
+ assert "template" not in await prefs(ctx) and "caption" not in await prefs(ctx)
180
+ text = (await say(ctx, OWNER, "/template")).texts[0]
181
+ assert "Template of your auto format" in text and "[S{season}E{episode}] {title} [{quality}]" in text
182
 
183
  await say(ctx, OWNER, "/caption <b>{filename}</b> {size}")
184
+ assert (await autofmt(ctx))["caption"] == "<b>{filename}</b> {size}"
185
+ assert "Caption of your auto format" in (await say(ctx, OWNER, "/caption")).texts[0]
186
+ assert await ctx.db.count_formats(OWNER) == 1 # both went into the same format
187
 
188
  await say(ctx, OWNER, "/removewords @Chan, [Group]")
189
  assert (await prefs(ctx))["remove_words"] == ["@Chan", "[Group]"]
 
194
 
195
  async def test_template_and_caption_commands_list_each_variable_as_its_own_copyable_block(ctx):
196
  line, blocks = variables_line((await say(ctx, OWNER, "/template")).texts[0])
197
+ assert blocks == line.split(": ", 1)[1].split() and "{quality}" in blocks and "{3episode}" in blocks and "{4episode}" not in blocks and len(blocks) == 11
198
  line, blocks = variables_line((await say(ctx, OWNER, "/caption")).texts[0])
199
+ assert blocks == line.split(": ", 1)[1].split() and "{size}" in blocks and "{3episode}" in blocks and "{4episode}" not in blocks and len(blocks) == 13
200
 
201
 
202
  async def test_settings_are_per_user(ctx):
 
259
  # ---------------------------------------------------------------------------
260
 
261
 
262
+ async def test_photo_becomes_the_thumbnail_of_the_auto_format_and_can_be_viewed_and_deleted(ctx):
263
+ assert "no thumbnail" in (await say(ctx, OWNER, "/thumb")).texts[0] and await autofmt(ctx) is None
264
  ev = FakeEvent(OWNER, message=FakeMessage("", mid=20, photo=True), photo_bytes=jpeg())
265
  await dispatch(ctx.client, ev)
266
+ assert "Thumbnail saved" in ev.texts[0] and "auto format" in ev.texts[0]
267
+ auto = await autofmt(ctx) # the first photo makes the auto format "Default" (like /template does)
268
+ assert auto["name"] == "Default" and auto["has_thumb"]
269
+ assert f"th:rm:{auto['id']}" in data_of(ev.out[0][1]) # the button names the format whose thumbnail it deletes
270
+ stored = await auto_thumb(ctx)
271
  assert stored[:2] == b"\xff\xd8"
272
+ assert await auto_thumb(ctx, ADMIN_CFG) is None and await autofmt(ctx, ADMIN_CFG) is None # per user
273
 
274
  await say(ctx, OWNER, "/thumb")
275
  (chat, file, kw), = ctx.client.files
276
+ assert chat == OWNER and file.read() == stored and f"th:rm:{auto['id']}" in data_of(kw["buttons"])
277
 
278
  assert "deleted" in (await say(ctx, OWNER, "/delthumb")).texts[0]
279
+ assert await auto_thumb(ctx) is None and (await autofmt(ctx))["id"] == auto["id"] # the format stays
280
  assert "no thumbnail" in (await say(ctx, OWNER, "/delthumb")).texts[0]
281
  assert "no thumbnail" in (await say(ctx, OWNER, "/thumb")).texts[0]
282
 
283
 
284
+ async def test_a_photo_goes_to_the_auto_format_in_use_and_never_to_a_manual_one(ctx):
285
+ one = await ctx.db.create_format(OWNER, "One", kind="auto")
286
+ two = await ctx.db.create_format(OWNER, "Two", kind="auto")
287
+ manual = await ctx.db.create_format(OWNER, "Manual", kind="manual")
288
+ await ctx.db.select_auto_format(OWNER, two["id"])
289
+ ev = FakeEvent(OWNER, message=FakeMessage("", mid=20, photo=True), photo_bytes=jpeg())
290
+ await dispatch(ctx.client, ev)
291
+ assert "<b>Two</b>" in ev.texts[0]
292
+ assert await ctx.db.get_format_thumb(OWNER, two["id"]) and await ctx.db.get_format_thumb(OWNER, one["id"]) is None
293
+ assert await ctx.db.get_format_thumb(OWNER, manual["id"]) is None
294
+
295
+
296
+ async def test_several_photos_at_once_from_someone_with_no_auto_format_all_work(ctx):
297
+ events = [FakeEvent(OWNER, message=FakeMessage("", mid=20 + i, photo=True), photo_bytes=jpeg()) for i in range(3)]
298
+ await asyncio.gather(*(dispatch(ctx.client, ev) for ev in events))
299
+ assert all("Thumbnail saved" in ev.texts[0] for ev in events) # none of them failed on the format the first one made
300
+ (auto,) = await ctx.db.list_formats(OWNER, "auto")
301
+ assert auto["name"] == "Default" and auto["has_thumb"] and (await prefs(ctx))["auto_format"] == auto["id"]
302
+
303
+
304
  async def test_unreadable_photos_are_reported(ctx):
305
  ev = FakeEvent(OWNER, message=FakeMessage("", photo=True), photo_bytes=b"not an image")
306
  await dispatch(ctx.client, ev)
 
308
  ev = FakeEvent(OWNER, message=FakeMessage("", photo=True), photo_bytes=None)
309
  await dispatch(ctx.client, ev)
310
  assert "couldn't download" in ev.texts[0]
311
+ assert await autofmt(ctx) is None # nothing was saved, not even a format
312
  stranger = FakeEvent(STRANGER, message=FakeMessage("", photo=True), photo_bytes=jpeg())
313
  await dispatch(ctx.client, stranger)
314
+ assert stranger.out == [] and await autofmt(ctx, STRANGER) is None
315
 
316
 
317
+ async def test_thumbnail_buttons_of_older_messages_have_no_format_and_use_the_one_in_use(ctx):
318
  cb = await click(ctx, OWNER, "th:view")
319
  assert cb.alerts and "no thumbnail" in cb.alerts[0]
320
+ await set_auto_thumb(ctx, b"\xff\xd8thumb")
321
  cb = await click(ctx, OWNER, "th:view")
322
  assert cb.answers == [(None, False)] and len(ctx.client.files) == 1
323
 
324
  cb = await click(ctx, OWNER, "th:del") # inside the Caption & Thumbnail panel
325
+ assert await auto_thumb(ctx) is None and "CAPTION" in cb.edits[-1][0] and "none" in cb.edits[-1][0]
326
  assert cb.toasts == ["Thumbnail deleted."] and not cb.deleted
327
 
328
+ await set_auto_thumb(ctx, b"\xff\xd8thumb")
329
  cb = await click(ctx, OWNER, "th:rm") # under a standalone thumbnail message
330
+ assert await auto_thumb(ctx) is None and cb.deleted and cb.edits == []
331
+
332
+
333
+ async def test_thumbnail_buttons_name_their_format_so_an_old_message_never_hits_another_one(ctx):
334
+ one = await ctx.db.create_format(OWNER, "One", kind="auto")
335
+ two = await ctx.db.create_format(OWNER, "Two", kind="auto")
336
+ await ctx.db.set_format_thumb(OWNER, one["id"], b"\xff\xd8one")
337
+ await ctx.db.set_format_thumb(OWNER, two["id"], b"\xff\xd8two")
338
+ await ctx.db.select_auto_format(OWNER, one["id"])
339
+ await say(ctx, OWNER, "/thumb") # the Delete button under it belongs to "One"
340
+ (_chat, _file, kw), = ctx.client.files
341
+ assert data_of(kw["buttons"])[0] == f"th:rm:{one['id']}"
342
+ _text, rows = await ctx.app.panel(OWNER, "cap") # the panel names the format it shows, too
343
+ assert [d for row in rows for _label, d in row][2:4] == [f"th:view:{one['id']}", f"th:del:{one['id']}"]
344
+
345
+ await click(ctx, OWNER, f"au:{two['id']}") # the user switches to "Two" ...
346
+ cb = await click(ctx, OWNER, f"th:rm:{one['id']}") # ... and taps Delete on the old message
347
+ assert cb.deleted and cb.toasts == ["Thumbnail deleted."]
348
+ assert await ctx.db.get_format_thumb(OWNER, one["id"]) is None
349
+ assert await ctx.db.get_format_thumb(OWNER, two["id"]) == b"\xff\xd8two" # the format in use now is untouched
350
+
351
+ await ctx.db.set_format_thumb(OWNER, one["id"], b"\xff\xd8one")
352
+ await click(ctx, OWNER, f"th:view:{one['id']}") # viewing from an old panel shows that format's thumbnail
353
+ assert ctx.client.files[-1][1].read() == b"\xff\xd8one"
354
+ await click(ctx, OWNER, f"th:del:{one['id']}")
355
+ assert await ctx.db.get_format_thumb(OWNER, one["id"]) is None and await ctx.db.get_format_thumb(OWNER, two["id"]) == b"\xff\xd8two"
356
+
357
+
358
+ async def test_a_thumbnail_button_of_a_format_that_is_gone_or_not_theirs_does_nothing(ctx):
359
+ mine = await ctx.db.create_format(OWNER, "Mine", kind="auto")
360
+ manual = await ctx.db.create_format(OWNER, "Manual", kind="manual")
361
+ theirs = await ctx.db.create_format(ADMIN_CFG, "Theirs", kind="auto")
362
+ for fid in (mine["id"], manual["id"], theirs["id"]):
363
+ await ctx.db.set_format_thumb(OWNER if fid != theirs["id"] else ADMIN_CFG, fid, b"\xff\xd8keep")
364
+ await ctx.db.delete_format(OWNER, mine["id"]) # an old message of a format that has since been deleted
365
+ for fid in (mine["id"], manual["id"], theirs["id"], 999999):
366
+ for action in ("rm", "del", "view"):
367
+ cb = await click(ctx, OWNER, f"th:{action}:{fid}")
368
+ assert cb.alerts == ["That format no longer exists."] and not cb.deleted and cb.edits == []
369
+ assert await ctx.db.get_format_thumb(OWNER, manual["id"]) == b"\xff\xd8keep"
370
+ assert await ctx.db.get_format_thumb(ADMIN_CFG, theirs["id"]) == b"\xff\xd8keep" and ctx.client.files == []
371
+
372
+
373
+ async def test_the_thumbnail_buttons_leave_manual_formats_alone(ctx):
374
+ manual = await ctx.db.create_format(OWNER, "Manual", kind="manual")
375
+ await ctx.db.set_format_thumb(OWNER, manual["id"], b"\xff\xd8mine")
376
+ await set_auto_thumb(ctx, b"\xff\xd8auto")
377
+ await click(ctx, OWNER, "th:del")
378
+ await click(ctx, OWNER, "th:rm")
379
+ assert await auto_thumb(ctx) is None and await ctx.db.get_format_thumb(OWNER, manual["id"]) == b"\xff\xd8mine"
380
 
381
 
382
  # ---------------------------------------------------------------------------
 
735
 
736
  async def test_clear_buttons(ctx):
737
  await say(ctx, OWNER, "/caption custom {filename}")
738
+ assert (await autofmt(ctx))["caption"] == "custom {filename}"
739
  cb = await click(ctx, OWNER, "clr:caption")
740
+ assert (await autofmt(ctx))["caption"] == "<b>{filename}</b>" and "CAPTION" in cb.edits[-1][0]
741
  await say(ctx, OWNER, "/keepaudio 2 1")
742
  await say(ctx, OWNER, "/keepsubtitle 1")
743
  cb = await click(ctx, OWNER, "clr:keep_audio")
 
781
 
782
  @pytest.mark.parametrize("panel", sorted(PANEL_NAMES))
783
  async def test_every_button_of_every_panel_is_handled(ctx, panel):
784
+ await set_auto_thumb(ctx, b"\xff\xd8thumb")
785
+ await ctx.db.create_format(OWNER, "Other auto", kind="auto")
786
+ await ctx.db.create_format(OWNER, "Manual one", kind="manual")
787
  _text, rows = await ctx.app.panel(OWNER, panel)
788
  clicked = 0
789
  for row in rows:
 
815
  await click(ctx, OWNER, "in:template", msg_id=500)
816
  ev = await say(ctx, OWNER, "{title} [{quality}]", mid=60)
817
  assert ev.deleted and OWNER not in ctx.app.pending # the typed value is removed to keep the chat tidy
818
+ assert (await autofmt(ctx))["template"] == "{title} [{quality}]"
819
  (chat, text, kw), = ctx.client.edited
820
  assert chat == OWNER and kw["message"] == 500 and "Template saved" in text and "RENAME MODE" in text
821
  assert "rename" == "rename" and "set:mode:filename" in data_of(kw["buttons"])
 
836
  assert "too long" in ev.texts[0] and "Try again" in ev.texts[0] and not ev.deleted
837
  assert OWNER in ctx.app.pending and ctx.client.edited == []
838
  ev = await say(ctx, OWNER, "{title}")
839
+ assert OWNER not in ctx.app.pending and (await autofmt(ctx))["template"] == "{title}"
840
 
841
 
842
  async def test_expired_prompts_and_other_users_text_are_ignored(ctx):
 
846
  ctx.app.pending[OWNER].expires = time.time() - 1
847
  ev = await say(ctx, OWNER, "{title}")
848
  assert ev.out == [] and not ev.deleted and OWNER not in ctx.app.pending
849
+ assert await autofmt(ctx) is None # nothing was saved
850
 
851
 
852
  async def test_plain_text_without_a_prompt_is_ignored(ctx):
tests/test_auto_formats_app.py ADDED
@@ -0,0 +1,319 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Auto formats in the app: the /start menu's Auto Format screen, Use for auto rename, copying a format to the other kind,
2
+ Apply default metadata, and what the files are renamed with."""
3
+ import pytest
4
+
5
+ from bot.db import MAX_FORMATS
6
+ from bot.formats import apply_format
7
+
8
+ from .appkit import ADMIN_CFG, EPISODE_NEW, OWNER, autofmt, click, data_of, jpeg, prefs, say, send_doc
9
+ from .test_formats_app import ONE_PIECE, labels_of, make_format
10
+
11
+ DEFAULT_META = {"title": "My Channel", "author": "@MyChannel", "artist": "", "audio": "{lang} Audio", "subtitle": "{lang}", "video": "HD"}
12
+
13
+
14
+ async def set_default_metadata(ctx, **over):
15
+ await ctx.db.update_settings(OWNER, meta=dict(DEFAULT_META), keep_audio=["japanese", "english"], keep_subtitle=["english"], **over)
16
+
17
+
18
+ def latest_job(ctx):
19
+ return list(ctx.app.queue.jobs.values())[-1]
20
+
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # the Auto Format screen of the /start menu
24
+ # ---------------------------------------------------------------------------
25
+
26
+
27
+ async def test_the_start_menu_opens_the_auto_format_screen(ctx):
28
+ ev = await say(ctx, OWNER, "/start")
29
+ text, buttons, _kw = ev.out[0]
30
+ assert "<b>Auto format:</b> none saved" in text and "m:auto" in data_of(buttons) and "Auto Format" in labels_of(buttons)
31
+ cb = await click(ctx, OWNER, "m:auto")
32
+ text, buttons = cb.edits[-1]
33
+ assert "AUTO RENAME FORMAT" in text and "no auto formats yet" in text and cb.answers
34
+ data = data_of(buttons)
35
+ assert {"fm:new:auto", "fm:l:auto", "m:home", "x:close"} <= set(data) and not any(d.startswith("au:") for d in data)
36
+
37
+
38
+ async def test_the_screen_lists_the_auto_formats_and_marks_the_one_in_use(ctx):
39
+ a = await make_format(ctx, "One Piece", kind="auto", template="OP - E{episode}")
40
+ b = await make_format(ctx, "Dragon Ball", kind="auto", template="DB - E{episode}")
41
+ manual = await make_format(ctx, "Manual one")
42
+ cb = await click(ctx, OWNER, "m:auto")
43
+ text, buttons = cb.edits[-1]
44
+ data = data_of(buttons)
45
+ assert f"au:{a['id']}" in data and f"au:{b['id']}" in data and f"au:{manual['id']}" not in data
46
+ assert labels_of(buttons)[:2] == ["One Piece ✅", "Dragon Ball"] # the first one is in use from the start
47
+ assert f"fm:v:{a['id']}" in data # "Edit the one in use"
48
+
49
+ cb = await click(ctx, OWNER, f"au:{b['id']}")
50
+ assert cb.toasts == ["Auto format: Dragon Ball"] and labels_of(cb.edits[-1][1])[:2] == ["One Piece", "Dragon Ball ✅"]
51
+ assert (await prefs(ctx))["auto_format"] == b["id"] and (await autofmt(ctx))["id"] == b["id"]
52
+ data = data_of(cb.edits[-1][1])
53
+ assert f"fm:v:{b['id']}" in data and f"fm:v:{a['id']}" not in data # the edit button follows the choice
54
+
55
+ ev = await say(ctx, OWNER, "/start") # and the menu shows what is in use
56
+ assert "<b>Auto format:</b> <b>Dragon Ball</b>" in ev.texts[0] and "DB - E{episode}" in ev.texts[0]
57
+
58
+
59
+ async def test_a_stale_or_foreign_choice_is_refused(ctx):
60
+ manual = await make_format(ctx, "Manual one")
61
+ theirs = await make_format(ctx, "Theirs", uid=ADMIN_CFG, kind="auto")
62
+ await make_format(ctx, "Mine", kind="auto")
63
+ for data in (f"au:{manual['id']}", f"au:{theirs['id']}", "au:999999", "au:x", "au"):
64
+ cb = await click(ctx, OWNER, data)
65
+ assert cb.alerts == ["That format no longer exists."] and "AUTO RENAME FORMAT" in cb.edits[-1][0], data
66
+ assert (await autofmt(ctx))["name"] == "Mine"
67
+
68
+
69
+ async def test_each_user_has_their_own_auto_format(ctx):
70
+ mine = await make_format(ctx, "Mine", kind="auto", template="Mine - {title}")
71
+ theirs = await make_format(ctx, "Theirs", uid=ADMIN_CFG, kind="auto", template="Theirs - {title}")
72
+ await send_doc(ctx, OWNER, ONE_PIECE, mid=50)
73
+ await send_doc(ctx, ADMIN_CFG, ONE_PIECE, mid=51)
74
+ jobs = {j.user_id: j for j in ctx.app.queue.jobs.values()}
75
+ assert jobs[OWNER].new_name == "Mine - One Piece.mkv" and jobs[OWNER].fmt["id"] == mine["id"]
76
+ assert jobs[ADMIN_CFG].new_name == "Theirs - One Piece.mkv" and jobs[ADMIN_CFG].fmt["id"] == theirs["id"]
77
+
78
+
79
+ # ---------------------------------------------------------------------------
80
+ # what the files are renamed with
81
+ # ---------------------------------------------------------------------------
82
+
83
+
84
+ async def test_the_auto_format_in_use_renames_every_file_sent(ctx):
85
+ a = await make_format(ctx, "One Piece", kind="auto", template="OP - E{episode}")
86
+ b = await make_format(ctx, "Dragon Ball", kind="auto", template="DB - E{episode}")
87
+ ev = await send_doc(ctx, OWNER, ONE_PIECE, mid=50)
88
+ job = latest_job(ctx)
89
+ assert job.new_name == "OP - E1147.mkv" and job.fmt["id"] == a["id"] and job.fmt["kind"] == "auto"
90
+ assert "🗂 Auto format: <b>One Piece</b>" in ev.texts[0]
91
+ await click(ctx, OWNER, f"au:{b['id']}")
92
+ ev = await send_doc(ctx, OWNER, ONE_PIECE, mid=51)
93
+ job = latest_job(ctx)
94
+ assert job.new_name == "DB - E1147.mkv" and job.fmt["id"] == b["id"] and "🗂 Auto format: <b>Dragon Ball</b>" in ev.texts[0]
95
+
96
+
97
+ async def test_without_an_auto_format_files_get_the_built_in_template(ctx):
98
+ await make_format(ctx, "Manual one") # a manual format is never used by itself
99
+ ev = await send_doc(ctx, OWNER)
100
+ (job,) = ctx.app.queue.jobs.values()
101
+ assert job.fmt is None and job.new_name == EPISODE_NEW and "Auto format" not in ev.texts[0]
102
+
103
+
104
+ async def test_the_padded_episode_variables_in_an_auto_format(ctx):
105
+ await make_format(ctx, "Padded", kind="auto", template="{title} - {1episode} {2episode} {3episode}")
106
+ for mid, name, expected in (
107
+ (50, "One Piece - 4 [1080p].mkv", "One Piece - 04 004 0004.mkv"),
108
+ (51, "One Piece - 321 [1080p].mkv", "One Piece - 321 321 0321.mkv"),
109
+ (52, "One Piece - 1321 [1080p].mkv", "One Piece - 1321 1321 1321.mkv"),
110
+ ):
111
+ await send_doc(ctx, OWNER, name, mid=mid)
112
+ assert latest_job(ctx).new_name == expected
113
+
114
+
115
+ async def test_the_first_auto_format_is_used_at_once_and_deleting_the_one_in_use_hands_over(ctx):
116
+ await click(ctx, OWNER, "fm:new:auto", msg_id=500)
117
+ await say(ctx, OWNER, "One Piece")
118
+ (a,) = await ctx.db.list_formats(OWNER, "auto")
119
+ _chat, text, kw = ctx.client.edited[-1]
120
+ assert "✅ In use" in text and f"fm:use:{a['id']}" not in data_of(kw["buttons"])
121
+ assert (await autofmt(ctx))["id"] == a["id"]
122
+ await make_format(ctx, "Dragon Ball", kind="auto")
123
+
124
+ await click(ctx, OWNER, f"fm:del:{a['id']}")
125
+ cb = await click(ctx, OWNER, f"fm:ok:{a['id']}") # the one in use goes: the other takes over
126
+ assert "AUTO FORMATS" in cb.edits[-1][0] and "Dragon Ball ✅" in labels_of(cb.edits[-1][1])
127
+ last = await autofmt(ctx)
128
+ assert last["name"] == "Dragon Ball"
129
+ await click(ctx, OWNER, f"fm:ok:{last['id']}") # the last one goes: back to the built-in defaults
130
+ assert await autofmt(ctx) is None
131
+ await send_doc(ctx, OWNER)
132
+ (job,) = ctx.app.queue.jobs.values()
133
+ assert job.fmt is None and job.new_name == EPISODE_NEW
134
+
135
+
136
+ async def test_use_for_auto_rename_button(ctx):
137
+ a = await make_format(ctx, "One Piece", kind="auto")
138
+ b = await make_format(ctx, "Dragon Ball", kind="auto")
139
+ cb = await click(ctx, OWNER, f"fm:v:{a['id']}")
140
+ assert "✅ In use" in cb.edits[-1][0] and f"fm:use:{a['id']}" not in data_of(cb.edits[-1][1])
141
+ cb = await click(ctx, OWNER, f"fm:v:{b['id']}")
142
+ text, buttons = cb.edits[-1]
143
+ assert "Not in use" in text and f"fm:use:{b['id']}" in data_of(buttons) and "✅ Use for auto rename" in labels_of(buttons)
144
+
145
+ cb = await click(ctx, OWNER, f"fm:use:{b['id']}")
146
+ text, buttons = cb.edits[-1]
147
+ assert cb.toasts == ["Auto format: Dragon Ball"] and "now renames your files" in text and "✅ In use" in text
148
+ assert f"fm:use:{b['id']}" not in data_of(buttons) and (await autofmt(ctx))["id"] == b["id"]
149
+ cb = await click(ctx, OWNER, f"fm:v:{a['id']}") # the other one can be used again
150
+ assert f"fm:use:{a['id']}" in data_of(cb.edits[-1][1])
151
+
152
+
153
+ async def test_a_manual_format_cannot_be_used_for_auto_rename(ctx):
154
+ auto = await make_format(ctx, "Auto one", kind="auto")
155
+ manual = await make_format(ctx, "Manual one")
156
+ cb = await click(ctx, OWNER, f"fm:v:{manual['id']}")
157
+ assert f"fm:use:{manual['id']}" not in data_of(cb.edits[-1][1]) and "Use it with /rename" in cb.edits[-1][0]
158
+ cb = await click(ctx, OWNER, f"fm:use:{manual['id']}") # a stale button
159
+ assert cb.alerts == ["Only an auto format can be used for auto rename."] and cb.edits == []
160
+ assert (await autofmt(ctx))["id"] == auto["id"]
161
+
162
+
163
+ # ---------------------------------------------------------------------------
164
+ # copying a format to the other kind
165
+ # ---------------------------------------------------------------------------
166
+
167
+
168
+ @pytest.mark.parametrize("source, target", [("auto", "manual"), ("manual", "auto")])
169
+ async def test_a_format_can_be_copied_to_the_other_kind(ctx, source, target):
170
+ fmt = await make_format(ctx, "One Piece", kind=source, meta={"title": "OP"}, keep_audio=["japanese"], caption="<i>{filename}</i>")
171
+ await ctx.db.set_format_thumb(OWNER, fmt["id"], jpeg())
172
+ cb = await click(ctx, OWNER, f"fm:v:{fmt['id']}")
173
+ assert f"fm:cp:{fmt['id']}" in data_of(cb.edits[-1][1]) and f"📋 Copy to {target} formats" in labels_of(cb.edits[-1][1])
174
+
175
+ cb = await click(ctx, OWNER, f"fm:cp:{fmt['id']}")
176
+ (copy,) = await ctx.db.list_formats(OWNER, target)
177
+ assert copy["id"] != fmt["id"] and copy["kind"] == target and copy["name"] == "One Piece"
178
+ assert copy["template"] == "One Piece - E{episode}" and copy["meta"]["title"] == "OP" and copy["keep_audio"] == ["japanese"]
179
+ assert copy["caption"] == "<i>{filename}</i>" and await ctx.db.get_format_thumb(OWNER, copy["id"]) == jpeg()
180
+ text, buttons = cb.edits[-1]
181
+ assert cb.toasts == [f"Copied to your {target} formats."] and f"is copied to your {target} formats" in text
182
+ assert data_of(buttons) == [f"fm:v:{copy['id']}", f"fm:v:{fmt['id']}", "x:close"]
183
+ assert (await ctx.db.get_format(OWNER, fmt["id"]))["kind"] == source and await ctx.db.count_formats(OWNER, source) == 1
184
+
185
+ cb = await click(ctx, OWNER, f"fm:v:{copy['id']}") # "Open the copy": it is a format of the other kind
186
+ assert f"{'🤖 Auto' if target == 'auto' else '✋ Manual'}" in cb.edits[-1][0].splitlines()[0]
187
+
188
+
189
+ async def test_copies_are_independent_and_get_a_free_name(ctx):
190
+ fmt = await make_format(ctx, "One Piece", kind="auto")
191
+ await click(ctx, OWNER, f"fm:cp:{fmt['id']}")
192
+ await click(ctx, OWNER, f"fm:cp:{fmt['id']}")
193
+ first, second = await ctx.db.list_formats(OWNER, "manual")
194
+ assert [first["name"], second["name"]] == ["One Piece", "One Piece (2)"]
195
+ await click(ctx, OWNER, f"fm:cp:{first['id']}") # and back again: "One Piece" is taken among the auto formats
196
+ back = (await ctx.db.list_formats(OWNER, "auto"))[-1]
197
+ assert back["name"] == "One Piece (2)" and back["kind"] == "auto" and await ctx.db.count_formats(OWNER, "auto") == 2
198
+
199
+ await click(ctx, OWNER, f"fm:in:{first['id']}:template")
200
+ await say(ctx, OWNER, "Changed {title}")
201
+ assert (await ctx.db.get_format(OWNER, first["id"]))["template"] == "Changed {title}"
202
+ assert (await ctx.db.get_format(OWNER, fmt["id"]))["template"] == "One Piece - E{episode}"
203
+ assert (await ctx.db.get_format(OWNER, second["id"]))["template"] == "One Piece - E{episode}"
204
+
205
+
206
+ @pytest.mark.parametrize("kind", ["auto", "manual"])
207
+ async def test_copying_to_a_full_kind_is_refused_and_the_kinds_count_apart(ctx, kind):
208
+ other = "manual" if kind == "auto" else "auto"
209
+ fmt = await make_format(ctx, "One Piece", kind=kind)
210
+ for i in range(MAX_FORMATS):
211
+ await make_format(ctx, f"F{i}", kind=other)
212
+ cb = await click(ctx, OWNER, f"fm:cp:{fmt['id']}")
213
+ assert cb.alerts == [f"You already have {MAX_FORMATS} {other} formats. Delete one first."] and cb.edits == []
214
+ assert await ctx.db.count_formats(OWNER, other) == MAX_FORMATS and await ctx.db.count_formats(OWNER, kind) == 1
215
+
216
+
217
+ async def test_a_copy_shows_up_where_its_kind_is_used(ctx):
218
+ auto = await make_format(ctx, "From auto", kind="auto")
219
+ manual = await make_format(ctx, "From manual")
220
+ await click(ctx, OWNER, f"fm:cp:{auto['id']}") # a manual copy of the auto format
221
+ await click(ctx, OWNER, f"fm:cp:{manual['id']}") # an auto copy of the manual one
222
+ assert {"From manual", "From auto"} <= set(labels_of((await say(ctx, OWNER, "/rename")).out[0][1]))
223
+ labels = labels_of((await click(ctx, OWNER, "m:auto")).edits[-1][1])
224
+ assert "From auto ✅" in labels and "From manual" in labels
225
+
226
+
227
+ async def test_an_auto_format_made_by_copying_is_the_first_one_and_renames_the_files(ctx):
228
+ manual = await make_format(ctx, "One Piece")
229
+ assert await autofmt(ctx) is None
230
+ await click(ctx, OWNER, f"fm:cp:{manual['id']}")
231
+ auto = await autofmt(ctx)
232
+ assert auto["kind"] == "auto" and auto["name"] == "One Piece"
233
+ ev = await send_doc(ctx, OWNER, ONE_PIECE, mid=50)
234
+ assert "🗂 Auto format: <b>One Piece</b>" in ev.texts[0] and latest_job(ctx).new_name == "One Piece - E1147.mkv"
235
+
236
+
237
+ # ---------------------------------------------------------------------------
238
+ # Apply default metadata
239
+ # ---------------------------------------------------------------------------
240
+
241
+
242
+ @pytest.mark.parametrize("kind", ["auto", "manual"])
243
+ async def test_apply_default_metadata_copies_all_of_it_into_the_format(ctx, kind):
244
+ await set_default_metadata(ctx) # the metadata switch is Off: copying does not depend on it
245
+ fmt = await make_format(
246
+ ctx, "One Piece", kind=kind, meta={"title": "Old title", "artist": "Old artist"}, keep_audio=["french"], keep_subtitle=["2"]
247
+ )
248
+ cb = await click(ctx, OWNER, f"fm:dm:{fmt['id']}")
249
+ got = await ctx.db.get_format(OWNER, fmt["id"])
250
+ assert got["meta"] == DEFAULT_META and got["keep_audio"] == ["japanese", "english"] and got["keep_subtitle"] == ["english"]
251
+ assert got["template"] == "One Piece - E{episode}" and got["kind"] == kind # nothing else changed
252
+ text, buttons = cb.edits[-1]
253
+ assert cb.toasts == ["Default metadata applied."] and "Your default metadata is now in <b>One Piece</b>" in text
254
+ assert "<b>Title</b> ▸ <code>My Channel</code>" in text and "Japanese, English" in text and f"fm:dm:{fmt['id']}" in data_of(buttons)
255
+
256
+ s = await prefs(ctx) # the default metadata itself is untouched, and the format is not linked to it afterwards
257
+ assert s["meta"] == DEFAULT_META and s["keep_audio"] == ["japanese", "english"] and s["metadata_on"] is False
258
+ await ctx.db.update_settings(OWNER, meta={"title": "Changed later"})
259
+ assert (await ctx.db.get_format(OWNER, fmt["id"]))["meta"]["title"] == "My Channel"
260
+
261
+
262
+ @pytest.mark.parametrize("kind", ["auto", "manual"])
263
+ async def test_a_new_format_can_take_the_default_metadata_straight_away(ctx, kind):
264
+ await set_default_metadata(ctx)
265
+ await click(ctx, OWNER, f"fm:new:{kind}", msg_id=500)
266
+ await say(ctx, OWNER, "Fresh")
267
+ (fmt,) = await ctx.db.list_formats(OWNER)
268
+ _chat, text, kw = ctx.client.edited[-1]
269
+ assert "🏷 Apply default metadata" in labels_of(kw["buttons"]) and "Apply default metadata" in text
270
+ assert fmt["meta"]["title"] == ""
271
+ await click(ctx, OWNER, f"fm:dm:{fmt['id']}")
272
+ assert (await ctx.db.get_format(OWNER, fmt["id"]))["meta"] == DEFAULT_META
273
+
274
+
275
+ @pytest.mark.parametrize("kind", ["auto", "manual"])
276
+ async def test_apply_default_metadata_says_so_when_there_is_none(ctx, kind):
277
+ fmt = await make_format(ctx, "One Piece", kind=kind, meta={"title": "Mine"})
278
+ await ctx.db.update_settings(OWNER, metadata_on=True) # the switch alone is not metadata
279
+ cb = await click(ctx, OWNER, f"fm:dm:{fmt['id']}")
280
+ assert cb.alerts == ["Your default metadata is empty. Set it in /metadata first, then apply it."] and cb.edits == []
281
+ assert (await ctx.db.get_format(OWNER, fmt["id"]))["meta"]["title"] == "Mine" # nothing was wiped
282
+
283
+
284
+ async def test_track_lists_alone_count_as_default_metadata(ctx):
285
+ await ctx.db.update_settings(OWNER, keep_audio=["english"])
286
+ fmt = await make_format(ctx, kind="auto")
287
+ cb = await click(ctx, OWNER, f"fm:dm:{fmt['id']}")
288
+ assert cb.toasts == ["Default metadata applied."] and (await ctx.db.get_format(OWNER, fmt["id"]))["keep_audio"] == ["english"]
289
+
290
+
291
+ async def test_a_file_renamed_with_a_format_that_took_the_default_metadata_gets_it(ctx):
292
+ await set_default_metadata(ctx)
293
+ fmt = await make_format(ctx)
294
+ await click(ctx, OWNER, f"fm:dm:{fmt['id']}")
295
+ await click(ctx, OWNER, f"rn:p:{fmt['id']}")
296
+ await send_doc(ctx, OWNER, ONE_PIECE, mid=50)
297
+ job = latest_job(ctx)
298
+ assert job.fmt["meta"]["title"] == "My Channel" and job.fmt["keep_audio"] == ["japanese", "english"]
299
+ out = apply_format(await prefs(ctx), job.fmt)
300
+ assert out["metadata_on"] is True and out["meta"]["video"] == "HD" # written, although the default switch is Off
301
+
302
+
303
+ async def test_an_auto_format_says_whether_the_default_metadata_applies_to_it(ctx):
304
+ auto = await make_format(ctx, "Auto one", kind="auto")
305
+ manual = await make_format(ctx, "Manual one")
306
+ cb = await click(ctx, OWNER, f"fm:v:{auto['id']}")
307
+ assert "No metadata of its own: your default metadata is used while it is On" in cb.edits[-1][0]
308
+ assert "your default metadata is used" not in (await click(ctx, OWNER, f"fm:v:{manual['id']}")).edits[-1][0] # a manual one never uses it
309
+ await ctx.db.update_format(OWNER, auto["id"], meta={"title": "Own"})
310
+ assert "your default metadata is used" not in (await click(ctx, OWNER, f"fm:v:{auto['id']}")).edits[-1][0]
311
+
312
+
313
+ async def test_the_metadata_screen_calls_it_default_metadata_and_points_to_the_button(ctx):
314
+ cb = await click(ctx, OWNER, "m:meta")
315
+ text = cb.edits[-1][0]
316
+ assert "Your default metadata is currently: Off" in text and "Apply default metadata" in text and "global" not in text.lower()
317
+ await click(ctx, OWNER, "set:meta:on")
318
+ assert (await prefs(ctx))["metadata_on"] is True # the On/Off switch and the /metadata commands are unchanged
319
+ assert "currently: On" in (await say(ctx, OWNER, "/metadata")).texts[0]
tests/test_caption_formatting_app.py CHANGED
@@ -1,9 +1,11 @@
1
- """A caption keeps the formatting it was sent with (bold, italic, links...): nobody has to type HTML."""
 
 
2
  from telethon.extensions import html as telegram_html
3
 
4
  from bot.pipeline import make_caption
5
 
6
- from .appkit import OWNER, click, data_of, prefs, say, say_formatted
7
  from .test_formats_app import make_format
8
 
9
  FORMATTED = "[b]Watch[/b] {title} [i]episode {episode}[/i] on [a=https://t.me/Anime_X]our channel[/a]"
@@ -17,71 +19,71 @@ STORED = '<b>Watch</b> {title} <i>episode {episode}</i> on <a href="https://t.me
17
 
18
  async def test_the_caption_command_keeps_the_formatting_that_was_applied(ctx):
19
  ev = await say_formatted(ctx, OWNER, "/caption " + FORMATTED)
20
- assert (await prefs(ctx))["caption"] == STORED
21
  assert "Caption template saved" in ev.texts[0] and "Unknown" not in ev.texts[0]
22
 
23
 
24
  async def test_the_caption_command_shows_the_caption_with_its_formatting(ctx):
25
  await say_formatted(ctx, OWNER, "/caption " + FORMATTED)
26
  text = (await say(ctx, OWNER, "/caption")).texts[0]
27
- assert "Current caption template" in text and STORED in text # shown as the person wrote it, not as tags to read
28
  assert "&lt;b&gt;" not in text and "&lt;a href" not in text
29
 
30
 
31
  async def test_the_command_may_carry_the_bot_name_extra_spaces_and_several_lines(ctx):
32
  await say_formatted(ctx, OWNER, "/caption@RenameBot [b]{filename}[/b]\nsecond [i]line[/i] {size}")
33
- assert (await prefs(ctx))["caption"] == "<b>{filename}</b>\nsecond <i>line</i> {size}"
34
 
35
 
36
  async def test_emoji_before_the_formatted_words_do_not_shift_it(ctx):
37
  await say_formatted(ctx, OWNER, "/caption 🔥🔥 [b]{filename}[/b] 👨‍👩‍👧 [i]{size}[/i]")
38
- assert (await prefs(ctx))["caption"] == "🔥🔥 <b>{filename}</b> 👨‍👩‍👧 <i>{size}</i>"
39
 
40
 
41
  async def test_html_typed_by_hand_still_works_when_nothing_is_formatted(ctx):
42
  await say(ctx, OWNER, "/caption <i>{filename}</i> · <a href=\"https://t.me/x\">join</a>")
43
- assert (await prefs(ctx))["caption"] == '<i>{filename}</i> · <a href="https://t.me/x">join</a>'
44
 
45
 
46
  async def test_what_telegram_finds_by_itself_is_not_formatting(ctx):
47
  """A #hashtag or an @mention is an entity too, but nothing was formatted: typed tags are still taken as typed."""
48
  await say_formatted(ctx, OWNER, "/caption <i>{filename}</i> [tag]#anime[/tag] [at]@channel[/at]")
49
- assert (await prefs(ctx))["caption"] == "<i>{filename}</i> #anime @channel"
50
 
51
 
52
  async def test_once_words_are_formatted_the_message_is_taken_as_written(ctx):
53
  await say_formatted(ctx, OWNER, "/caption <b>{filename}</b> [i]x[/i]")
54
- assert (await prefs(ctx))["caption"] == "&lt;b&gt;{filename}&lt;/b&gt; <i>x</i>" # the tags the person typed are just text
55
 
56
 
57
  async def test_a_spoiler_is_not_kept_so_its_words_stay_plain(ctx):
58
  await say_formatted(ctx, OWNER, "/caption [spoiler]secret[/spoiler] {title}")
59
- assert (await prefs(ctx))["caption"] == "secret {title}"
60
 
61
 
62
  async def test_reset_still_resets_when_it_was_made_bold(ctx):
63
  await say(ctx, OWNER, "/caption custom {filename}")
64
  ev = await say_formatted(ctx, OWNER, "/caption [b]reset[/b]")
65
- assert (await prefs(ctx))["caption"] == "<b>{filename}</b>" and "Caption template saved" in ev.texts[0]
66
 
67
 
68
  async def test_a_variable_cut_by_the_formatting_is_still_a_whole_variable(ctx):
69
  """Double-tapping {filename} selects the word without its braces: the bold covers the whole variable all the same."""
70
  ev = await say_formatted(ctx, OWNER, "/caption {[b]filename[/b]} · {siz[i]e} left[/i]")
71
- assert (await prefs(ctx))["caption"] == "<b>{filename}</b> · <i>{size} left</i>"
72
  assert "Unknown" not in ev.texts[0]
73
 
74
 
75
  async def test_unknown_variables_are_still_reported(ctx):
76
  ev = await say_formatted(ctx, OWNER, "/caption [b]{filename}[/b] {nonsense}")
77
  assert "Unknown variables" in ev.texts[0] and "{nonsense}" in ev.texts[0]
78
- assert (await prefs(ctx))["caption"] == "<b>{filename}</b> {nonsense}"
79
 
80
 
81
  async def test_a_caption_that_is_too_long_with_its_link_addresses_is_refused(ctx):
82
  ev = await say_formatted(ctx, OWNER, f"/caption [a=https://example.com/{'a' * 800}]link[/a] {{filename}}")
83
  assert "too long" in ev.texts[0] and "link addresses" in ev.texts[0]
84
- assert (await prefs(ctx))["caption"] == "<b>{filename}</b>"
85
 
86
 
87
  # ---------------------------------------------------------------------------
@@ -94,7 +96,7 @@ async def test_the_set_caption_prompt_keeps_the_formatting(ctx):
94
  assert "No HTML needed" in cb.edits[-1][0]
95
  ev = await say_formatted(ctx, OWNER, FORMATTED, mid=60)
96
  assert ev.deleted and OWNER not in ctx.app.pending
97
- assert (await prefs(ctx))["caption"] == STORED
98
  (chat, text, kw), = ctx.client.edited
99
  assert kw["message"] == 500 and "Caption template saved" in text and "CAPTION" in text
100
  assert STORED in text and "&lt;b&gt;" not in text # the panel shows the caption formatted
@@ -104,17 +106,17 @@ async def test_the_set_caption_prompt_keeps_the_formatting(ctx):
104
  async def test_the_set_caption_prompt_still_takes_typed_html_and_reset(ctx):
105
  await click(ctx, OWNER, "in:caption")
106
  await say(ctx, OWNER, "<u>{filename}</u> {size}")
107
- assert (await prefs(ctx))["caption"] == "<u>{filename}</u> {size}"
108
  await click(ctx, OWNER, "in:caption")
109
  await say_formatted(ctx, OWNER, "[b]reset[/b]")
110
- assert (await prefs(ctx))["caption"] == "<b>{filename}</b>"
111
 
112
 
113
  async def test_formatting_sent_to_another_prompt_is_not_turned_into_html(ctx):
114
  """Only captions are rich text: a template or a title is plain text, whatever it was formatted with."""
115
  await click(ctx, OWNER, "in:template")
116
  await say_formatted(ctx, OWNER, "[b]{title}[/b] [i]{quality}[/i]")
117
- assert (await prefs(ctx))["template"] == "{title} {quality}"
118
 
119
 
120
  async def test_a_prompt_that_is_refused_stays_open_for_another_try(ctx):
@@ -122,7 +124,7 @@ async def test_a_prompt_that_is_refused_stays_open_for_another_try(ctx):
122
  ev = await say_formatted(ctx, OWNER, f"[a=https://example.com/{'a' * 800}]link[/a]")
123
  assert "too long" in ev.texts[0] and "Try again" in ev.texts[0] and OWNER in ctx.app.pending
124
  await say_formatted(ctx, OWNER, "[b]{filename}[/b] ok")
125
- assert OWNER not in ctx.app.pending and (await prefs(ctx))["caption"] == "<b>{filename}</b> ok"
126
 
127
 
128
  # ---------------------------------------------------------------------------
@@ -131,7 +133,7 @@ async def test_a_prompt_that_is_refused_stays_open_for_another_try(ctx):
131
 
132
 
133
  async def test_a_formats_caption_prompt_keeps_the_formatting(ctx):
134
- await ctx.db.update_settings(OWNER, caption="<u>{filename}</u>")
135
  fmt = await make_format(ctx)
136
  i = fmt["id"]
137
  cb = await click(ctx, OWNER, f"fm:in:{i}:caption", msg_id=710)
@@ -139,7 +141,7 @@ async def test_a_formats_caption_prompt_keeps_the_formatting(ctx):
139
  ev = await say_formatted(ctx, OWNER, FORMATTED)
140
  assert ev.deleted and OWNER not in ctx.app.pending
141
  assert (await ctx.db.get_format(OWNER, i))["caption"] == STORED
142
- assert (await prefs(ctx))["caption"] == "<u>{filename}</u>" # the user's own caption was never touched
143
  chat, text, kw = ctx.client.edited[-1]
144
  assert kw["message"] == 710 and "Caption template saved" in text
145
  assert "<b>Caption</b> ▸ " + STORED in text and "· default" not in text # the format's screen shows it formatted
@@ -197,7 +199,7 @@ async def test_a_link_in_a_caption_gets_no_link_preview_on_any_screen_that_shows
197
 
198
  async def test_the_formatting_follows_the_values_that_replace_the_variables(ctx):
199
  await say_formatted(ctx, OWNER, "/caption [b]{filename}[/b] · [i]{size}[/i] · [a=https://t.me/x]{original}[/a]")
200
- caption = make_caption((await prefs(ctx))["caption"], new_name="One Piece - E5.mkv", orig_name="1147 - One Piece [Dual].mkv",
201
  ext=".mkv", size=1536, duration=0)
202
  text, entities = telegram_html.parse(caption) # what Telegram makes of it when it is sent with parse_mode="html"
203
  assert text == "One Piece - E5 · 1.50 KB · 1147 - One Piece [Dual].mkv"
@@ -211,7 +213,7 @@ async def test_the_formatting_follows_the_values_that_replace_the_variables(ctx)
211
 
212
  async def test_a_name_with_html_characters_cannot_break_the_formatting(ctx):
213
  await say_formatted(ctx, OWNER, "/caption [b]{filename}[/b] [i]{original}[/i]")
214
- caption = make_caption((await prefs(ctx))["caption"], new_name="Tom & <Jerry>.mkv", orig_name="a<b>&c.mkv", ext=".mkv", size=1, duration=0)
215
  text, entities = telegram_html.parse(caption)
216
  assert text == "Tom & <Jerry> a<b>&c.mkv"
217
  assert [(type(e).__name__, e.offset, e.length) for e in entities] == [("MessageEntityBold", 0, 13), ("MessageEntityItalic", 14, 10)]
 
1
+ """A caption keeps the formatting it was sent with (bold, italic, links...): nobody has to type HTML.
2
+
3
+ /caption and the Set Caption button edit the caption of the auto format in use, so ``autofmt`` is where it is read back."""
4
  from telethon.extensions import html as telegram_html
5
 
6
  from bot.pipeline import make_caption
7
 
8
+ from .appkit import OWNER, autofmt, click, data_of, say, say_formatted
9
  from .test_formats_app import make_format
10
 
11
  FORMATTED = "[b]Watch[/b] {title} [i]episode {episode}[/i] on [a=https://t.me/Anime_X]our channel[/a]"
 
19
 
20
  async def test_the_caption_command_keeps_the_formatting_that_was_applied(ctx):
21
  ev = await say_formatted(ctx, OWNER, "/caption " + FORMATTED)
22
+ assert (await autofmt(ctx))["caption"] == STORED
23
  assert "Caption template saved" in ev.texts[0] and "Unknown" not in ev.texts[0]
24
 
25
 
26
  async def test_the_caption_command_shows_the_caption_with_its_formatting(ctx):
27
  await say_formatted(ctx, OWNER, "/caption " + FORMATTED)
28
  text = (await say(ctx, OWNER, "/caption")).texts[0]
29
+ assert "Caption of your auto format" in text and STORED in text # shown as the person wrote it, not as tags to read
30
  assert "&lt;b&gt;" not in text and "&lt;a href" not in text
31
 
32
 
33
  async def test_the_command_may_carry_the_bot_name_extra_spaces_and_several_lines(ctx):
34
  await say_formatted(ctx, OWNER, "/caption@RenameBot [b]{filename}[/b]\nsecond [i]line[/i] {size}")
35
+ assert (await autofmt(ctx))["caption"] == "<b>{filename}</b>\nsecond <i>line</i> {size}"
36
 
37
 
38
  async def test_emoji_before_the_formatted_words_do_not_shift_it(ctx):
39
  await say_formatted(ctx, OWNER, "/caption 🔥🔥 [b]{filename}[/b] 👨‍👩‍👧 [i]{size}[/i]")
40
+ assert (await autofmt(ctx))["caption"] == "🔥🔥 <b>{filename}</b> 👨‍👩‍👧 <i>{size}</i>"
41
 
42
 
43
  async def test_html_typed_by_hand_still_works_when_nothing_is_formatted(ctx):
44
  await say(ctx, OWNER, "/caption <i>{filename}</i> · <a href=\"https://t.me/x\">join</a>")
45
+ assert (await autofmt(ctx))["caption"] == '<i>{filename}</i> · <a href="https://t.me/x">join</a>'
46
 
47
 
48
  async def test_what_telegram_finds_by_itself_is_not_formatting(ctx):
49
  """A #hashtag or an @mention is an entity too, but nothing was formatted: typed tags are still taken as typed."""
50
  await say_formatted(ctx, OWNER, "/caption <i>{filename}</i> [tag]#anime[/tag] [at]@channel[/at]")
51
+ assert (await autofmt(ctx))["caption"] == "<i>{filename}</i> #anime @channel"
52
 
53
 
54
  async def test_once_words_are_formatted_the_message_is_taken_as_written(ctx):
55
  await say_formatted(ctx, OWNER, "/caption <b>{filename}</b> [i]x[/i]")
56
+ assert (await autofmt(ctx))["caption"] == "&lt;b&gt;{filename}&lt;/b&gt; <i>x</i>" # the tags the person typed are just text
57
 
58
 
59
  async def test_a_spoiler_is_not_kept_so_its_words_stay_plain(ctx):
60
  await say_formatted(ctx, OWNER, "/caption [spoiler]secret[/spoiler] {title}")
61
+ assert (await autofmt(ctx))["caption"] == "secret {title}"
62
 
63
 
64
  async def test_reset_still_resets_when_it_was_made_bold(ctx):
65
  await say(ctx, OWNER, "/caption custom {filename}")
66
  ev = await say_formatted(ctx, OWNER, "/caption [b]reset[/b]")
67
+ assert (await autofmt(ctx))["caption"] == "<b>{filename}</b>" and "Caption template saved" in ev.texts[0]
68
 
69
 
70
  async def test_a_variable_cut_by_the_formatting_is_still_a_whole_variable(ctx):
71
  """Double-tapping {filename} selects the word without its braces: the bold covers the whole variable all the same."""
72
  ev = await say_formatted(ctx, OWNER, "/caption {[b]filename[/b]} · {siz[i]e} left[/i]")
73
+ assert (await autofmt(ctx))["caption"] == "<b>{filename}</b> · <i>{size} left</i>"
74
  assert "Unknown" not in ev.texts[0]
75
 
76
 
77
  async def test_unknown_variables_are_still_reported(ctx):
78
  ev = await say_formatted(ctx, OWNER, "/caption [b]{filename}[/b] {nonsense}")
79
  assert "Unknown variables" in ev.texts[0] and "{nonsense}" in ev.texts[0]
80
+ assert (await autofmt(ctx))["caption"] == "<b>{filename}</b> {nonsense}"
81
 
82
 
83
  async def test_a_caption_that_is_too_long_with_its_link_addresses_is_refused(ctx):
84
  ev = await say_formatted(ctx, OWNER, f"/caption [a=https://example.com/{'a' * 800}]link[/a] {{filename}}")
85
  assert "too long" in ev.texts[0] and "link addresses" in ev.texts[0]
86
+ assert await autofmt(ctx) is None # nothing was saved, so there is no format either
87
 
88
 
89
  # ---------------------------------------------------------------------------
 
96
  assert "No HTML needed" in cb.edits[-1][0]
97
  ev = await say_formatted(ctx, OWNER, FORMATTED, mid=60)
98
  assert ev.deleted and OWNER not in ctx.app.pending
99
+ assert (await autofmt(ctx))["caption"] == STORED
100
  (chat, text, kw), = ctx.client.edited
101
  assert kw["message"] == 500 and "Caption template saved" in text and "CAPTION" in text
102
  assert STORED in text and "&lt;b&gt;" not in text # the panel shows the caption formatted
 
106
  async def test_the_set_caption_prompt_still_takes_typed_html_and_reset(ctx):
107
  await click(ctx, OWNER, "in:caption")
108
  await say(ctx, OWNER, "<u>{filename}</u> {size}")
109
+ assert (await autofmt(ctx))["caption"] == "<u>{filename}</u> {size}"
110
  await click(ctx, OWNER, "in:caption")
111
  await say_formatted(ctx, OWNER, "[b]reset[/b]")
112
+ assert (await autofmt(ctx))["caption"] == "<b>{filename}</b>"
113
 
114
 
115
  async def test_formatting_sent_to_another_prompt_is_not_turned_into_html(ctx):
116
  """Only captions are rich text: a template or a title is plain text, whatever it was formatted with."""
117
  await click(ctx, OWNER, "in:template")
118
  await say_formatted(ctx, OWNER, "[b]{title}[/b] [i]{quality}[/i]")
119
+ assert (await autofmt(ctx))["template"] == "{title} {quality}"
120
 
121
 
122
  async def test_a_prompt_that_is_refused_stays_open_for_another_try(ctx):
 
124
  ev = await say_formatted(ctx, OWNER, f"[a=https://example.com/{'a' * 800}]link[/a]")
125
  assert "too long" in ev.texts[0] and "Try again" in ev.texts[0] and OWNER in ctx.app.pending
126
  await say_formatted(ctx, OWNER, "[b]{filename}[/b] ok")
127
+ assert OWNER not in ctx.app.pending and (await autofmt(ctx))["caption"] == "<b>{filename}</b> ok"
128
 
129
 
130
  # ---------------------------------------------------------------------------
 
133
 
134
 
135
  async def test_a_formats_caption_prompt_keeps_the_formatting(ctx):
136
+ await say(ctx, OWNER, "/caption <u>{filename}</u>") # the auto format in use has a caption of its own
137
  fmt = await make_format(ctx)
138
  i = fmt["id"]
139
  cb = await click(ctx, OWNER, f"fm:in:{i}:caption", msg_id=710)
 
141
  ev = await say_formatted(ctx, OWNER, FORMATTED)
142
  assert ev.deleted and OWNER not in ctx.app.pending
143
  assert (await ctx.db.get_format(OWNER, i))["caption"] == STORED
144
+ assert (await autofmt(ctx))["caption"] == "<u>{filename}</u>" # the auto format in use was never touched
145
  chat, text, kw = ctx.client.edited[-1]
146
  assert kw["message"] == 710 and "Caption template saved" in text
147
  assert "<b>Caption</b> ▸ " + STORED in text and "· default" not in text # the format's screen shows it formatted
 
199
 
200
  async def test_the_formatting_follows_the_values_that_replace_the_variables(ctx):
201
  await say_formatted(ctx, OWNER, "/caption [b]{filename}[/b] · [i]{size}[/i] · [a=https://t.me/x]{original}[/a]")
202
+ caption = make_caption((await autofmt(ctx))["caption"], new_name="One Piece - E5.mkv", orig_name="1147 - One Piece [Dual].mkv",
203
  ext=".mkv", size=1536, duration=0)
204
  text, entities = telegram_html.parse(caption) # what Telegram makes of it when it is sent with parse_mode="html"
205
  assert text == "One Piece - E5 · 1.50 KB · 1147 - One Piece [Dual].mkv"
 
213
 
214
  async def test_a_name_with_html_characters_cannot_break_the_formatting(ctx):
215
  await say_formatted(ctx, OWNER, "/caption [b]{filename}[/b] [i]{original}[/i]")
216
+ caption = make_caption((await autofmt(ctx))["caption"], new_name="Tom & <Jerry>.mkv", orig_name="a<b>&c.mkv", ext=".mkv", size=1, duration=0)
217
  text, entities = telegram_html.parse(caption)
218
  assert text == "Tom & <Jerry> a<b>&c.mkv"
219
  assert [(type(e).__name__, e.offset, e.length) for e in entities] == [("MessageEntityBold", 0, 13), ("MessageEntityItalic", 14, 10)]
tests/test_check.py CHANGED
@@ -81,3 +81,18 @@ async def test_check_is_in_the_command_menu(ctx):
81
  from bot.app import COMMANDS
82
 
83
  assert "check" in dict(COMMANDS)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  from bot.app import COMMANDS
82
 
83
  assert "check" in dict(COMMANDS)
84
+
85
+
86
+ async def test_the_new_name_is_the_one_the_auto_format_in_use_would_give(ctx):
87
+ await ctx.db.create_format(OWNER, "One", "One - E{episode}", kind="auto")
88
+ two = await ctx.db.create_format(OWNER, "Two", "Two - {title} {3episode}", kind="auto")
89
+ await ctx.db.create_format(OWNER, "Manual", "Manual - {title}") # a manual format is for /rename, not for the automatic renames
90
+ reply = document_message("1147 - One Piece [Dual] 720p.mkv")
91
+ assert "<code>One - E1147.mkv</code>" in (await say(ctx, OWNER, "/check", reply=reply)).texts[0]
92
+ await ctx.db.select_auto_format(OWNER, two["id"])
93
+ assert "<code>Two - One Piece 1147.mkv</code>" in (await say(ctx, OWNER, "/check", reply=reply)).texts[0]
94
+
95
+
96
+ async def test_without_an_auto_format_the_new_name_follows_the_built_in_template(ctx):
97
+ ev = await say(ctx, OWNER, "/check", reply=document_message("[SubsPlease] Tokyo Revengers - 05 (1080p) [ABCD1234].mkv"))
98
+ assert "<code>[S01E05] Tokyo Revengers [1080p].mkv</code>" in ev.texts[0]
tests/test_db.py CHANGED
@@ -1,6 +1,9 @@
 
 
1
  import pytest
2
 
3
- from bot.db import DEFAULT_SETTINGS, Database, normalize_database_url
 
4
 
5
 
6
  @pytest.fixture
@@ -43,15 +46,7 @@ async def test_unknown_setting_rejected(db):
43
  await db.update_settings(1, nope=1)
44
 
45
 
46
- async def test_thumb_and_stats(db):
47
- assert not await db.has_thumb(5)
48
- await db.set_thumb(5, b"\xff\xd8jpeg")
49
- assert await db.has_thumb(5)
50
- assert await db.get_thumb(5) == b"\xff\xd8jpeg"
51
- await db.set_thumb(5, None)
52
- assert not await db.has_thumb(5)
53
- assert await db.get_thumb(5) is None
54
-
55
  await db.record_job(5, 1000)
56
  await db.record_job(5, 24)
57
  await db.get_settings(6)
@@ -111,7 +106,7 @@ async def test_first_use_by_many_concurrent_handlers_does_not_collide(db):
111
  results = await asyncio.gather(*(db.get_settings(77) for _ in range(8)))
112
  assert all(r == DEFAULT_SETTINGS for r in results)
113
  # the same goes for the other writers that create the row on first use
114
- await asyncio.gather(*(db.record_job(78, 10) for _ in range(5)), *(db.set_thumb(79, b"x") for _ in range(5)))
115
  assert await db.count_users() == 3
116
 
117
 
@@ -121,17 +116,19 @@ async def test_first_use_by_many_concurrent_handlers_does_not_collide(db):
121
 
122
 
123
  async def test_captions_are_bold_by_default(db):
124
- assert DEFAULT_SETTINGS["caption"] == "<b>{filename}</b>"
125
- assert (await db.get_settings(1))["caption"] == "<b>{filename}</b>"
 
126
 
127
 
128
  async def test_the_plain_default_caption_of_earlier_versions_becomes_bold_once(db):
129
  import json
130
 
131
- from bot.db import BOLD_CAPTION_MIGRATION, User
132
 
133
- # what an older version left behind: no marker yet, and the settings stored in full
134
  await db.kv_delete(BOLD_CAPTION_MIGRATION)
 
135
  stored = {
136
  1: {"caption": "{filename}", "mode": "caption", "template": "T {title}"}, # still the old default
137
  2: {"caption": "{filename} · {size}"}, # their own caption
@@ -144,21 +141,27 @@ async def test_the_plain_default_caption_of_earlier_versions_becomes_bold_once(d
144
  session.add(User(id=uid, settings=value if isinstance(value, str) else json.dumps(value)))
145
  await session.commit()
146
 
147
- await db.init() # runs the migration
148
  fresh = Database(db.url) # a new instance has no cache
149
  try:
 
150
  s1 = await fresh.get_settings(1)
151
- assert s1["caption"] == "<b>{filename}</b>" and s1["mode"] == "caption" and s1["template"] == "T {title}"
152
- assert (await fresh.get_settings(2))["caption"] == "{filename} · {size}"
153
- assert (await fresh.get_settings(3))["caption"] == "<b>{filename}</b>"
 
 
 
 
154
  assert await fresh.get_settings(4) == DEFAULT_SETTINGS and await fresh.get_settings(5) == DEFAULT_SETTINGS
155
- await fresh.update_settings(1, caption="{filename}") # going back to a plain caption afterwards...
156
  finally:
157
  await fresh.close()
158
  await db.init()
159
  again = Database(db.url)
160
  try:
161
- assert (await again.get_settings(1))["caption"] == "{filename}" # ...sticks, also across restarts
 
162
  finally:
163
  await again.close()
164
 
@@ -219,3 +222,139 @@ async def test_copy_format_stops_at_the_limit(db):
219
  with pytest.raises(FormatError) as raised:
220
  await db.copy_format(2, 1, src["id"])
221
  assert raised.value.code == "limit" and await db.count_formats(2) == MAX_FORMATS
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+
3
  import pytest
4
 
5
+ from bot.db import AUTO_FORMATS_MIGRATION, DEFAULT_CAPTION, DEFAULT_FORMAT, DEFAULT_SETTINGS, Database, Format, User, normalize_database_url
6
+ from bot.parser import DEFAULT_TEMPLATE
7
 
8
 
9
  @pytest.fixture
 
46
  await db.update_settings(1, nope=1)
47
 
48
 
49
+ async def test_stats(db):
 
 
 
 
 
 
 
 
50
  await db.record_job(5, 1000)
51
  await db.record_job(5, 24)
52
  await db.get_settings(6)
 
106
  results = await asyncio.gather(*(db.get_settings(77) for _ in range(8)))
107
  assert all(r == DEFAULT_SETTINGS for r in results)
108
  # the same goes for the other writers that create the row on first use
109
+ await asyncio.gather(*(db.record_job(78, 10) for _ in range(5)), *(db.update_settings(79, mode="caption") for _ in range(5)))
110
  assert await db.count_users() == 3
111
 
112
 
 
116
 
117
 
118
  async def test_captions_are_bold_by_default(db):
119
+ assert DEFAULT_CAPTION == DEFAULT_FORMAT["caption"] == "<b>{filename}</b>"
120
+ assert (await db.create_format(1, "New", kind="auto"))["caption"] == "<b>{filename}</b>"
121
+ assert "caption" not in DEFAULT_SETTINGS and "template" not in DEFAULT_SETTINGS # they live in the formats now
122
 
123
 
124
  async def test_the_plain_default_caption_of_earlier_versions_becomes_bold_once(db):
125
  import json
126
 
127
+ from bot.db import AUTO_FORMATS_MIGRATION, BOLD_CAPTION_MIGRATION, User
128
 
129
+ # what an older version left behind: no markers yet, and the settings stored in full
130
  await db.kv_delete(BOLD_CAPTION_MIGRATION)
131
+ await db.kv_delete(AUTO_FORMATS_MIGRATION)
132
  stored = {
133
  1: {"caption": "{filename}", "mode": "caption", "template": "T {title}"}, # still the old default
134
  2: {"caption": "{filename} · {size}"}, # their own caption
 
141
  session.add(User(id=uid, settings=value if isinstance(value, str) else json.dumps(value)))
142
  await session.commit()
143
 
144
+ await db.init() # runs the migrations
145
  fresh = Database(db.url) # a new instance has no cache
146
  try:
147
+ # the old default became the bold one, and the old template and caption became an auto format
148
  s1 = await fresh.get_settings(1)
149
+ assert s1["mode"] == "caption"
150
+ (one,) = await fresh.list_formats(1, "auto")
151
+ assert one["name"] == "Default" and one["caption"] == "<b>{filename}</b>" and one["template"] == "T {title}"
152
+ assert s1["auto_format"] == one["id"]
153
+ (two,) = await fresh.list_formats(2, "auto")
154
+ assert two["caption"] == "{filename} · {size}" and two["template"] == DEFAULT_FORMAT["template"]
155
+ assert await fresh.list_formats(3) == [] and (await fresh.get_settings(3))["mode"] == "caption" # nothing of theirs to keep
156
  assert await fresh.get_settings(4) == DEFAULT_SETTINGS and await fresh.get_settings(5) == DEFAULT_SETTINGS
157
+ await fresh.update_format(1, one["id"], caption="{filename}") # going back to a plain caption afterwards...
158
  finally:
159
  await fresh.close()
160
  await db.init()
161
  again = Database(db.url)
162
  try:
163
+ assert (await again.get_format(1, one["id"]))["caption"] == "{filename}" # ...sticks, also across restarts
164
+ assert len(await again.list_formats(1)) == 1 # and the migrations ran only once
165
  finally:
166
  await again.close()
167
 
 
222
  with pytest.raises(FormatError) as raised:
223
  await db.copy_format(2, 1, src["id"])
224
  assert raised.value.code == "limit" and await db.count_formats(2) == MAX_FORMATS
225
+
226
+
227
+ # ---------------------------------------------------------------------------
228
+ # from the global template, caption and thumbnail to auto formats
229
+ # ---------------------------------------------------------------------------
230
+
231
+ META = {"title": "My Title", "author": "", "artist": "", "audio": "", "subtitle": "", "video": ""}
232
+
233
+
234
+ async def start_after_an_old_version(db, users, formats=()):
235
+ """Store what a version without auto formats left behind (no marker yet), start the bot on it and return a fresh Database.
236
+
237
+ ``users``: ``{id: (stored settings, old thumbnail)}``; ``formats``: ``[(user id, name, stored data, thumbnail)]`` - formats of
238
+ that version had no kind.
239
+ """
240
+ await db.kv_delete(AUTO_FORMATS_MIGRATION)
241
+ async with db._sessions() as session:
242
+ for uid, (settings, thumb) in users.items():
243
+ session.add(User(id=uid, settings=json.dumps(settings), thumb=thumb))
244
+ for uid, name, data, thumb in formats:
245
+ session.add(Format(user_id=uid, name=name, data=json.dumps(data), thumb=thumb))
246
+ await session.commit()
247
+ await db.init()
248
+ return Database(db.url) # a new instance has no cache
249
+
250
+
251
+ async def test_the_old_template_caption_and_thumbnail_become_the_auto_format_default(db):
252
+ fresh = await start_after_an_old_version(
253
+ db, {1: ({"template": "T {title}", "caption": "<i>{filename}</i>", "mode": "caption"}, b"\xff\xd8old")}
254
+ )
255
+ try:
256
+ (auto,) = await fresh.list_formats(1)
257
+ assert (auto["name"], auto["kind"]) == ("Default", "auto")
258
+ assert auto["template"] == "T {title}" and auto["caption"] == "<i>{filename}</i>" and auto["has_thumb"]
259
+ assert await fresh.get_format_thumb(1, auto["id"]) == b"\xff\xd8old"
260
+ settings = await fresh.get_settings(1)
261
+ assert settings["auto_format"] == auto["id"] and settings["mode"] == "caption" # in use, the rest of the settings is untouched
262
+ assert (await fresh.get_auto_format(1))["id"] == auto["id"]
263
+ async with fresh._sessions() as session: # and nothing is kept in two places
264
+ row = await session.get(User, 1)
265
+ assert row.thumb is None and {"template", "caption"}.isdisjoint(json.loads(row.settings))
266
+ finally:
267
+ await fresh.close()
268
+
269
+
270
+ async def test_the_old_metadata_stays_as_the_default_metadata_and_is_not_copied_into_the_auto_format(db):
271
+ users = {
272
+ 1: ({"template": "A {title}", "metadata_on": True, "meta": META, "keep_audio": ["english"]}, None),
273
+ 2: ({"template": "B {title}", "metadata_on": False, "meta": META, "keep_audio": ["english"]}, None),
274
+ 3: ({"metadata_on": True, "meta": META}, None), # nothing else of its own
275
+ }
276
+ fresh = await start_after_an_old_version(db, users)
277
+ try:
278
+ for uid, template in ((1, "A {title}"), (2, "B {title}")):
279
+ (auto,) = await fresh.list_formats(uid)
280
+ # no snapshot: it has none of its own, so the default metadata applies to it while it is On, edits included
281
+ assert auto["template"] == template and not any(auto["meta"].values()) and auto["keep_audio"] == [] and auto["keep_subtitle"] == []
282
+ assert await fresh.list_formats(3) == [] # nothing to keep: the built-in defaults are used
283
+ for uid in (1, 2, 3): # the metadata is where it always was
284
+ settings = await fresh.get_settings(uid)
285
+ assert settings["meta"]["title"] == "My Title" and settings["metadata_on"] is (uid in (1, 3))
286
+ assert (await fresh.get_settings(2))["keep_audio"] == ["english"] and (await fresh.get_settings(1))["keep_audio"] == ["english"]
287
+ finally:
288
+ await fresh.close()
289
+
290
+
291
+ async def test_formats_made_before_the_kinds_are_manual_and_the_ones_without_a_thumbnail_keep_the_old_one(db):
292
+ fresh = await start_after_an_old_version(
293
+ db,
294
+ {1: ({"template": "T"}, b"old-1"), 2: ({}, b"old-2"), 3: ({}, None)},
295
+ formats=[
296
+ (1, "Own thumb", {"template": "X"}, b"own"), (1, "Borrowing", {"template": "Y"}, None),
297
+ (2, "Other", {}, None), (3, "Plain", {}, None),
298
+ ],
299
+ )
300
+ try:
301
+ own, borrowing = await fresh.list_formats(1, "manual")
302
+ assert (own["name"], borrowing["name"]) == ("Own thumb", "Borrowing") and own["template"] == "X"
303
+ assert await fresh.get_format_thumb(1, own["id"]) == b"own" # it had one of its own
304
+ assert await fresh.get_format_thumb(1, borrowing["id"]) == b"old-1" # it used the old global one: it still does
305
+ (auto,) = await fresh.list_formats(1, "auto")
306
+ assert await fresh.get_format_thumb(1, auto["id"]) == b"old-1"
307
+
308
+ (other,) = await fresh.list_formats(2, "manual")
309
+ assert await fresh.get_format_thumb(2, other["id"]) == b"old-2"
310
+ (their_auto,) = await fresh.list_formats(2, "auto") # a thumbnail alone is something to keep
311
+ assert their_auto["template"] == DEFAULT_TEMPLATE and their_auto["has_thumb"]
312
+
313
+ (plain,) = await fresh.list_formats(3)
314
+ assert plain["kind"] == "manual" and not plain["has_thumb"] and await fresh.get_auto_format(3) is None
315
+ finally:
316
+ await fresh.close()
317
+
318
+
319
+ async def test_someone_who_never_changed_anything_gets_no_auto_format(db):
320
+ fresh = await start_after_an_old_version(db, {1: ({}, None), 2: ({"template": DEFAULT_TEMPLATE, "caption": DEFAULT_CAPTION}, None)})
321
+ try:
322
+ for uid in (1, 2):
323
+ assert await fresh.list_formats(uid) == [] and (await fresh.get_settings(uid))["auto_format"] == 0
324
+ assert await fresh.get_auto_format(uid) is None
325
+ finally:
326
+ await fresh.close()
327
+
328
+
329
+ async def test_the_migration_makes_each_users_auto_format_once(db):
330
+ fresh = await start_after_an_old_version(db, {1: ({"template": "T"}, b"t")})
331
+ await fresh.close()
332
+ await db.kv_delete(AUTO_FORMATS_MIGRATION) # as if the marker was lost: the user's own mark is what counts
333
+ await db.init()
334
+ again = Database(db.url)
335
+ try:
336
+ assert len(await again.list_formats(1)) == 1
337
+ assert await again.kv_get(AUTO_FORMATS_MIGRATION) == "done"
338
+ finally:
339
+ await again.close()
340
+
341
+
342
+ async def test_settings_that_cannot_be_read_are_left_as_they_are(db):
343
+ await db.kv_delete(AUTO_FORMATS_MIGRATION)
344
+ async with db._sessions() as session:
345
+ session.add(User(id=1, settings="not json", thumb=b"x"))
346
+ session.add(User(id=2, settings="[1, 2]"))
347
+ await session.commit()
348
+ await db.init()
349
+ async with db._sessions() as session:
350
+ assert (await session.get(User, 1)).settings == "not json" and (await session.get(User, 2)).settings == "[1, 2]"
351
+ assert await db.list_formats(1) == [] and await db.list_formats(2) == []
352
+
353
+
354
+ async def test_a_new_installation_just_notes_that_nothing_needs_changing(tmp_path):
355
+ fresh = Database(f"sqlite+aiosqlite:///{tmp_path}/new.db")
356
+ await fresh.init()
357
+ try:
358
+ assert await fresh.kv_get(AUTO_FORMATS_MIGRATION) == "done" and await fresh.count_users() == 0
359
+ finally:
360
+ await fresh.close()
tests/test_ethumb.py CHANGED
@@ -10,7 +10,7 @@ from telethon import types
10
 
11
  from bot import app as appmod
12
 
13
- from .appkit import OWNER, STRANGER, click, data_of, jpeg, say, send_doc
14
  from .conftest import needs_ffmpeg
15
  from .tgfakes import FakeEvent, FakeMessage, dispatch, document_message
16
 
@@ -164,7 +164,7 @@ async def test_it_handles_one_file_per_command(ctx):
164
  async def test_a_photo_while_waiting_still_sets_the_thumbnail_as_usual(ctx):
165
  await say(ctx, OWNER, "/ethumb")
166
  await dispatch(ctx.client, FakeEvent(OWNER, message=FakeMessage("", photo=True, mid=80), photo_bytes=jpeg()))
167
- assert await ctx.db.has_thumb(OWNER) and ctx.app.modes[OWNER].kind == "ethumb"
168
 
169
 
170
  async def test_cancel_and_the_stop_button_end_it(ctx):
 
10
 
11
  from bot import app as appmod
12
 
13
+ from .appkit import OWNER, STRANGER, auto_thumb, click, data_of, jpeg, say, send_doc
14
  from .conftest import needs_ffmpeg
15
  from .tgfakes import FakeEvent, FakeMessage, dispatch, document_message
16
 
 
164
  async def test_a_photo_while_waiting_still_sets_the_thumbnail_as_usual(ctx):
165
  await say(ctx, OWNER, "/ethumb")
166
  await dispatch(ctx.client, FakeEvent(OWNER, message=FakeMessage("", photo=True, mid=80), photo_bytes=jpeg()))
167
+ assert await auto_thumb(ctx) and ctx.app.modes[OWNER].kind == "ethumb"
168
 
169
 
170
  async def test_cancel_and_the_stop_button_end_it(ctx):
tests/test_formats.py CHANGED
@@ -1,8 +1,15 @@
 
 
1
  import pytest
 
2
 
3
- from bot.db import DEFAULT_FORMAT, DEFAULT_SETTINGS, MAX_FORMATS, Database
4
  from bot.errors import FormatError
5
- from bot.formats import NAME_MAX, FormatSettings, apply_format, has_metadata, validate_name
 
 
 
 
6
  from bot.settings_ops import apply_setting
7
 
8
  U, V = 111, 222
@@ -128,7 +135,7 @@ def test_validate_name():
128
 
129
 
130
  def fmt_with(**kw):
131
- base = {"id": 1, "name": "F", "has_thumb": False, "template": "T {title}", "caption": DEFAULT_FORMAT["caption"],
132
  "meta": dict(DEFAULT_FORMAT["meta"]), "keep_audio": [], "keep_subtitle": []}
133
  base.update(kw)
134
  return base
@@ -137,17 +144,17 @@ def fmt_with(**kw):
137
  def test_apply_format_lays_the_format_over_the_settings_without_mutating_them():
138
  settings = dict(DEFAULT_SETTINGS, mode="caption", caption="<i>{original}</i>", file_type="document", remove_words=["x"],
139
  target_channel=-100123, metadata_on=False, keep_audio=["french"])
140
- settings["meta"] = dict(DEFAULT_SETTINGS["meta"], title="GLOBAL TITLE")
141
  fmt = fmt_with(template="One Piece - E{episode}", meta=dict(DEFAULT_FORMAT["meta"], author="@Me"), keep_subtitle=["english"])
142
  out = apply_format(settings, fmt)
143
  assert out["template"] == "One Piece - E{episode}"
144
- assert out["meta"]["author"] == "@Me" and out["meta"]["title"] == "" # the format's own metadata, not the global one
145
  assert out["keep_audio"] == [] and out["keep_subtitle"] == ["english"]
146
  assert out["metadata_on"] is True
147
  assert out["caption"] == "<b>{filename}</b>" # the format's own, not the user's <i>{original}</i>
148
  # everything else is still the user's
149
  assert (out["mode"], out["file_type"], out["remove_words"], out["target_channel"]) == ("caption", "document", ["x"], -100123)
150
- assert settings["template"] == DEFAULT_SETTINGS["template"] and settings["meta"]["title"] == "GLOBAL TITLE"
151
  assert settings["caption"] == "<i>{original}</i>"
152
 
153
 
@@ -164,7 +171,7 @@ def test_a_format_brings_its_own_caption_and_never_borrows_the_users():
164
 
165
  def test_a_format_without_metadata_leaves_the_file_alone():
166
  assert not has_metadata(fmt_with())
167
- settings = dict(DEFAULT_SETTINGS, metadata_on=True) # global metadata on, but the format sets nothing
168
  assert apply_format(settings, fmt_with())["metadata_on"] is False
169
  assert has_metadata(fmt_with(keep_audio=["1"])) and has_metadata(fmt_with(meta=dict(DEFAULT_FORMAT["meta"], video="v")))
170
 
@@ -174,7 +181,7 @@ def test_a_format_without_metadata_leaves_the_file_alone():
174
  # ---------------------------------------------------------------------------
175
 
176
 
177
- async def test_settings_ops_edit_a_format_with_the_same_rules_as_the_global_settings(db):
178
  fmt = await db.create_format(U, "One Piece")
179
  store = FormatSettings(db, fmt["id"])
180
 
@@ -221,7 +228,7 @@ async def test_editing_a_deleted_format_raises_lookup_error(db):
221
 
222
  async def test_a_format_starts_with_the_file_name_in_bold_and_keeps_a_caption_of_its_own(db):
223
  fmt = await db.create_format(U, "One Piece")
224
- assert fmt["caption"] == "<b>{filename}</b>" == DEFAULT_SETTINGS["caption"]
225
  out = await db.update_format(U, fmt["id"], caption="<i>{filename}</i> · {size}")
226
  assert out["caption"] == "<i>{filename}</i> · {size}"
227
  await db.update_format(U, fmt["id"], template="X {title}", meta={"title": "T"}) # other edits leave it alone
@@ -229,7 +236,7 @@ async def test_a_format_starts_with_the_file_name_in_bold_and_keeps_a_caption_of
229
  assert (await other.get_format(U, fmt["id"]))["caption"] == "<i>{filename}</i> · {size}"
230
  await other.close()
231
  assert (await db.list_formats(U))[0]["caption"] == "<i>{filename}</i> · {size}"
232
- assert await db.get_settings(U) == DEFAULT_SETTINGS # the user's own caption is a different thing
233
 
234
 
235
  async def test_formats_saved_before_captions_existed_get_the_default_one(db):
@@ -256,14 +263,14 @@ async def test_a_copy_of_a_format_has_its_caption(db):
256
  assert (await db.get_format(U, fmt["id"]))["caption"] == "<code>{filename}</code>"
257
 
258
 
259
- async def test_settings_ops_edit_a_formats_caption_with_the_checks_of_the_global_one(db):
260
  fmt = await db.create_format(U, "One Piece")
261
  store = FormatSettings(db, fmt["id"])
262
  ok, msg = await apply_setting(store, U, "caption", "<b>{filename}</b> · {size} · {duration}")
263
  assert ok and "Caption template saved" in msg and "Unknown" not in msg
264
  assert (await db.get_format(U, fmt["id"]))["caption"] == "<b>{filename}</b> · {size} · {duration}"
265
  ok, msg = await apply_setting(store, U, "caption", "<i>{nonsense}</i>")
266
- assert ok and "Unknown variables" in msg and "{nonsense}" in msg # kept as plain text, like for the global one
267
  ok, msg = await apply_setting(store, U, "caption", "x" * 801)
268
  assert not ok and "too long" in msg and (await db.get_format(U, fmt["id"]))["caption"] == "<i>{nonsense}</i>"
269
  ok, msg = await apply_setting(store, U, "caption", "")
@@ -274,3 +281,269 @@ async def test_settings_ops_edit_a_formats_caption_with_the_checks_of_the_global
274
  assert ok and (await db.get_format(U, fmt["id"]))["caption"] == "<b>{filename}</b>", word # the file name in bold again
275
  assert (await store.get_settings(U))["caption"] == "<b>{filename}</b>"
276
  assert await db.get_settings(U) == DEFAULT_SETTINGS
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+
3
  import pytest
4
+ from sqlalchemy.ext.asyncio import AsyncSession
5
 
6
+ from bot.db import DEFAULT_CAPTION, DEFAULT_FORMAT, DEFAULT_SETTINGS, MAX_FORMATS, Database
7
  from bot.errors import FormatError
8
+ from bot.formats import (
9
+ NAME_MAX, AutoFormatSettings, FormatSettings, apply_format, default_auto_format, default_metadata_changes, has_default_metadata,
10
+ has_metadata, other_kind, validate_name,
11
+ )
12
+ from bot.parser import DEFAULT_TEMPLATE
13
  from bot.settings_ops import apply_setting
14
 
15
  U, V = 111, 222
 
135
 
136
 
137
  def fmt_with(**kw):
138
+ base = {"id": 1, "name": "F", "kind": "manual", "has_thumb": False, "template": "T {title}", "caption": DEFAULT_FORMAT["caption"],
139
  "meta": dict(DEFAULT_FORMAT["meta"]), "keep_audio": [], "keep_subtitle": []}
140
  base.update(kw)
141
  return base
 
144
  def test_apply_format_lays_the_format_over_the_settings_without_mutating_them():
145
  settings = dict(DEFAULT_SETTINGS, mode="caption", caption="<i>{original}</i>", file_type="document", remove_words=["x"],
146
  target_channel=-100123, metadata_on=False, keep_audio=["french"])
147
+ settings["meta"] = dict(DEFAULT_SETTINGS["meta"], title="DEFAULT TITLE")
148
  fmt = fmt_with(template="One Piece - E{episode}", meta=dict(DEFAULT_FORMAT["meta"], author="@Me"), keep_subtitle=["english"])
149
  out = apply_format(settings, fmt)
150
  assert out["template"] == "One Piece - E{episode}"
151
+ assert out["meta"]["author"] == "@Me" and out["meta"]["title"] == "" # the format's own metadata, not the default metadata
152
  assert out["keep_audio"] == [] and out["keep_subtitle"] == ["english"]
153
  assert out["metadata_on"] is True
154
  assert out["caption"] == "<b>{filename}</b>" # the format's own, not the user's <i>{original}</i>
155
  # everything else is still the user's
156
  assert (out["mode"], out["file_type"], out["remove_words"], out["target_channel"]) == ("caption", "document", ["x"], -100123)
157
+ assert "template" not in settings and settings["meta"]["title"] == "DEFAULT TITLE"
158
  assert settings["caption"] == "<i>{original}</i>"
159
 
160
 
 
171
 
172
  def test_a_format_without_metadata_leaves_the_file_alone():
173
  assert not has_metadata(fmt_with())
174
+ settings = dict(DEFAULT_SETTINGS, metadata_on=True) # default metadata on, but the format sets nothing
175
  assert apply_format(settings, fmt_with())["metadata_on"] is False
176
  assert has_metadata(fmt_with(keep_audio=["1"])) and has_metadata(fmt_with(meta=dict(DEFAULT_FORMAT["meta"], video="v")))
177
 
 
181
  # ---------------------------------------------------------------------------
182
 
183
 
184
+ async def test_settings_ops_edit_a_format_with_the_same_rules_as_the_users_own_settings(db):
185
  fmt = await db.create_format(U, "One Piece")
186
  store = FormatSettings(db, fmt["id"])
187
 
 
228
 
229
  async def test_a_format_starts_with_the_file_name_in_bold_and_keeps_a_caption_of_its_own(db):
230
  fmt = await db.create_format(U, "One Piece")
231
+ assert fmt["caption"] == "<b>{filename}</b>" == DEFAULT_CAPTION
232
  out = await db.update_format(U, fmt["id"], caption="<i>{filename}</i> · {size}")
233
  assert out["caption"] == "<i>{filename}</i> · {size}"
234
  await db.update_format(U, fmt["id"], template="X {title}", meta={"title": "T"}) # other edits leave it alone
 
236
  assert (await other.get_format(U, fmt["id"]))["caption"] == "<i>{filename}</i> · {size}"
237
  await other.close()
238
  assert (await db.list_formats(U))[0]["caption"] == "<i>{filename}</i> · {size}"
239
+ assert await db.get_settings(U) == DEFAULT_SETTINGS # formats never touch the user's own settings
240
 
241
 
242
  async def test_formats_saved_before_captions_existed_get_the_default_one(db):
 
263
  assert (await db.get_format(U, fmt["id"]))["caption"] == "<code>{filename}</code>"
264
 
265
 
266
+ async def test_settings_ops_edit_a_formats_caption_with_the_checks_of_the_auto_format_caption(db):
267
  fmt = await db.create_format(U, "One Piece")
268
  store = FormatSettings(db, fmt["id"])
269
  ok, msg = await apply_setting(store, U, "caption", "<b>{filename}</b> · {size} · {duration}")
270
  assert ok and "Caption template saved" in msg and "Unknown" not in msg
271
  assert (await db.get_format(U, fmt["id"]))["caption"] == "<b>{filename}</b> · {size} · {duration}"
272
  ok, msg = await apply_setting(store, U, "caption", "<i>{nonsense}</i>")
273
+ assert ok and "Unknown variables" in msg and "{nonsense}" in msg # kept as plain text, as for /caption
274
  ok, msg = await apply_setting(store, U, "caption", "x" * 801)
275
  assert not ok and "too long" in msg and (await db.get_format(U, fmt["id"]))["caption"] == "<i>{nonsense}</i>"
276
  ok, msg = await apply_setting(store, U, "caption", "")
 
281
  assert ok and (await db.get_format(U, fmt["id"]))["caption"] == "<b>{filename}</b>", word # the file name in bold again
282
  assert (await store.get_settings(U))["caption"] == "<b>{filename}</b>"
283
  assert await db.get_settings(U) == DEFAULT_SETTINGS
284
+
285
+
286
+ # ---------------------------------------------------------------------------
287
+ # two kinds: 12 auto formats and 12 manual ones
288
+ # ---------------------------------------------------------------------------
289
+
290
+
291
+ async def test_a_format_is_manual_unless_it_is_made_as_an_auto_one(db):
292
+ assert (await db.create_format(U, "A"))["kind"] == "manual"
293
+ assert (await db.create_format(U, "B", kind="manual"))["kind"] == "manual"
294
+ auto = await db.create_format(U, "C", kind="auto")
295
+ assert auto["kind"] == "auto" and (await db.get_format(U, auto["id"]))["kind"] == "auto"
296
+ with pytest.raises(KeyError):
297
+ await db.create_format(U, "D", kind="both")
298
+ assert other_kind("auto") == "manual" and other_kind("manual") == "auto"
299
+
300
+
301
+ async def test_each_kind_lists_and_counts_its_own_formats(db):
302
+ a1, m1, a2 = await db.create_format(U, "A1", kind="auto"), await db.create_format(U, "M1"), await db.create_format(U, "A2", kind="auto")
303
+ await db.create_format(V, "Theirs", kind="auto")
304
+ assert [f["id"] for f in await db.list_formats(U)] == [a1["id"], m1["id"], a2["id"]] # no kind: all of them, oldest first
305
+ assert [f["id"] for f in await db.list_formats(U, "auto")] == [a1["id"], a2["id"]]
306
+ assert [f["id"] for f in await db.list_formats(U, "manual")] == [m1["id"]]
307
+ assert (await db.count_formats(U), await db.count_formats(U, "auto"), await db.count_formats(U, "manual")) == (3, 2, 1)
308
+ assert await db.count_formats(V, "manual") == 0 and await db.count_formats(V, "auto") == 1
309
+
310
+
311
+ async def test_a_user_can_save_twelve_formats_of_each_kind_and_no_more(db):
312
+ for kind in ("auto", "manual"):
313
+ for i in range(MAX_FORMATS):
314
+ await db.create_format(U, f"{kind} {i}", kind=kind)
315
+ with pytest.raises(FormatError) as exc:
316
+ await db.create_format(U, "One too many", kind=kind)
317
+ assert exc.value.code == "limit"
318
+ assert await db.count_formats(U) == 2 * MAX_FORMATS
319
+ first = (await db.list_formats(U, "auto"))[0]
320
+ assert await db.delete_format(U, first["id"])
321
+ await db.create_format(U, "One too many", kind="auto") # a slot of that kind is free again
322
+ with pytest.raises(FormatError):
323
+ await db.create_format(U, "Still too many", kind="manual") # the manual ones are still full
324
+
325
+
326
+ async def test_a_name_only_has_to_be_free_within_its_kind(db):
327
+ auto = await db.create_format(U, "One Piece", kind="auto")
328
+ manual = await db.create_format(U, "ONE PIECE") # the same name, the other kind
329
+ with pytest.raises(FormatError) as exc:
330
+ await db.create_format(U, "one piece", kind="auto")
331
+ assert exc.value.code == "taken"
332
+ other = await db.create_format(U, "Dragon Ball", kind="auto")
333
+ with pytest.raises(FormatError):
334
+ await db.update_format(U, other["id"], name="one piece") # taken by an auto format
335
+ await db.update_format(U, other["id"], name="Dragon Ball Z")
336
+ await db.update_format(U, manual["id"], name="Dragon Ball Z") # but free among the manual ones
337
+ assert (await db.get_format(U, auto["id"]))["name"] == "One Piece"
338
+
339
+
340
+ async def test_formats_stored_before_there_were_kinds_are_manual(db):
341
+ import json
342
+
343
+ from bot.db import Format
344
+
345
+ async with db._sessions() as session:
346
+ session.add(Format(user_id=U, name="Old", data=json.dumps({"template": "Old - {title}"})))
347
+ session.add(Format(user_id=U, name="Odd", data=json.dumps({"kind": "sideways"})))
348
+ session.add(Format(user_id=U, name="Auto", data=json.dumps({"kind": "auto"})))
349
+ await session.commit()
350
+ assert [(f["name"], f["kind"]) for f in await db.list_formats(U)] == [("Old", "manual"), ("Odd", "manual"), ("Auto", "auto")]
351
+ old = (await db.list_formats(U, "manual"))[0]
352
+ out = await db.update_format(U, old["id"], template="New - {title}") # editing a format keeps its kind
353
+ assert out["kind"] == "manual" and (await db.get_format(U, old["id"]))["template"] == "New - {title}"
354
+ auto = (await db.list_formats(U, "auto"))[0]
355
+ assert (await db.update_format(U, auto["id"], meta={"title": "T"}))["kind"] == "auto"
356
+
357
+
358
+ async def test_copy_format_to_the_other_kind(db):
359
+ src = await db.create_format(U, "One Piece", "E{episode}", kind="auto")
360
+ await db.update_format(U, src["id"], meta={"title": "OP"}, keep_audio=["japanese"], caption="<i>{filename}</i>")
361
+ await db.set_format_thumb(U, src["id"], b"\xff\xd8thumb")
362
+
363
+ same = await db.copy_format(V, U, src["id"]) # sharing: the copy keeps the kind
364
+ assert same["kind"] == "auto"
365
+ mine = await db.copy_format(U, U, src["id"], kind="manual")
366
+ assert mine["kind"] == "manual" and mine["name"] == "One Piece" and mine["id"] != src["id"] # free in the manual names
367
+ assert mine["template"] == "E{episode}" and mine["meta"]["title"] == "OP" and mine["keep_audio"] == ["japanese"]
368
+ assert mine["caption"] == "<i>{filename}</i>" and mine["has_thumb"] and await db.get_format_thumb(U, mine["id"]) == b"\xff\xd8thumb"
369
+ assert [(await db.copy_format(U, U, src["id"], kind="manual"))["name"] for _ in range(2)] == ["One Piece (2)", "One Piece (3)"]
370
+ back = await db.copy_format(U, U, mine["id"], kind="auto") # and the other way round: "One Piece" is taken among the auto ones
371
+ assert back["kind"] == "auto" and back["name"] == "One Piece (2)"
372
+ await db.update_format(U, mine["id"], template="Changed") # the copy is independent
373
+ assert (await db.get_format(U, src["id"]))["template"] == "E{episode}"
374
+ with pytest.raises(KeyError):
375
+ await db.copy_format(U, U, src["id"], kind="both")
376
+
377
+
378
+ async def test_copying_to_the_other_kind_stops_at_the_limit_of_that_kind(db):
379
+ src = await db.create_format(U, "One Piece", kind="auto")
380
+ for i in range(MAX_FORMATS):
381
+ await db.create_format(U, f"M{i}")
382
+ with pytest.raises(FormatError) as exc:
383
+ await db.copy_format(U, U, src["id"], kind="manual")
384
+ assert exc.value.code == "limit" and await db.count_formats(U, "manual") == MAX_FORMATS
385
+ assert (await db.copy_format(U, U, src["id"]))["kind"] == "auto" # the auto ones have room
386
+
387
+
388
+ # ---------------------------------------------------------------------------
389
+ # the auto format in use
390
+ # ---------------------------------------------------------------------------
391
+
392
+
393
+ async def test_without_auto_formats_there_is_none_in_use(db):
394
+ assert await db.get_auto_format(U) is None
395
+ await db.create_format(U, "Manual only") # a manual format never renames files by itself
396
+ assert await db.get_auto_format(U) is None and (await db.get_settings(U))["auto_format"] == 0
397
+
398
+
399
+ async def test_the_first_auto_format_is_in_use_and_it_is_remembered(db):
400
+ first = await db.create_format(U, "First", kind="auto")
401
+ await db.create_format(U, "Second", kind="auto")
402
+ assert (await db.get_auto_format(U))["id"] == first["id"]
403
+ assert (await db.get_settings(U))["auto_format"] == first["id"]
404
+ assert (await db.get_auto_format(V)) is None # per user
405
+
406
+
407
+ async def test_selecting_an_auto_format(db):
408
+ a, b = await db.create_format(U, "A", kind="auto"), await db.create_format(U, "B", kind="auto")
409
+ manual = await db.create_format(U, "M")
410
+ theirs = await db.create_format(V, "Theirs", kind="auto")
411
+ assert (await db.select_auto_format(U, b["id"]))["id"] == b["id"]
412
+ assert (await db.get_auto_format(U))["id"] == b["id"] and (await db.get_settings(U))["auto_format"] == b["id"]
413
+ for fid in (manual["id"], theirs["id"], 99999): # not an auto format of theirs
414
+ assert await db.select_auto_format(U, fid) is None
415
+ assert (await db.get_auto_format(U))["id"] == b["id"] # unchanged by the refusals
416
+ await db.select_auto_format(U, a["id"])
417
+ assert (await db.get_auto_format(U))["id"] == a["id"]
418
+
419
+
420
+ async def test_deleting_the_auto_format_in_use_hands_over_to_another(db):
421
+ a = await db.create_format(U, "A", kind="auto")
422
+ b = await db.create_format(U, "B", kind="auto")
423
+ c = await db.create_format(U, "C", kind="auto")
424
+ await db.select_auto_format(U, b["id"])
425
+ await db.delete_format(U, b["id"])
426
+ assert (await db.get_auto_format(U))["id"] == a["id"] # the oldest left takes over
427
+ assert (await db.get_settings(U))["auto_format"] == a["id"]
428
+ await db.delete_format(U, c["id"]) # deleting one that is not in use changes nothing
429
+ assert (await db.get_auto_format(U))["id"] == a["id"]
430
+ await db.delete_format(U, a["id"])
431
+ assert await db.get_auto_format(U) is None and (await db.get_settings(U))["auto_format"] == 0
432
+
433
+
434
+ async def test_ensure_auto_format_makes_a_default_one_only_when_there_is_none(db):
435
+ await db.create_format(U, "Default") # a manual one of that name does not count
436
+ made = await db.ensure_auto_format(U)
437
+ assert made["name"] == "Default" and made["kind"] == "auto" and made["template"] == DEFAULT_TEMPLATE
438
+ assert (await db.get_auto_format(U))["id"] == made["id"]
439
+ assert (await db.ensure_auto_format(U))["id"] == made["id"] and await db.count_formats(U, "auto") == 1
440
+ other = await db.create_format(U, "Other", kind="auto")
441
+ await db.select_auto_format(U, other["id"])
442
+ assert (await db.ensure_auto_format(U))["id"] == other["id"] # the one in use, never a second default
443
+
444
+
445
+ async def test_ensure_auto_format_asked_by_several_tasks_at_once_makes_one_default(db, monkeypatch):
446
+ original = AsyncSession.execute
447
+
448
+ async def slower(self, *args, **kwargs): # lets the tasks interleave, as photos sent together do
449
+ result = await original(self, *args, **kwargs)
450
+ await asyncio.sleep(0.01)
451
+ return result
452
+
453
+ monkeypatch.setattr(AsyncSession, "execute", slower)
454
+ made = await asyncio.gather(*(db.ensure_auto_format(U) for _ in range(4)))
455
+ assert len({f["id"] for f in made}) == 1 and await db.count_formats(U, "auto") == 1
456
+ assert (await db.get_settings(U))["auto_format"] == made[0]["id"]
457
+ other = await asyncio.gather(db.ensure_auto_format(V), db.ensure_auto_format(V)) # each user has their own
458
+ assert other[0]["id"] == other[1]["id"] != made[0]["id"] and await db.count_formats(V, "auto") == 1
459
+
460
+
461
+ async def test_auto_format_settings_edit_the_one_in_use_and_make_it_when_needed(db):
462
+ store = AutoFormatSettings(db)
463
+ shown = await store.get_settings(U) # looking is not changing: nothing is made
464
+ assert shown["template"] == DEFAULT_TEMPLATE and shown["caption"] == DEFAULT_CAPTION and await db.count_formats(U) == 0
465
+
466
+ ok, msg = await apply_setting(store, U, "template", "[S{season}E{episode}] {title}")
467
+ assert ok and "Template saved" in msg
468
+ (auto,) = await db.list_formats(U, "auto")
469
+ assert auto["name"] == "Default" and auto["template"] == "[S{season}E{episode}] {title}"
470
+ ok, _ = await apply_setting(store, U, "caption", "<i>{filename}</i>")
471
+ assert ok and (await db.get_auto_format(U))["caption"] == "<i>{filename}</i>" and await db.count_formats(U) == 1
472
+ assert (await store.get_settings(U))["template"] == "[S{season}E{episode}] {title}"
473
+ ok, _ = await apply_setting(store, U, "caption", "reset")
474
+ assert ok and (await db.get_auto_format(U))["caption"] == DEFAULT_CAPTION
475
+ assert await db.get_settings(U) == {**DEFAULT_SETTINGS, "auto_format": auto["id"]} # the user's own settings only remember which one
476
+
477
+ second = await db.create_format(U, "Second", kind="auto") # with several, the one in use is the one that is edited
478
+ await db.select_auto_format(U, second["id"])
479
+ await apply_setting(store, U, "template", "{title}")
480
+ assert (await db.get_format(U, second["id"]))["template"] == "{title}" and (await db.get_format(U, auto["id"]))["template"] != "{title}"
481
+
482
+
483
+ # ---------------------------------------------------------------------------
484
+ # a format over the user's settings, and the default metadata
485
+ # ---------------------------------------------------------------------------
486
+
487
+
488
+ def with_default_metadata(**over):
489
+ settings = dict(DEFAULT_SETTINGS, **over)
490
+ settings["meta"] = dict(DEFAULT_SETTINGS["meta"], title="DEFAULT TITLE", audio="{lang} Audio")
491
+ return settings
492
+
493
+
494
+ def test_an_auto_format_without_metadata_leaves_the_default_metadata_in_charge():
495
+ mine = with_default_metadata(metadata_on=True, keep_audio=["english"])
496
+ out = apply_format(mine, fmt_with(kind="auto"))
497
+ assert out["template"] == "T {title}" and out["metadata_on"] is True # its switch decides, as it always did
498
+ assert out["meta"]["title"] == "DEFAULT TITLE" and out["keep_audio"] == ["english"]
499
+ off = apply_format(with_default_metadata(metadata_on=False), fmt_with(kind="auto"))
500
+ assert off["metadata_on"] is False and off["meta"]["title"] == "DEFAULT TITLE" # off: nothing is written
501
+
502
+
503
+ def test_an_auto_format_with_metadata_of_its_own_ignores_the_default_metadata():
504
+ fmt = fmt_with(kind="auto", meta=dict(DEFAULT_FORMAT["meta"], author="@Me"))
505
+ out = apply_format(with_default_metadata(metadata_on=False), fmt)
506
+ assert out["metadata_on"] is True and out["meta"]["author"] == "@Me" and out["meta"]["title"] == "" and out["keep_audio"] == []
507
+ assert apply_format(with_default_metadata(metadata_on=True), fmt)["meta"]["title"] == ""
508
+
509
+
510
+ def test_a_manual_format_never_uses_the_default_metadata():
511
+ out = apply_format(with_default_metadata(metadata_on=True, keep_audio=["english"]), fmt_with(kind="manual"))
512
+ assert out["metadata_on"] is False and out["meta"]["title"] == "" and out["keep_audio"] == []
513
+
514
+
515
+ def test_no_auto_format_means_the_built_in_defaults_and_the_default_metadata():
516
+ built_in = default_auto_format()
517
+ assert built_in["kind"] == "auto" and built_in["template"] == DEFAULT_TEMPLATE and built_in["caption"] == DEFAULT_CAPTION
518
+ assert not built_in["has_thumb"] and not has_metadata(built_in)
519
+ mine = with_default_metadata(metadata_on=True, template="ignored")
520
+ out = apply_format(mine, None)
521
+ assert out["template"] == DEFAULT_TEMPLATE and out["caption"] == DEFAULT_CAPTION
522
+ assert out["metadata_on"] is True and out["meta"]["title"] == "DEFAULT TITLE"
523
+ assert built_in is not default_auto_format() and default_auto_format()["meta"] == DEFAULT_FORMAT["meta"] # a fresh copy each time
524
+
525
+
526
+ def test_default_metadata_is_what_the_user_set_in_the_metadata_menu():
527
+ assert not has_default_metadata(DEFAULT_SETTINGS)
528
+ assert not has_default_metadata(dict(DEFAULT_SETTINGS, metadata_on=True)) # the switch alone is not metadata
529
+ assert has_default_metadata(with_default_metadata())
530
+ assert has_default_metadata(dict(DEFAULT_SETTINGS, keep_audio=["1"])) and has_default_metadata(dict(DEFAULT_SETTINGS, keep_subtitle=["english"]))
531
+ changes = default_metadata_changes(with_default_metadata(keep_audio=["2", "1"], keep_subtitle=["english"]))
532
+ assert set(changes) == {"meta", "keep_audio", "keep_subtitle"}
533
+ assert changes["meta"]["title"] == "DEFAULT TITLE" and changes["keep_audio"] == ["2", "1"] and changes["keep_subtitle"] == ["english"]
534
+ source = with_default_metadata(keep_audio=["2"])
535
+ changes = default_metadata_changes(source)
536
+ changes["keep_audio"].append("3")
537
+ changes["meta"]["title"] = "other"
538
+ assert source["keep_audio"] == ["2"] and source["meta"]["title"] == "DEFAULT TITLE" # copies, not the user's own lists
539
+
540
+
541
+ async def test_default_metadata_can_be_copied_into_a_format_of_either_kind(db):
542
+ settings = with_default_metadata(keep_audio=["japanese"], keep_subtitle=["english"])
543
+ for kind in ("auto", "manual"):
544
+ fmt = await db.create_format(U, f"{kind} one", kind=kind)
545
+ await db.update_format(U, fmt["id"], meta={"video": "OLD"}, keep_audio=["french", "2"])
546
+ got = await db.update_format(U, fmt["id"], **default_metadata_changes(settings))
547
+ assert got["meta"]["title"] == "DEFAULT TITLE" and got["meta"]["audio"] == "{lang} Audio"
548
+ assert got["keep_audio"] == ["japanese"] and got["keep_subtitle"] == ["english"] and got["kind"] == kind
549
+ assert got["meta"]["video"] == "" and got["meta"]["author"] == "" # all six fields are sent: it replaces what the format had
tests/test_formats_app.py CHANGED
@@ -1,89 +1,149 @@
1
- """/formats (the manager) and /rename (pick a format, send one file)."""
2
  import asyncio
3
  import time
4
 
 
 
5
  from bot import app as appmod
6
  from bot import panels
7
  from bot.db import MAX_FORMATS
 
 
8
 
9
- from .appkit import ADMIN_CFG, EPISODE, OWNER, click, data_of, jpeg, say, send_doc
10
- from .tgfakes import FakeEvent, FakeMessage, dispatch, document_message
11
 
12
  ONE_PIECE = "1147 - One Piece [Dual] 720p @Anime_X_Universe.mkv"
 
13
 
14
 
15
- async def make_format(ctx, name="One Piece", uid=OWNER, **changes):
16
- fmt = await ctx.db.create_format(uid, name, template="One Piece - E{episode}")
17
  if changes:
18
  fmt = await ctx.db.update_format(uid, fmt["id"], **changes)
19
  return fmt
20
 
21
 
 
 
 
 
22
  # ---------------------------------------------------------------------------
23
- # the manager
24
  # ---------------------------------------------------------------------------
25
 
26
 
27
- async def test_formats_command_lists_the_formats_and_offers_a_new_one(ctx):
28
- ev = await say(ctx, OWNER, "/formats")
29
- assert "FORMATS" in ev.texts[0] and "(0/12)" in ev.texts[0] and "no formats yet" in ev.texts[0]
30
- assert "fm:new" in data_of(ev.out[0][1])
 
 
 
 
 
 
 
 
31
 
 
 
 
 
 
 
 
 
 
 
32
  a, b = await make_format(ctx, "One Piece"), await make_format(ctx, "Dragon Ball")
33
- ev = await say(ctx, OWNER, "/formats")
34
- data = data_of(ev.out[0][1])
35
- assert "(2/12)" in ev.texts[0] and f"fm:v:{a['id']}" in data and f"fm:v:{b['id']}" in data and "fm:new" in data
36
- labels = [label for row in ev.out[0][1] for label in [b.text for b in row]]
37
- assert "One Piece" in labels and "Dragon Ball" in labels
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
 
39
 
40
  async def test_formats_are_reachable_from_the_home_menu(ctx):
41
  ev = await say(ctx, OWNER, "/start")
42
  assert "m:formats" in data_of(ev.out[0][1])
43
  cb = await click(ctx, OWNER, "m:formats")
44
- assert "FORMATS" in cb.edits[-1][0] and cb.answers
45
 
46
 
47
- async def test_creating_a_format_asks_for_a_name_and_opens_it(ctx):
48
- await say(ctx, OWNER, "/template Mine - {title}")
49
- cb = await click(ctx, OWNER, "fm:new", msg_id=500)
50
  p = ctx.app.pending[OWNER]
51
- assert p.kind == "fmt:new" and p.fid == 0 and p.msg_id == 500
52
- assert cb.edits[-1][0] == panels.FORMAT_PROMPTS["new"] and data_of(cb.edits[-1][1]) == ["m:formats"]
53
 
54
  ev = await say(ctx, OWNER, " One Piece ", mid=60)
55
  assert ev.deleted and OWNER not in ctx.app.pending
56
  (fmt,) = await ctx.db.list_formats(OWNER)
57
- assert fmt["name"] == "One Piece" and fmt["template"] == "Mine - {title}" # starts from the user's current template
58
  (chat, text, kw), = ctx.client.edited
59
- assert kw["message"] == 500 and "created" in text and "🗂 One Piece" in text and f"fm:in:{fmt['id']}:template" in data_of(kw["buttons"])
 
60
 
61
 
62
  async def test_a_bad_or_taken_name_keeps_the_prompt_open(ctx):
63
  await make_format(ctx, "One Piece")
64
- await click(ctx, OWNER, "fm:new")
65
  for text, expected in (("x" * 31, "too long"), ("one piece", "already have a format")):
66
  ev = await say(ctx, OWNER, text)
67
  assert expected in ev.texts[0] and "Try again" in ev.texts[0]
68
  assert OWNER in ctx.app.pending and await ctx.db.count_formats(OWNER) == 1
69
- cb = await click(ctx, OWNER, "m:formats") # Cancel
70
- assert OWNER not in ctx.app.pending and "FORMATS" in cb.edits[-1][0]
 
 
 
 
 
 
 
 
 
 
 
71
 
72
 
73
- async def test_the_twelve_format_limit(ctx):
 
 
74
  for i in range(MAX_FORMATS):
75
- await make_format(ctx, f"F{i}")
76
- ev = await say(ctx, OWNER, "/formats")
77
- assert "all 12 formats" in ev.texts[0] and "fm:new" not in data_of(ev.out[0][1])
78
- cb = await click(ctx, OWNER, "fm:new")
79
- assert "already have 12" in cb.alerts[0] and OWNER not in ctx.app.pending
 
 
 
 
 
80
 
81
  # the limit is also enforced when it is reached while the name prompt is open
82
- await ctx.db.delete_format(OWNER, (await ctx.db.list_formats(OWNER))[0]["id"])
83
- await click(ctx, OWNER, "fm:new")
84
- await make_format(ctx, "Sneaked in")
85
  ev = await say(ctx, OWNER, "Late one")
86
- assert "already have 12" in ev.texts[0] and OWNER not in ctx.app.pending
87
 
88
 
89
  async def test_the_format_screen_shows_every_field_and_has_a_button_for_each(ctx):
@@ -92,15 +152,17 @@ async def test_the_format_screen_shows_every_field_and_has_a_button_for_each(ctx
92
  text, buttons = cb.edits[-1]
93
  for label in ("Template", "Caption", "Thumbnail", "Title", "Author", "Artist", "Audio", "Subtitle", "Video", "Keep Audio", "Keep Subtitle"):
94
  assert label in text, label
95
- assert "One Piece - E{episode}" in text and "Japanese, 2" in text and "your default thumbnail" in text
96
  assert "<b>Title</b> ▸ <code>One Piece</code>" in text and "<b>Video</b> ▸ <code>HD</code>" in text # values land on the right line
97
  assert "<b>Author</b> ▸ —" in text
 
98
  data = data_of(buttons)
99
  i = fmt["id"]
100
  for field in ("name", "template", "caption", "title", "author", "artist", "audio", "subtitle", "video", "keep_audio", "keep_subtitle"):
101
  assert f"fm:in:{i}:{field}" in data, field
102
- assert {f"fm:th:{i}", f"fm:del:{i}", "m:formats", "x:close"} <= set(data)
103
  assert f"fm:thd:{i}" not in data # nothing to delete yet
 
104
  assert f"fm:cr:{i}" not in data and "<b>Caption</b> ▸ <b>{filename}</b> · default" in text # nor to reset
105
 
106
 
@@ -129,9 +191,22 @@ async def test_editing_each_field_goes_through_the_same_checks_as_the_settings(c
129
  assert check(await ctx.db.get_format(OWNER, i)), field
130
  chat, text, kw = ctx.client.edited[-1]
131
  assert kw["message"] == 700 + n and "🗂 One Piece" in text, field # the format screen is back in the same message
132
- # none of this touched the user's own settings
133
  mine = await ctx.db.get_settings(OWNER)
134
- assert mine["meta"]["title"] == "" and mine["keep_audio"] == [] and mine["caption"] == "<b>{filename}</b>"
 
 
 
 
 
 
 
 
 
 
 
 
 
135
 
136
 
137
  async def test_invalid_values_keep_the_prompt_open_and_cancel_returns_to_the_format(ctx):
@@ -155,14 +230,14 @@ async def test_clearing_a_field_with_off(ctx):
155
 
156
 
157
  async def test_a_formats_caption_is_its_own_and_can_be_reset(ctx):
158
- await ctx.db.update_settings(OWNER, caption="<i>MY OWN {filename}</i>")
159
  fmt = await make_format(ctx)
160
  i = fmt["id"]
161
  cb = await click(ctx, OWNER, f"fm:in:{i}:caption", msg_id=710)
162
  assert cb.edits[-1][0] == panels.FORMAT_PROMPTS["caption"] and data_of(cb.edits[-1][1]) == [f"fm:cx:{i}"]
163
  assert "reset" in cb.edits[-1][0]
164
 
165
- ev = await say(ctx, OWNER, "x" * 801) # the same checks as the global caption: too long keeps the prompt open
166
  assert "too long" in ev.texts[0] and "Try again" in ev.texts[0] and OWNER in ctx.app.pending
167
  assert (await ctx.db.get_format(OWNER, i))["caption"] == "<b>{filename}</b>"
168
  ev = await say(ctx, OWNER, "<b>{filename}</b> · {size} · {nonsense}")
@@ -175,7 +250,7 @@ async def test_a_formats_caption_is_its_own_and_can_be_reset(ctx):
175
  cb = await click(ctx, OWNER, f"fm:cr:{i}")
176
  assert cb.toasts == ["Caption reset."] and (await ctx.db.get_format(OWNER, i))["caption"] == "<b>{filename}</b>"
177
  assert "· default" in cb.edits[-1][0] and f"fm:cr:{i}" not in data_of(cb.edits[-1][1])
178
- assert (await ctx.db.get_settings(OWNER))["caption"] == "<i>MY OWN {filename}</i>" # the user's own caption was never touched
179
 
180
  await ctx.db.update_format(OWNER, i, caption="<u>{filename}</u>") # sending reset in the prompt does the same
181
  await click(ctx, OWNER, f"fm:in:{i}:caption")
@@ -212,22 +287,25 @@ async def test_a_format_thumbnail_is_saved_shown_and_deleted(ctx):
212
 
213
  ev = FakeEvent(OWNER, message=FakeMessage("", mid=801, photo=True), photo_bytes=jpeg((900, 600)))
214
  await dispatch(ctx.client, ev)
215
- assert OWNER not in ctx.app.pending and ev.out == [] # no "thumbnail saved" for the global one
216
  stored = await ctx.db.get_format_thumb(OWNER, i)
217
- assert stored[:2] == b"\xff\xd8" and await ctx.db.get_thumb(OWNER) is None # the format's, not the user's default
218
  chat, text, kw = ctx.client.edited[-1]
219
  assert kw["message"] == 800 and "Thumbnail saved" in text and "✅ saved" in text and f"fm:thd:{i}" in data_of(kw["buttons"])
220
 
221
  cb = await click(ctx, OWNER, f"fm:thd:{i}")
222
  assert cb.toasts == ["Thumbnail deleted."] and await ctx.db.get_format_thumb(OWNER, i) is None
223
- assert "your default thumbnail" in cb.edits[-1][0]
224
 
225
 
226
- async def test_a_photo_without_a_pending_format_prompt_is_still_the_global_thumbnail(ctx):
227
- await make_format(ctx)
228
  ev = FakeEvent(OWNER, message=FakeMessage("", mid=900, photo=True), photo_bytes=jpeg())
229
  await dispatch(ctx.client, ev)
230
- assert "Thumbnail saved" in ev.texts[0] and (await ctx.db.get_thumb(OWNER)) is not None
 
 
 
231
 
232
 
233
  async def test_deleting_a_format_asks_first(ctx):
@@ -240,13 +318,16 @@ async def test_deleting_a_format_asks_first(ctx):
240
  assert "🗂 One Piece" in cb.edits[-1][0]
241
  cb = await click(ctx, OWNER, f"fm:ok:{i}")
242
  assert await ctx.db.get_format(OWNER, i) is None
243
- assert "FORMATS" in cb.edits[-1][0] and cb.toasts == ["Deleted One Piece."]
244
 
245
 
246
- async def test_buttons_of_a_deleted_format_say_so_and_show_the_list(ctx):
247
  fmt = await make_format(ctx)
248
  await ctx.db.delete_format(OWNER, fmt["id"])
249
- for data in (f"fm:v:{fmt['id']}", f"fm:in:{fmt['id']}:title", f"fm:th:{fmt['id']}", f"fm:del:{fmt['id']}", f"fm:ok:{fmt['id']}"):
 
 
 
250
  cb = await click(ctx, OWNER, data)
251
  assert cb.alerts == ["That format no longer exists."] and "FORMATS" in cb.edits[-1][0], data
252
  assert OWNER not in ctx.app.pending
@@ -262,50 +343,58 @@ async def test_typing_into_a_prompt_for_a_deleted_format_is_handled(ctx):
262
 
263
  async def test_formats_belong_to_one_user(ctx):
264
  mine = await make_format(ctx, "Mine")
265
- cb = await click(ctx, ADMIN_CFG, f"fm:v:{mine['id']}")
266
- assert cb.alerts == ["That format no longer exists."]
267
- cb = await click(ctx, ADMIN_CFG, f"fm:ok:{mine['id']}")
268
- assert await ctx.db.get_format(OWNER, mine["id"]) is not None
 
 
 
 
269
  ev = await say(ctx, ADMIN_CFG, "/formats")
270
- assert "no formats yet" in ev.texts[0]
271
  ev = await say(ctx, ADMIN_CFG, "/rename")
272
- assert "no saved formats" in ev.texts[0]
273
 
274
 
275
  async def test_any_other_tap_abandons_an_open_prompt(ctx):
276
  fmt = await make_format(ctx)
277
  await click(ctx, OWNER, f"fm:in:{fmt['id']}:title")
278
- await click(ctx, OWNER, "fm:new")
279
  assert ctx.app.pending[OWNER].kind == "fmt:new"
280
  await click(ctx, OWNER, f"fm:v:{fmt['id']}")
281
  assert OWNER not in ctx.app.pending
282
 
283
 
284
  # ---------------------------------------------------------------------------
285
- # /rename: pick a format, send one file
286
  # ---------------------------------------------------------------------------
287
 
288
 
289
- async def test_rename_without_formats_points_to_creating_one(ctx):
290
  ev = await say(ctx, OWNER, "/rename")
291
- assert "no saved formats" in ev.texts[0] and "fm:new" in data_of(ev.out[0][1])
292
  ev = await say(ctx, OWNER, "/rename New Name", reply=FakeMessage("x", mid=3)) # the old reply-with-a-name form is gone
293
- assert "no saved formats" in ev.texts[0] and not ctx.app.queue.jobs
 
 
 
294
 
295
 
296
- async def test_rename_offers_the_saved_formats_by_name(ctx):
297
  a, b = await make_format(ctx, "One Piece"), await make_format(ctx, "Dragon Ball")
 
298
  ev = await say(ctx, OWNER, "/rename")
299
- assert "Choose the format" in ev.texts[0]
300
  data = data_of(ev.out[0][1])
301
- assert f"rn:p:{a['id']}" in data and f"rn:p:{b['id']}" in data and "m:formats" in data
302
- labels = [btn.text for row in ev.out[0][1] for btn in row]
303
- assert "One Piece" in labels and "Dragon Ball" in labels
304
  assert not ctx.app.queue.jobs and ctx.app._mode(OWNER) is None # nothing is armed until a format is chosen
305
 
306
 
307
  async def test_choosing_a_format_then_sending_a_file_renames_it_with_that_format(ctx):
308
- await ctx.db.update_settings(OWNER, template="DEFAULT {title}")
309
  fmt = await make_format(ctx, meta={"title": "One Piece"})
310
  cb = await click(ctx, OWNER, f"rn:p:{fmt['id']}")
311
  assert cb.toasts == ["Format: One Piece"] and ctx.app._mode(OWNER, "rename") is not None
@@ -315,7 +404,7 @@ async def test_choosing_a_format_then_sending_a_file_renames_it_with_that_format
315
 
316
  ev = await send_doc(ctx, OWNER, ONE_PIECE, mid=50)
317
  (job,) = ctx.app.queue.jobs.values()
318
- assert job.new_name == "One Piece - E1147.mkv" # the format's template, not the default one
319
  assert job.fmt["id"] == fmt["id"] and job.fmt["meta"]["title"] == "One Piece" and job.order == 50
320
  assert "Queued" in ev.texts[0] and "🗂 Format: <b>One Piece</b>" in ev.texts[0] and "One Piece - E1147.mkv" in ev.texts[0]
321
  assert ctx.app._mode(OWNER) is None # it was for one file
@@ -331,6 +420,7 @@ async def test_other_settings_still_come_from_the_users_own_settings(ctx):
331
 
332
 
333
  async def test_the_next_file_needs_rename_again(ctx):
 
334
  fmt = await make_format(ctx)
335
  await click(ctx, OWNER, f"rn:p:{fmt['id']}")
336
  await send_doc(ctx, OWNER, ONE_PIECE, mid=50)
@@ -341,16 +431,17 @@ async def test_the_next_file_needs_rename_again(ctx):
341
  assert ev.out == []
342
  assert [j.file_name for j in ctx.app.queue.jobs.values()] == [ONE_PIECE]
343
 
344
- # once the short grace period is over, files are renamed with the normal settings as usual
345
  ctx.app._recent[OWNER][1] -= appmod.ONE_SHOT_GRACE + 1
346
  ev = await send_doc(ctx, OWNER, "1150 - One Piece [Dual] 720p.mkv", mid=53)
347
- assert "Queued" in ev.texts[0] and "Format" not in ev.texts[0]
348
  assert len(ctx.app.queue.jobs) == 2
349
 
350
  # and choosing a format again renames the next one with it
351
  await click(ctx, OWNER, f"rn:p:{fmt['id']}")
352
  ev = await send_doc(ctx, OWNER, "1151 - One Piece [Dual] 720p.mkv", mid=54)
353
  assert "🗂 Format: <b>One Piece</b>" in ev.texts[0] and len(ctx.app.queue.jobs) == 3
 
354
 
355
 
356
  async def test_a_forwarded_batch_gives_the_format_to_exactly_one_file(ctx):
@@ -370,7 +461,7 @@ async def test_the_chosen_format_is_forgotten_after_the_wait(ctx):
370
  ctx.app.modes[OWNER].expires = time.time() - 1
371
  ev = await send_doc(ctx, OWNER, ONE_PIECE, mid=50)
372
  (job,) = ctx.app.queue.jobs.values()
373
- assert job.fmt is None and "Format" not in ev.texts[0] # a normal auto-rename
374
  assert ctx.app._mode(OWNER) is None
375
 
376
 
@@ -396,7 +487,16 @@ async def test_choosing_a_format_that_is_gone_or_not_yours(ctx):
396
  assert cb.alerts == ["That format no longer exists."] and ctx.app._mode(ADMIN_CFG) is None
397
  await ctx.db.delete_format(OWNER, fmt["id"])
398
  cb = await click(ctx, OWNER, f"rn:p:{fmt['id']}")
399
- assert cb.alerts == ["That format no longer exists."] and "no saved formats" in cb.edits[-1][0]
 
 
 
 
 
 
 
 
 
400
 
401
 
402
  async def test_one_users_chosen_format_does_not_affect_another(ctx):
@@ -412,7 +512,7 @@ async def test_change_format_and_stop_buttons(ctx):
412
  a, b = await make_format(ctx, "One Piece"), await make_format(ctx, "Dragon Ball")
413
  await click(ctx, OWNER, f"rn:p:{a['id']}")
414
  cb = await click(ctx, OWNER, "rn:menu")
415
- assert "Choose the format" in cb.edits[-1][0] and ctx.app._mode(OWNER) is None
416
  await click(ctx, OWNER, f"rn:p:{b['id']}")
417
  assert ctx.app.modes[OWNER].data["fid"] == b["id"]
418
  cb = await click(ctx, OWNER, "rn:stop")
@@ -452,27 +552,33 @@ async def test_start_and_the_rename_picker_close_an_open_text_prompt(ctx):
452
  assert OWNER not in ctx.app.pending
453
 
454
 
455
- def test_the_format_screens_fit_in_a_caption_even_with_the_longest_values():
 
456
  from .test_panels import CAPTION_LIMIT, visible
457
 
458
  longest = ["klingon-language-tag-x"] * 12
459
  fmt = {
460
- "id": 12, "name": "N" * 30, "has_thumb": True, "template": "T" * 200, "caption": "C" * 800,
461
  "meta": {k: "M" * 200 for k in ("title", "author", "artist", "audio", "subtitle", "video")},
462
  "keep_audio": longest, "keep_subtitle": longest,
463
  }
464
  formats = [dict(fmt, id=i, name="F" * 30) for i in range(12)]
 
465
  for name, (text, rows) in {
466
- "view": panels.format_view(fmt), "view_inline": panels.format_view(fmt, share_inline=True),
467
- "menu": panels.formats_menu(formats, 12), "delete": panels.format_delete_confirm(fmt),
 
 
468
  "ready": panels.rename_ready(fmt, 10), "picker": panels.rename_picker(formats),
 
469
  "shared": panels.shared_format(fmt, "T" * 16), "shared_mine": panels.shared_format(fmt, "T" * 16, mine=True),
470
  "added": panels.shared_added(fmt), "card": (panels.share_card(fmt)[2], []),
471
- **{f"prompt_{k}": panels.format_prompt(k, 12) for k in panels.FORMAT_PROMPTS},
 
472
  }.items():
473
  assert len(visible(text)) <= CAPTION_LIMIT, (name, len(visible(text)))
474
  assert text.count("<b>") == text.count("</b>") and text.count("<code>") == text.count("</code>"), name
475
  for row in rows:
476
- assert 1 <= len(row) <= 3, name
477
  for label, data in row:
478
  assert label.strip() and len(label) <= 32 and 0 < len(data.encode()) <= 64, (name, label, data)
 
1
+ """/formats (the manager of auto and manual formats) and /rename (pick a manual format, send one file)."""
2
  import asyncio
3
  import time
4
 
5
+ import pytest
6
+
7
  from bot import app as appmod
8
  from bot import panels
9
  from bot.db import MAX_FORMATS
10
+ from bot.formats import KIND_LABELS
11
+ from bot.parser import DEFAULT_TEMPLATE
12
 
13
+ from .appkit import ADMIN_CFG, EPISODE, OWNER, autofmt, click, data_of, jpeg, say, send_doc
14
+ from .tgfakes import FakeEvent, FakeMessage, dispatch, document_message, flat_buttons
15
 
16
  ONE_PIECE = "1147 - One Piece [Dual] 720p @Anime_X_Universe.mkv"
17
+ KINDS = ["auto", "manual"]
18
 
19
 
20
+ async def make_format(ctx, name="One Piece", uid=OWNER, kind="manual", **changes):
21
+ fmt = await ctx.db.create_format(uid, name, template="One Piece - E{episode}", kind=kind)
22
  if changes:
23
  fmt = await ctx.db.update_format(uid, fmt["id"], **changes)
24
  return fmt
25
 
26
 
27
+ def labels_of(buttons):
28
+ return [label for label, _data in flat_buttons(buttons)]
29
+
30
+
31
  # ---------------------------------------------------------------------------
32
+ # the manager: /formats shows the two kinds, each kind has its own list
33
  # ---------------------------------------------------------------------------
34
 
35
 
36
+ async def test_formats_command_offers_the_two_kinds_with_their_counts(ctx):
37
+ for command in ("/formats", "/format", "/FORMAT@RenameBot"):
38
+ ev = await say(ctx, OWNER, command)
39
+ text, buttons, _kw = ev.out[0]
40
+ assert "FORMATS" in text and "Auto rename" in text and "Manual rename" in text, command
41
+ assert flat_buttons(buttons)[:2] == [("🤖 Auto rename (0/12)", "fm:l:auto"), ("✋ Manual rename (0/12)", "fm:l:manual")], command
42
+ await make_format(ctx, "Auto One", kind="auto")
43
+ await make_format(ctx, "One Piece")
44
+ await make_format(ctx, "Dragon Ball")
45
+ ev = await say(ctx, OWNER, "/format")
46
+ assert flat_buttons(ev.out[0][1])[:2] == [("🤖 Auto rename (1/12)", "fm:l:auto"), ("✋ Manual rename (2/12)", "fm:l:manual")]
47
+
48
 
49
+ async def test_each_kind_has_its_own_list_and_its_own_new_button(ctx):
50
+ cb = await click(ctx, OWNER, "fm:l:manual")
51
+ text, buttons = cb.edits[-1]
52
+ assert "MANUAL FORMATS" in text and "(0/12)" in text and "no manual formats yet" in text and cb.answers
53
+ assert "fm:new:manual" in data_of(buttons) and "fm:new:auto" not in data_of(buttons)
54
+ cb = await click(ctx, OWNER, "fm:l:auto")
55
+ text, buttons = cb.edits[-1]
56
+ assert "AUTO FORMATS" in text and "no auto formats yet" in text and "fm:new:auto" in data_of(buttons)
57
+
58
+ auto = await make_format(ctx, "Auto One", kind="auto")
59
  a, b = await make_format(ctx, "One Piece"), await make_format(ctx, "Dragon Ball")
60
+ text, buttons = (await click(ctx, OWNER, "fm:l:manual")).edits[-1]
61
+ data = data_of(buttons)
62
+ assert "(2/12)" in text and f"fm:v:{a['id']}" in data and f"fm:v:{b['id']}" in data and f"fm:v:{auto['id']}" not in data
63
+ assert "One Piece" in labels_of(buttons) and "Dragon Ball" in labels_of(buttons) and "Auto One" not in labels_of(buttons)
64
+ assert "fm:l:manual" not in data and "m:formats" in data # the way back is the chooser
65
+
66
+ text, buttons = (await click(ctx, OWNER, "fm:l:auto")).edits[-1]
67
+ data = data_of(buttons)
68
+ assert "(1/12)" in text and f"fm:v:{auto['id']}" in data and f"fm:v:{a['id']}" not in data
69
+ assert "Auto One ✅" in labels_of(buttons) # the one in use is ticked
70
+
71
+
72
+ @pytest.mark.parametrize("data", ["fm:l", "fm:l:nope", "fm:new", "fm:new:nope"])
73
+ async def test_a_button_without_a_known_kind_opens_the_choice(ctx, data):
74
+ """``fm:new`` is what the button of a message from before the two kinds sends: nothing is created, the user chooses a kind."""
75
+ cb = await click(ctx, OWNER, data)
76
+ text, buttons = cb.edits[-1]
77
+ assert "FORMATS" in text and data_of(buttons)[:2] == ["fm:l:auto", "fm:l:manual"] and cb.answers == [(None, False)]
78
+ assert OWNER not in ctx.app.pending and await ctx.db.count_formats(OWNER) == 0
79
 
80
 
81
  async def test_formats_are_reachable_from_the_home_menu(ctx):
82
  ev = await say(ctx, OWNER, "/start")
83
  assert "m:formats" in data_of(ev.out[0][1])
84
  cb = await click(ctx, OWNER, "m:formats")
85
+ assert "FORMATS" in cb.edits[-1][0] and cb.answers and "fm:l:auto" in data_of(cb.edits[-1][1])
86
 
87
 
88
+ @pytest.mark.parametrize("kind", KINDS)
89
+ async def test_creating_a_format_asks_for_a_name_and_opens_it(ctx, kind):
90
+ cb = await click(ctx, OWNER, f"fm:new:{kind}", msg_id=500)
91
  p = ctx.app.pending[OWNER]
92
+ assert p.kind == "fmt:new" and p.fid == 0 and p.msg_id == 500 and p.fmt_kind == kind
93
+ assert cb.edits[-1][0] == panels.FORMAT_PROMPTS["new"] and data_of(cb.edits[-1][1]) == [f"fm:l:{kind}"]
94
 
95
  ev = await say(ctx, OWNER, " One Piece ", mid=60)
96
  assert ev.deleted and OWNER not in ctx.app.pending
97
  (fmt,) = await ctx.db.list_formats(OWNER)
98
+ assert fmt["name"] == "One Piece" and fmt["kind"] == kind and fmt["template"] == DEFAULT_TEMPLATE # it starts from the defaults
99
  (chat, text, kw), = ctx.client.edited
100
+ assert kw["message"] == 500 and "created" in text and "🗂 One Piece" in text and KIND_LABELS[kind] in text
101
+ assert f"fm:in:{fmt['id']}:template" in data_of(kw["buttons"]) and f"fm:dm:{fmt['id']}" in data_of(kw["buttons"])
102
 
103
 
104
  async def test_a_bad_or_taken_name_keeps_the_prompt_open(ctx):
105
  await make_format(ctx, "One Piece")
106
+ await click(ctx, OWNER, "fm:new:manual")
107
  for text, expected in (("x" * 31, "too long"), ("one piece", "already have a format")):
108
  ev = await say(ctx, OWNER, text)
109
  assert expected in ev.texts[0] and "Try again" in ev.texts[0]
110
  assert OWNER in ctx.app.pending and await ctx.db.count_formats(OWNER) == 1
111
+ cb = await click(ctx, OWNER, "fm:l:manual") # Cancel
112
+ assert OWNER not in ctx.app.pending and "MANUAL FORMATS" in cb.edits[-1][0]
113
+
114
+
115
+ async def test_a_name_is_taken_only_within_its_own_kind(ctx):
116
+ await make_format(ctx, "One Piece", kind="auto")
117
+ await click(ctx, OWNER, "fm:new:manual")
118
+ await say(ctx, OWNER, "one piece")
119
+ assert OWNER not in ctx.app.pending
120
+ assert [f["kind"] for f in await ctx.db.list_formats(OWNER)] == ["auto", "manual"]
121
+ await click(ctx, OWNER, "fm:new:manual")
122
+ ev = await say(ctx, OWNER, "ONE PIECE")
123
+ assert "already have a format" in ev.texts[0] and OWNER in ctx.app.pending # but not twice in the same kind
124
 
125
 
126
+ @pytest.mark.parametrize("kind", KINDS)
127
+ async def test_the_twelve_format_limit_of_each_kind(ctx, kind):
128
+ other = "manual" if kind == "auto" else "auto"
129
  for i in range(MAX_FORMATS):
130
+ await make_format(ctx, f"F{i}", kind=kind)
131
+ cb = await click(ctx, OWNER, f"fm:l:{kind}")
132
+ assert f"all 12 {kind} formats" in cb.edits[-1][0] and f"fm:new:{kind}" not in data_of(cb.edits[-1][1])
133
+ cb = await click(ctx, OWNER, f"fm:new:{kind}")
134
+ assert f"already have 12 {kind} formats" in cb.alerts[0] and OWNER not in ctx.app.pending
135
+
136
+ # the other kind has its own twelve
137
+ cb = await click(ctx, OWNER, f"fm:new:{other}")
138
+ assert not cb.alerts and ctx.app.pending[OWNER].fmt_kind == other
139
+ await click(ctx, OWNER, f"fm:l:{other}")
140
 
141
  # the limit is also enforced when it is reached while the name prompt is open
142
+ await ctx.db.delete_format(OWNER, (await ctx.db.list_formats(OWNER, kind))[0]["id"])
143
+ await click(ctx, OWNER, f"fm:new:{kind}")
144
+ await make_format(ctx, "Sneaked in", kind=kind)
145
  ev = await say(ctx, OWNER, "Late one")
146
+ assert f"already have 12 {kind} formats" in ev.texts[0] and OWNER not in ctx.app.pending
147
 
148
 
149
  async def test_the_format_screen_shows_every_field_and_has_a_button_for_each(ctx):
 
152
  text, buttons = cb.edits[-1]
153
  for label in ("Template", "Caption", "Thumbnail", "Title", "Author", "Artist", "Audio", "Subtitle", "Video", "Keep Audio", "Keep Subtitle"):
154
  assert label in text, label
155
+ assert "One Piece - E{episode}" in text and "Japanese, 2" in text and panels.THUMB_NONE in text
156
  assert "<b>Title</b> ▸ <code>One Piece</code>" in text and "<b>Video</b> ▸ <code>HD</code>" in text # values land on the right line
157
  assert "<b>Author</b> ▸ —" in text
158
+ assert "✋ Manual" in text.splitlines()[0]
159
  data = data_of(buttons)
160
  i = fmt["id"]
161
  for field in ("name", "template", "caption", "title", "author", "artist", "audio", "subtitle", "video", "keep_audio", "keep_subtitle"):
162
  assert f"fm:in:{i}:{field}" in data, field
163
+ assert {f"fm:th:{i}", f"fm:del:{i}", f"fm:dm:{i}", f"fm:cp:{i}", "fm:l:manual", "m:home", "x:close"} <= set(data)
164
  assert f"fm:thd:{i}" not in data # nothing to delete yet
165
+ assert f"fm:use:{i}" not in data # only an auto format is used for auto rename
166
  assert f"fm:cr:{i}" not in data and "<b>Caption</b> ▸ <b>{filename}</b> · default" in text # nor to reset
167
 
168
 
 
191
  assert check(await ctx.db.get_format(OWNER, i)), field
192
  chat, text, kw = ctx.client.edited[-1]
193
  assert kw["message"] == 700 + n and "🗂 One Piece" in text, field # the format screen is back in the same message
194
+ # none of this touched the default metadata or the auto formats
195
  mine = await ctx.db.get_settings(OWNER)
196
+ assert mine["meta"]["title"] == "" and mine["keep_audio"] == [] and mine["metadata_on"] is False and await autofmt(ctx) is None
197
+
198
+
199
+ async def test_an_auto_format_is_edited_in_the_same_way(ctx):
200
+ fmt = await make_format(ctx, "Auto One", kind="auto")
201
+ i = fmt["id"]
202
+ await click(ctx, OWNER, f"fm:in:{i}:template", msg_id=701)
203
+ await say(ctx, OWNER, "{title} - {2episode}")
204
+ await click(ctx, OWNER, f"fm:in:{i}:title", msg_id=702)
205
+ await say(ctx, OWNER, "One Piece")
206
+ got = await ctx.db.get_format(OWNER, i)
207
+ assert got["template"] == "{title} - {2episode}" and got["meta"]["title"] == "One Piece" and got["kind"] == "auto"
208
+ chat, text, kw = ctx.client.edited[-1]
209
+ assert kw["message"] == 702 and "🗂 Auto One" in text and "🤖 Auto" in text
210
 
211
 
212
  async def test_invalid_values_keep_the_prompt_open_and_cancel_returns_to_the_format(ctx):
 
230
 
231
 
232
  async def test_a_formats_caption_is_its_own_and_can_be_reset(ctx):
233
+ auto = await make_format(ctx, "Auto One", kind="auto", caption="<i>MY OWN {filename}</i>")
234
  fmt = await make_format(ctx)
235
  i = fmt["id"]
236
  cb = await click(ctx, OWNER, f"fm:in:{i}:caption", msg_id=710)
237
  assert cb.edits[-1][0] == panels.FORMAT_PROMPTS["caption"] and data_of(cb.edits[-1][1]) == [f"fm:cx:{i}"]
238
  assert "reset" in cb.edits[-1][0]
239
 
240
+ ev = await say(ctx, OWNER, "x" * 801) # the same checks as /caption: too long keeps the prompt open
241
  assert "too long" in ev.texts[0] and "Try again" in ev.texts[0] and OWNER in ctx.app.pending
242
  assert (await ctx.db.get_format(OWNER, i))["caption"] == "<b>{filename}</b>"
243
  ev = await say(ctx, OWNER, "<b>{filename}</b> · {size} · {nonsense}")
 
250
  cb = await click(ctx, OWNER, f"fm:cr:{i}")
251
  assert cb.toasts == ["Caption reset."] and (await ctx.db.get_format(OWNER, i))["caption"] == "<b>{filename}</b>"
252
  assert "· default" in cb.edits[-1][0] and f"fm:cr:{i}" not in data_of(cb.edits[-1][1])
253
+ assert (await autofmt(ctx))["caption"] == "<i>MY OWN {filename}</i>" and (await autofmt(ctx))["id"] == auto["id"] # never touched
254
 
255
  await ctx.db.update_format(OWNER, i, caption="<u>{filename}</u>") # sending reset in the prompt does the same
256
  await click(ctx, OWNER, f"fm:in:{i}:caption")
 
287
 
288
  ev = FakeEvent(OWNER, message=FakeMessage("", mid=801, photo=True), photo_bytes=jpeg((900, 600)))
289
  await dispatch(ctx.client, ev)
290
+ assert OWNER not in ctx.app.pending and ev.out == [] # the format's panel is shown again, no "saved for your auto format"
291
  stored = await ctx.db.get_format_thumb(OWNER, i)
292
+ assert stored[:2] == b"\xff\xd8" and await autofmt(ctx) is None # the photo went to this format, no "Default" auto format was made
293
  chat, text, kw = ctx.client.edited[-1]
294
  assert kw["message"] == 800 and "Thumbnail saved" in text and "✅ saved" in text and f"fm:thd:{i}" in data_of(kw["buttons"])
295
 
296
  cb = await click(ctx, OWNER, f"fm:thd:{i}")
297
  assert cb.toasts == ["Thumbnail deleted."] and await ctx.db.get_format_thumb(OWNER, i) is None
298
+ assert panels.THUMB_NONE in cb.edits[-1][0]
299
 
300
 
301
+ async def test_a_photo_without_a_pending_format_prompt_is_the_thumbnail_of_the_auto_format(ctx):
302
+ manual = await make_format(ctx)
303
  ev = FakeEvent(OWNER, message=FakeMessage("", mid=900, photo=True), photo_bytes=jpeg())
304
  await dispatch(ctx.client, ev)
305
+ assert "Thumbnail saved" in ev.texts[0] and "auto format" in ev.texts[0]
306
+ auto = await autofmt(ctx)
307
+ assert auto["name"] == "Default" and await ctx.db.get_format_thumb(OWNER, auto["id"]) is not None
308
+ assert await ctx.db.get_format_thumb(OWNER, manual["id"]) is None
309
 
310
 
311
  async def test_deleting_a_format_asks_first(ctx):
 
318
  assert "🗂 One Piece" in cb.edits[-1][0]
319
  cb = await click(ctx, OWNER, f"fm:ok:{i}")
320
  assert await ctx.db.get_format(OWNER, i) is None
321
+ assert "MANUAL FORMATS" in cb.edits[-1][0] and cb.toasts == ["Deleted One Piece."] # back to the list it was in
322
 
323
 
324
+ async def test_buttons_of_a_deleted_format_say_so_and_show_the_chooser(ctx):
325
  fmt = await make_format(ctx)
326
  await ctx.db.delete_format(OWNER, fmt["id"])
327
+ for data in (
328
+ f"fm:v:{fmt['id']}", f"fm:in:{fmt['id']}:title", f"fm:th:{fmt['id']}", f"fm:del:{fmt['id']}", f"fm:ok:{fmt['id']}",
329
+ f"fm:dm:{fmt['id']}", f"fm:cp:{fmt['id']}", f"fm:use:{fmt['id']}",
330
+ ):
331
  cb = await click(ctx, OWNER, data)
332
  assert cb.alerts == ["That format no longer exists."] and "FORMATS" in cb.edits[-1][0], data
333
  assert OWNER not in ctx.app.pending
 
343
 
344
  async def test_formats_belong_to_one_user(ctx):
345
  mine = await make_format(ctx, "Mine")
346
+ auto = await make_format(ctx, "Auto", kind="auto")
347
+ for fid in (mine["id"], auto["id"]):
348
+ cb = await click(ctx, ADMIN_CFG, f"fm:v:{fid}")
349
+ assert cb.alerts == ["That format no longer exists."]
350
+ for action in ("ok", "dm", "cp", "use"):
351
+ await click(ctx, ADMIN_CFG, f"fm:{action}:{fid}")
352
+ assert await ctx.db.get_format(OWNER, fid) is not None
353
+ assert await ctx.db.count_formats(OWNER) == 2 and await ctx.db.count_formats(ADMIN_CFG) == 0
354
  ev = await say(ctx, ADMIN_CFG, "/formats")
355
+ assert flat_buttons(ev.out[0][1])[:2] == [("🤖 Auto rename (0/12)", "fm:l:auto"), ("✋ Manual rename (0/12)", "fm:l:manual")]
356
  ev = await say(ctx, ADMIN_CFG, "/rename")
357
+ assert "no manual formats yet" in ev.texts[0]
358
 
359
 
360
  async def test_any_other_tap_abandons_an_open_prompt(ctx):
361
  fmt = await make_format(ctx)
362
  await click(ctx, OWNER, f"fm:in:{fmt['id']}:title")
363
+ await click(ctx, OWNER, "fm:new:manual")
364
  assert ctx.app.pending[OWNER].kind == "fmt:new"
365
  await click(ctx, OWNER, f"fm:v:{fmt['id']}")
366
  assert OWNER not in ctx.app.pending
367
 
368
 
369
  # ---------------------------------------------------------------------------
370
+ # /rename: pick a manual format, send one file
371
  # ---------------------------------------------------------------------------
372
 
373
 
374
+ async def test_rename_without_manual_formats_points_to_creating_one(ctx):
375
  ev = await say(ctx, OWNER, "/rename")
376
+ assert "no manual formats yet" in ev.texts[0] and "fm:new:manual" in data_of(ev.out[0][1])
377
  ev = await say(ctx, OWNER, "/rename New Name", reply=FakeMessage("x", mid=3)) # the old reply-with-a-name form is gone
378
+ assert "no manual formats yet" in ev.texts[0] and not ctx.app.queue.jobs
379
+ await make_format(ctx, "Auto One", kind="auto") # an auto format is not for /rename
380
+ ev = await say(ctx, OWNER, "/rename")
381
+ assert "no manual formats yet" in ev.texts[0]
382
 
383
 
384
+ async def test_rename_offers_the_saved_manual_formats_by_name(ctx):
385
  a, b = await make_format(ctx, "One Piece"), await make_format(ctx, "Dragon Ball")
386
+ auto = await make_format(ctx, "Auto One", kind="auto")
387
  ev = await say(ctx, OWNER, "/rename")
388
+ assert "Choose the manual format" in ev.texts[0]
389
  data = data_of(ev.out[0][1])
390
+ assert f"rn:p:{a['id']}" in data and f"rn:p:{b['id']}" in data and "fm:l:manual" in data
391
+ assert f"rn:p:{auto['id']}" not in data and "Auto One" not in labels_of(ev.out[0][1]) # that one is chosen in the /start menu
392
+ assert "One Piece" in labels_of(ev.out[0][1]) and "Dragon Ball" in labels_of(ev.out[0][1])
393
  assert not ctx.app.queue.jobs and ctx.app._mode(OWNER) is None # nothing is armed until a format is chosen
394
 
395
 
396
  async def test_choosing_a_format_then_sending_a_file_renames_it_with_that_format(ctx):
397
+ await make_format(ctx, "Auto One", kind="auto", template="DEFAULT {title}")
398
  fmt = await make_format(ctx, meta={"title": "One Piece"})
399
  cb = await click(ctx, OWNER, f"rn:p:{fmt['id']}")
400
  assert cb.toasts == ["Format: One Piece"] and ctx.app._mode(OWNER, "rename") is not None
 
404
 
405
  ev = await send_doc(ctx, OWNER, ONE_PIECE, mid=50)
406
  (job,) = ctx.app.queue.jobs.values()
407
+ assert job.new_name == "One Piece - E1147.mkv" # the manual format's template, not the auto format's
408
  assert job.fmt["id"] == fmt["id"] and job.fmt["meta"]["title"] == "One Piece" and job.order == 50
409
  assert "Queued" in ev.texts[0] and "🗂 Format: <b>One Piece</b>" in ev.texts[0] and "One Piece - E1147.mkv" in ev.texts[0]
410
  assert ctx.app._mode(OWNER) is None # it was for one file
 
420
 
421
 
422
  async def test_the_next_file_needs_rename_again(ctx):
423
+ auto = await make_format(ctx, "Auto One", kind="auto", template="Auto - E{episode}")
424
  fmt = await make_format(ctx)
425
  await click(ctx, OWNER, f"rn:p:{fmt['id']}")
426
  await send_doc(ctx, OWNER, ONE_PIECE, mid=50)
 
431
  assert ev.out == []
432
  assert [j.file_name for j in ctx.app.queue.jobs.values()] == [ONE_PIECE]
433
 
434
+ # once the short grace period is over, files are renamed with the auto format as usual
435
  ctx.app._recent[OWNER][1] -= appmod.ONE_SHOT_GRACE + 1
436
  ev = await send_doc(ctx, OWNER, "1150 - One Piece [Dual] 720p.mkv", mid=53)
437
+ assert "Queued" in ev.texts[0] and "🗂 Auto format: <b>Auto One</b>" in ev.texts[0] and "Auto - E1150.mkv" in ev.texts[0]
438
  assert len(ctx.app.queue.jobs) == 2
439
 
440
  # and choosing a format again renames the next one with it
441
  await click(ctx, OWNER, f"rn:p:{fmt['id']}")
442
  ev = await send_doc(ctx, OWNER, "1151 - One Piece [Dual] 720p.mkv", mid=54)
443
  assert "🗂 Format: <b>One Piece</b>" in ev.texts[0] and len(ctx.app.queue.jobs) == 3
444
+ assert auto["id"] != fmt["id"]
445
 
446
 
447
  async def test_a_forwarded_batch_gives_the_format_to_exactly_one_file(ctx):
 
461
  ctx.app.modes[OWNER].expires = time.time() - 1
462
  ev = await send_doc(ctx, OWNER, ONE_PIECE, mid=50)
463
  (job,) = ctx.app.queue.jobs.values()
464
+ assert job.fmt is None and "Format" not in ev.texts[0] # a normal auto-rename (the user has no auto format)
465
  assert ctx.app._mode(OWNER) is None
466
 
467
 
 
487
  assert cb.alerts == ["That format no longer exists."] and ctx.app._mode(ADMIN_CFG) is None
488
  await ctx.db.delete_format(OWNER, fmt["id"])
489
  cb = await click(ctx, OWNER, f"rn:p:{fmt['id']}")
490
+ assert cb.alerts == ["That format no longer exists."] and "no manual formats yet" in cb.edits[-1][0]
491
+
492
+
493
+ async def test_an_auto_format_cannot_be_chosen_for_rename(ctx):
494
+ auto = await make_format(ctx, "Auto One", kind="auto")
495
+ cb = await click(ctx, OWNER, f"rn:p:{auto['id']}") # a stale or made-up button: the picker never offers it
496
+ assert cb.alerts == ["That format no longer exists."] and ctx.app._mode(OWNER) is None
497
+ await send_doc(ctx, OWNER, ONE_PIECE, mid=50)
498
+ (job,) = ctx.app.queue.jobs.values()
499
+ assert job.fmt["id"] == auto["id"] # the file goes through the auto format, as any file does
500
 
501
 
502
  async def test_one_users_chosen_format_does_not_affect_another(ctx):
 
512
  a, b = await make_format(ctx, "One Piece"), await make_format(ctx, "Dragon Ball")
513
  await click(ctx, OWNER, f"rn:p:{a['id']}")
514
  cb = await click(ctx, OWNER, "rn:menu")
515
+ assert "Choose the manual format" in cb.edits[-1][0] and ctx.app._mode(OWNER) is None
516
  await click(ctx, OWNER, f"rn:p:{b['id']}")
517
  assert ctx.app.modes[OWNER].data["fid"] == b["id"]
518
  cb = await click(ctx, OWNER, "rn:stop")
 
552
  assert OWNER not in ctx.app.pending
553
 
554
 
555
+ @pytest.mark.parametrize("kind", KINDS)
556
+ def test_the_format_screens_fit_in_a_caption_even_with_the_longest_values(kind):
557
  from .test_panels import CAPTION_LIMIT, visible
558
 
559
  longest = ["klingon-language-tag-x"] * 12
560
  fmt = {
561
+ "id": 12, "name": "N" * 30, "kind": kind, "has_thumb": True, "template": "T" * 200, "caption": "C" * 800,
562
  "meta": {k: "M" * 200 for k in ("title", "author", "artist", "audio", "subtitle", "video")},
563
  "keep_audio": longest, "keep_subtitle": longest,
564
  }
565
  formats = [dict(fmt, id=i, name="F" * 30) for i in range(12)]
566
+ other = dict(fmt, kind="manual" if kind == "auto" else "auto", id=13)
567
  for name, (text, rows) in {
568
+ "view": panels.format_view(fmt), "view_active": panels.format_view(fmt, active=True),
569
+ "view_inline": panels.format_view(fmt, share_inline=True),
570
+ "menu": panels.formats_menu(formats, 12, kind, 3), "delete": panels.format_delete_confirm(fmt),
571
+ "chooser": panels.formats_home(12, 12, 12), "auto_menu": panels.auto_menu(formats, 3, 12),
572
  "ready": panels.rename_ready(fmt, 10), "picker": panels.rename_picker(formats),
573
+ "copied": panels.format_copied(fmt, other), "metadata_applied": (panels.format_metadata_applied(fmt), []),
574
  "shared": panels.shared_format(fmt, "T" * 16), "shared_mine": panels.shared_format(fmt, "T" * 16, mine=True),
575
  "added": panels.shared_added(fmt), "card": (panels.share_card(fmt)[2], []),
576
+ **{f"prompt_{k}": panels.format_prompt(k, 12) for k in panels.FORMAT_PROMPTS if k != "new"},
577
+ "prompt_new": panels.format_prompt("new", 0, kind),
578
  }.items():
579
  assert len(visible(text)) <= CAPTION_LIMIT, (name, len(visible(text)))
580
  assert text.count("<b>") == text.count("</b>") and text.count("<code>") == text.count("</code>"), name
581
  for row in rows:
582
+ assert 1 <= len(row) <= 4, name
583
  for label, data in row:
584
  assert label.strip() and len(label) <= 32 and 0 < len(data.encode()) <= 64, (name, label, data)
tests/test_panels.py CHANGED
@@ -15,8 +15,8 @@ from bot.utils import code_each
15
  from .tgfakes import button_rows, variables_line
16
 
17
  CAPTION_LIMIT = 1000 # Telegram allows 1024 for a caption; keep a margin
18
- TEMPLATE_LIST = "{title} {season} {episode} {quality} {year} {codec} {filename} {ext}"
19
- CAPTION_LIST = "{filename} {original} {title} {season} {episode} {quality} {year} {codec} {size} {duration}"
20
 
21
 
22
  def visible(text: str) -> str:
@@ -33,38 +33,81 @@ def worst_case():
33
  longest = sorted(LANGUAGES, key=len, reverse=True)[:12]
34
  return settings(
35
  mode="caption", file_type="document", metadata_on=True,
36
- template="W" * 200, caption="C" * 800, remove_words=["R" * 60] * 50,
37
  meta={k: "M" * 200 for k in DEFAULT_SETTINGS["meta"]},
38
  keep_audio=list(longest), keep_subtitle=list(longest), target_channel=-1001234567890123,
39
  )
40
 
41
 
42
- def every_panel(s, has_thumb=True):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  out = {
44
- "home_owner": panels.home(s, owner=True),
45
- "home_user": panels.home(s, owner=False),
46
- "rename": panels.rename(s),
47
- "cap": panels.caption(s, has_thumb),
48
  "meta": panels.metadata(s),
49
  "tool": panels.tool(s),
50
  "ftype": panels.filetype(s),
51
  "help": panels.help_panel(),
52
  "metahelp": panels.metadata_help(),
53
  "admin": panels.admin(1, list(range(1000, 1030)), 3, 4, -1001234567890),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  }
55
  out.update({f"prompt_{k}": panels.input_prompt(k, BACK[k]) for k in panels.PROMPTS})
 
 
56
  return out
57
 
58
 
59
- @pytest.mark.parametrize("s", [settings(), worst_case()], ids=["defaults", "worst-case"])
60
- def test_every_panel_fits_in_a_photo_caption(s):
61
- for name, (text, _rows) in every_panel(s).items():
 
 
 
 
62
  assert len(visible(text)) <= CAPTION_LIMIT, f"{name} is {len(visible(text))} characters"
63
 
64
 
65
- @pytest.mark.parametrize("s", [settings(), worst_case()], ids=["defaults", "worst-case"])
66
- def test_callback_data_is_short_unique_per_panel_and_labels_are_present(s):
67
- for name, (_text, rows) in every_panel(s).items():
68
  seen = set()
69
  for row in rows:
70
  assert 1 <= len(row) <= 4, name
@@ -76,7 +119,7 @@ def test_callback_data_is_short_unique_per_panel_and_labels_are_present(s):
76
 
77
 
78
  def test_every_panel_html_is_balanced():
79
- for name, (text, _rows) in every_panel(worst_case()).items():
80
  for tag in ("b", "code", "i"):
81
  assert text.count(f"<{tag}>") == text.count(f"</{tag}>"), (name, tag)
82
 
@@ -85,7 +128,7 @@ def test_every_panel_html_is_balanced():
85
  ("template prompt", panels.PROMPTS["template"], TEMPLATE_LIST),
86
  ("caption prompt", panels.PROMPTS["caption"], CAPTION_LIST),
87
  ("help panel", panels.help_panel()[0], TEMPLATE_LIST),
88
- ("caption panel", panels.caption(settings(), True)[0], CAPTION_LIST),
89
  ])
90
  def test_every_variable_is_its_own_tap_to_copy_block(name, text, shown):
91
  # Tapping a <code> block in Telegram copies all of it, so "{quality}" must not share a block with the other variables.
@@ -100,13 +143,6 @@ def test_code_each_makes_one_escaped_block_per_item():
100
  assert code_each([]) == ""
101
 
102
 
103
- def sample_format(**over):
104
- fmt = {"id": 12, "name": "One Piece", "has_thumb": False, "template": "[S{season}E{episode}] {title}",
105
- "caption": "<b>{filename}</b>", "meta": {k: "" for k in ("title", "author", "artist", "audio", "subtitle", "video")}, "keep_audio": [], "keep_subtitle": []}
106
- fmt.update(over)
107
- return fmt
108
-
109
-
110
  def test_the_share_button_is_the_chat_picker_or_the_plain_button_that_explains():
111
  fmt = sample_format()
112
  on = [d for row in panels.format_view(fmt, share_inline=True)[1] for _l, d in row]
@@ -159,6 +195,10 @@ def test_navigation_always_offers_a_way_out():
159
  data = [d for row in rows for _l, d in row]
160
  if name.startswith("prompt_"):
161
  assert any(d.startswith("in:cancel:") for d in data), name
 
 
 
 
162
  else:
163
  assert "x:close" in data, name
164
 
@@ -171,6 +211,18 @@ def test_home_shows_admin_button_only_to_the_owner():
171
  assert expected in user
172
 
173
 
 
 
 
 
 
 
 
 
 
 
 
 
174
  def test_active_choices_get_a_checkmark():
175
  def labels(panel):
176
  return {data: label for row in panel[1] for label, data in row}
@@ -211,9 +263,11 @@ def test_fmt_tokens():
211
 
212
 
213
  def test_html_in_user_values_is_escaped():
214
- s = settings(template="<b>{title}</b> & co", meta={**DEFAULT_SETTINGS["meta"], "title": "<i>x</i>"})
215
- assert "<b>{title}</b>" not in panels.rename(s)[0]
216
- assert "&lt;b&gt;" in panels.rename(s)[0]
 
 
217
  assert "<i>x</i>" not in panels.metadata(s)[0]
218
 
219
 
 
15
  from .tgfakes import button_rows, variables_line
16
 
17
  CAPTION_LIMIT = 1000 # Telegram allows 1024 for a caption; keep a margin
18
+ TEMPLATE_LIST = "{title} {season} {episode} {1episode} {2episode} {3episode} {quality} {year} {codec} {filename} {ext}"
19
+ CAPTION_LIST = "{filename} {original} {title} {season} {episode} {1episode} {2episode} {3episode} {quality} {year} {codec} {size} {duration}"
20
 
21
 
22
  def visible(text: str) -> str:
 
33
  longest = sorted(LANGUAGES, key=len, reverse=True)[:12]
34
  return settings(
35
  mode="caption", file_type="document", metadata_on=True,
36
+ remove_words=["R" * 60] * 50,
37
  meta={k: "M" * 200 for k in DEFAULT_SETTINGS["meta"]},
38
  keep_audio=list(longest), keep_subtitle=list(longest), target_channel=-1001234567890123,
39
  )
40
 
41
 
42
+ def sample_format(**over):
43
+ fmt = {"id": 12, "name": "One Piece", "kind": "manual", "has_thumb": False, "template": "[S{season}E{episode}] {title}",
44
+ "caption": "<b>{filename}</b>", "meta": {k: "" for k in ("title", "author", "artist", "audio", "subtitle", "video")}, "keep_audio": [], "keep_subtitle": []}
45
+ fmt.update(over)
46
+ return fmt
47
+
48
+
49
+ def worst_format(fid=12, kind="auto", **over):
50
+ longest = sorted(LANGUAGES, key=len, reverse=True)[:12]
51
+ fmt = sample_format(
52
+ id=fid, kind=kind, name="N" * 30, has_thumb=True, template="W" * 200, caption="C" * 800,
53
+ meta={k: "M" * 200 for k in DEFAULT_SETTINGS["meta"]}, keep_audio=list(longest), keep_subtitle=list(longest),
54
+ )
55
+ fmt.update(over)
56
+ return fmt
57
+
58
+
59
+ def every_panel(s, has_thumb=True, auto=None):
60
+ """Every panel of the bot for these settings; ``auto`` is the auto format in use (``None``: the user has none)."""
61
+ formats = [worst_format(i, "auto", name=f"{i:02d}" + "N" * 28) for i in range(1, 13)]
62
+ manual = [worst_format(i + 20, "manual", name=f"{i:02d}" + "M" * 28) for i in range(1, 13)]
63
  out = {
64
+ "home_owner": panels.home(s, owner=True, auto=auto),
65
+ "home_user": panels.home(s, owner=False, auto=auto),
66
+ "rename": panels.rename(s, auto),
67
+ "cap": panels.caption(auto, has_thumb),
68
  "meta": panels.metadata(s),
69
  "tool": panels.tool(s),
70
  "ftype": panels.filetype(s),
71
  "help": panels.help_panel(),
72
  "metahelp": panels.metadata_help(),
73
  "admin": panels.admin(1, list(range(1000, 1030)), 3, 4, -1001234567890),
74
+ "formats_home": panels.formats_home(12, 12, 12),
75
+ "auto_menu": panels.auto_menu(formats, 3, 12),
76
+ "auto_menu_empty": panels.auto_menu([], 0, 12),
77
+ "auto_list": panels.formats_menu(formats, 12, "auto", 3),
78
+ "manual_list": panels.formats_menu(manual, 12, "manual"),
79
+ "manual_list_empty": panels.formats_menu([], 12, "manual"),
80
+ "auto_format": panels.format_view(formats[2], active=False),
81
+ "auto_format_in_use": panels.format_view(formats[2], active=True, share_inline=True),
82
+ "manual_format": panels.format_view(manual[0], share_inline=True),
83
+ "copied": panels.format_copied(formats[0], manual[0]),
84
+ "delete": panels.format_delete_confirm(formats[0]),
85
+ "picker": panels.rename_picker(manual),
86
+ "picker_empty": panels.rename_picker([]),
87
+ "ready": panels.rename_ready(manual[0], 10),
88
+ "shared": panels.shared_format(formats[0], "tok"),
89
+ "shared_mine": panels.shared_format(manual[0], "tok", mine=True),
90
+ "shared_added": panels.shared_added(manual[0]),
91
  }
92
  out.update({f"prompt_{k}": panels.input_prompt(k, BACK[k]) for k in panels.PROMPTS})
93
+ out.update({f"format_prompt_{k}": panels.format_prompt(k, 12) for k in panels.FORMAT_PROMPTS if k != "new"})
94
+ out["format_prompt_new"] = panels.format_prompt("new", 0, "auto")
95
  return out
96
 
97
 
98
+ CASES = [(settings(), None), (worst_case(), None), (worst_case(), worst_format())]
99
+ CASE_IDS = ["defaults", "worst-case settings", "worst-case settings and format"]
100
+
101
+
102
+ @pytest.mark.parametrize("s, auto", CASES, ids=CASE_IDS)
103
+ def test_every_panel_fits_in_a_photo_caption(s, auto):
104
+ for name, (text, _rows) in every_panel(s, auto=auto).items():
105
  assert len(visible(text)) <= CAPTION_LIMIT, f"{name} is {len(visible(text))} characters"
106
 
107
 
108
+ @pytest.mark.parametrize("s, auto", CASES, ids=CASE_IDS)
109
+ def test_callback_data_is_short_unique_per_panel_and_labels_are_present(s, auto):
110
+ for name, (_text, rows) in every_panel(s, auto=auto).items():
111
  seen = set()
112
  for row in rows:
113
  assert 1 <= len(row) <= 4, name
 
119
 
120
 
121
  def test_every_panel_html_is_balanced():
122
+ for name, (text, _rows) in every_panel(worst_case(), auto=worst_format()).items():
123
  for tag in ("b", "code", "i"):
124
  assert text.count(f"<{tag}>") == text.count(f"</{tag}>"), (name, tag)
125
 
 
128
  ("template prompt", panels.PROMPTS["template"], TEMPLATE_LIST),
129
  ("caption prompt", panels.PROMPTS["caption"], CAPTION_LIST),
130
  ("help panel", panels.help_panel()[0], TEMPLATE_LIST),
131
+ ("caption panel", panels.caption(None, True)[0], CAPTION_LIST),
132
  ])
133
  def test_every_variable_is_its_own_tap_to_copy_block(name, text, shown):
134
  # Tapping a <code> block in Telegram copies all of it, so "{quality}" must not share a block with the other variables.
 
143
  assert code_each([]) == ""
144
 
145
 
 
 
 
 
 
 
 
146
  def test_the_share_button_is_the_chat_picker_or_the_plain_button_that_explains():
147
  fmt = sample_format()
148
  on = [d for row in panels.format_view(fmt, share_inline=True)[1] for _l, d in row]
 
195
  data = [d for row in rows for _l, d in row]
196
  if name.startswith("prompt_"):
197
  assert any(d.startswith("in:cancel:") for d in data), name
198
+ elif name.startswith("format_prompt_"):
199
+ assert any(d.startswith(("fm:cx:", "fm:l:")) for d in data), name
200
+ elif name in ("delete", "ready"): # a choice and a wait for a file: they have their own way out (Keep it, Stop)
201
+ assert any(d.startswith(("fm:v:", "rn:stop")) for d in data), name
202
  else:
203
  assert "x:close" in data, name
204
 
 
211
  assert expected in user
212
 
213
 
214
+ def test_home_metadata_line_says_what_the_auto_format_in_use_will_do():
215
+ def line(s, auto=None):
216
+ return re.search(r"Metadata: (ON|OFF)", visible(panels.home(s, owner=False, auto=auto)[0])).group(1)
217
+
218
+ assert line(settings(metadata_on=False)) == "OFF" and line(settings(metadata_on=True)) == "ON" # no auto format: the switch
219
+ plain = sample_format(kind="auto")
220
+ own = sample_format(kind="auto", meta={**plain["meta"], "title": "T"})
221
+ assert line(settings(metadata_on=False), own) == "ON" # the format carries metadata of its own: the switch is not asked
222
+ assert line(settings(metadata_on=False), plain) == "OFF" and line(settings(metadata_on=True), plain) == "ON" # none: the default decides
223
+ assert line(settings(metadata_on=False), sample_format(kind="auto", keep_audio=["english"])) == "ON"
224
+
225
+
226
  def test_active_choices_get_a_checkmark():
227
  def labels(panel):
228
  return {data: label for row in panel[1] for label, data in row}
 
263
 
264
 
265
  def test_html_in_user_values_is_escaped():
266
+ s = settings(meta={**DEFAULT_SETTINGS["meta"], "title": "<i>x</i>"})
267
+ auto = sample_format(kind="auto", name="<u>Mine</u>", template="<b>{title}</b> & co")
268
+ assert "<b>{title}</b>" not in panels.rename(s, auto)[0]
269
+ assert "&lt;b&gt;" in panels.rename(s, auto)[0] and "<u>Mine</u>" not in panels.rename(s, auto)[0]
270
+ assert "<b>{title}</b>" not in panels.home(s, owner=True, auto=auto)[0] and "<u>" not in panels.home(s, owner=True, auto=auto)[0]
271
  assert "<i>x</i>" not in panels.metadata(s)[0]
272
 
273
 
tests/test_parser.py CHANGED
@@ -5,7 +5,9 @@ from bot.parser import (
5
  apply_remove_words,
6
  compute_new_name,
7
  finalize_name,
 
8
  missing_variables,
 
9
  parse_caption,
10
  parse_info,
11
  render_template,
@@ -279,3 +281,74 @@ def test_missing_variables_only_reports_holes_outside_bracket_groups():
279
  assert missing_variables("{title} ({year})", info) == []
280
  assert missing_variables("{title} {nope}", info) == []
281
  assert missing_variables("One Piece - E{episode}", parse_info("1147 - One Piece 720p.mkv")) == []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  apply_remove_words,
6
  compute_new_name,
7
  finalize_name,
8
+ build_values,
9
  missing_variables,
10
+ padded_episode,
11
  parse_caption,
12
  parse_info,
13
  render_template,
 
281
  assert missing_variables("{title} ({year})", info) == []
282
  assert missing_variables("{title} {nope}", info) == []
283
  assert missing_variables("One Piece - E{episode}", parse_info("1147 - One Piece 720p.mkv")) == []
284
+
285
+
286
+ # ---------------------------------------------------------------------------
287
+ # {1episode} {2episode} {3episode}: the episode with zeros in front
288
+ # ---------------------------------------------------------------------------
289
+
290
+
291
+ @pytest.mark.parametrize(
292
+ "detected, zeros, expected",
293
+ [
294
+ ("4", 1, "04"), ("04", 1, "04"), ("14", 1, "14"), ("100", 1, "100"), ("004", 1, "004"), # {1episode}: at least 2 digits
295
+ ("4", 2, "004"), ("04", 2, "004"), ("004", 2, "004"), ("005", 2, "005"), ("14", 2, "014"), ("1147", 2, "1147"),
296
+ ("4", 3, "0004"), ("04", 3, "0004"), ("004", 3, "0004"), ("12", 3, "0012"), # {3episode}: at least 4 digits
297
+ ("321", 3, "0321"), ("1321", 3, "1321"), ("1147", 3, "1147"), ("12345", 3, "12345"), # wide enough: no zero is added
298
+ ("", 1, ""), ("", 3, ""), # no episode found: nothing to pad
299
+ ],
300
+ )
301
+ def test_padded_episode(detected, zeros, expected):
302
+ assert padded_episode(detected, zeros) == expected
303
+
304
+
305
+ @pytest.mark.parametrize("written", ["E4", "E04", "E004", "Episode 4", "S01E4", "S01E004"])
306
+ def test_the_padded_variables_do_not_depend_on_how_the_episode_was_written(written):
307
+ info = parse_info(f"Show {written} 720p.mkv")
308
+ values = build_values(info, "Show", ".mkv")
309
+ assert [values[name] for name in ("episode", "1episode", "2episode", "3episode")] == ["04", "04", "004", "0004"]
310
+ assert "4episode" not in values # three padded variables: up to four digits
311
+
312
+
313
+ def test_the_padded_variables_in_a_template():
314
+ name, _ = compute_new_name(
315
+ template="{title} - {1episode} - {2episode} - {3episode}", mode="filename",
316
+ original_name="One Piece - 4 [1080p].mkv", original_ext=".mkv",
317
+ )
318
+ assert name == "One Piece - 04 - 004 - 0004.mkv"
319
+ name, _ = compute_new_name(
320
+ template="{title} {2episode}", mode="filename", original_name="1147 - One Piece [Dual] 720p.mkv", original_ext=".mkv",
321
+ )
322
+ assert name == "One Piece 1147.mkv" # a number that is wide enough keeps its digits
323
+
324
+
325
+ @pytest.mark.parametrize("original, expected", [
326
+ ("One Piece - 1321 [1080p].mkv", "One Piece 1321.mkv"), # four digits already: no zero is added
327
+ ("One Piece - 321 [1080p].mkv", "One Piece 0321.mkv"), # three digits: one zero
328
+ ("One Piece - 21 [1080p].mkv", "One Piece 0021.mkv"),
329
+ ("One Piece - 1 [1080p].mkv", "One Piece 0001.mkv"),
330
+ ("1321 - One Piece [Dual] 720p.mkv", "One Piece 1321.mkv"),
331
+ ("One Piece S01E0321 720p.mkv", "One Piece 0321.mkv"), # zeros that were already there are not counted twice
332
+ ])
333
+ def test_third_padded_episode_is_four_digits_wide(original, expected):
334
+ name, _ = compute_new_name(template="{title} {3episode}", mode="filename", original_name=original, original_ext=".mkv")
335
+ assert name == expected
336
+
337
+
338
+ def test_a_fourth_padded_variable_does_not_exist():
339
+ values = build_values(parse_info("One Piece - 4 [1080p].mkv"), "One Piece", ".mkv")
340
+ assert "4episode" not in values
341
+ name, _ = compute_new_name(
342
+ template="{title} {4episode}", mode="filename", original_name="One Piece - 4 [1080p].mkv", original_ext=".mkv"
343
+ )
344
+ assert name == "One Piece {4episode}.mkv" # an unknown variable is left as plain text
345
+
346
+
347
+ def test_without_an_episode_a_group_with_a_padded_variable_goes_away():
348
+ info = parse_info("Plain Movie.mkv")
349
+ values = build_values(info, "Plain Movie", ".mkv")
350
+ assert values["1episode"] == values["3episode"] == ""
351
+ assert render_template("{title} [E{3episode}]", values) == "Plain Movie"
352
+ assert missing_variables("{title} E{2episode}", info) == ["2episode"] # a bare one leaves a hole in the name
353
+ assert missing_variables("{title} [E{2episode}] {episode} {3episode}", info) == ["episode", "3episode"]
354
+ assert missing_variables("{title} E{2episode}", parse_info("One Piece - 05.mkv")) == []
tests/test_pipeline.py CHANGED
@@ -168,10 +168,20 @@ async def test_forced_video_for_non_video_falls_back_to_document(env, tmp_path):
168
  assert kw["thumb"] is None
169
 
170
 
 
 
 
 
 
 
 
 
 
 
171
  @needs_ffmpeg
172
  async def test_custom_thumbnail_wins_over_generated(env):
173
- await env.db.set_thumb(42, b"\xff\xd8custom")
174
  client, renamer, job = make(env)
 
175
  await renamer.process(job)
176
  assert client.sent[0][2]["thumb"] == b"\xff\xd8custom"
177
 
@@ -321,9 +331,9 @@ async def make_fmt(env, name="One Piece", **changes):
321
 
322
  @needs_ffmpeg
323
  async def test_a_format_brings_its_own_metadata_tracks_and_thumbnail(env):
324
- # the global metadata is off and has a different title: the format must not depend on either
325
- await env.db.update_settings(42, metadata_on=False, meta={"title": "GLOBAL"}, keep_audio=["hindi"])
326
- await env.db.set_thumb(42, b"\xff\xd8global")
327
  fmt = await make_fmt(env, meta={"title": "One Piece", "audio": "{lang}"}, keep_audio=["english", "japanese"], keep_subtitle=["2"])
328
  await env.db.set_format_thumb(42, fmt["id"], b"\xff\xd8format")
329
  fmt = await env.db.get_format(42, fmt["id"])
@@ -341,19 +351,19 @@ async def test_a_format_brings_its_own_metadata_tracks_and_thumbnail(env):
341
 
342
 
343
  @needs_ffmpeg
344
- async def test_a_format_without_a_thumbnail_uses_the_users_default_one(env):
345
- await env.db.set_thumb(42, b"\xff\xd8global")
346
  fmt = await make_fmt(env)
347
  client, renamer, job = make(env)
348
  job.fmt = fmt
349
  await renamer.process(job)
350
  (_t, _f, kw), = client.sent
351
- assert kw["thumb"] == b"\xff\xd8global"
352
 
353
 
354
  @needs_ffmpeg
355
- async def test_a_format_that_sets_no_metadata_leaves_the_file_untouched_even_if_global_metadata_is_on(env):
356
- await env.db.update_settings(42, metadata_on=True, meta={"title": "GLOBAL"}, keep_audio=["english"])
357
  fmt = await make_fmt(env)
358
  client, renamer, job = make(env)
359
  job.fmt = fmt
@@ -362,9 +372,39 @@ async def test_a_format_that_sets_no_metadata_leaves_the_file_untouched_even_if_
362
  assert job.state == "done"
363
 
364
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
365
  @needs_ffmpeg
366
  async def test_everything_else_still_comes_from_the_users_settings(env):
367
- await env.db.update_settings(42, file_type="document", caption="<b>{filename}</b> by me", target_channel=-100777)
368
  fmt = await make_fmt(env, meta={"title": "T"})
369
  client, renamer, job = make(env)
370
  job.fmt = fmt
@@ -372,14 +412,14 @@ async def test_everything_else_still_comes_from_the_users_settings(env):
372
  await renamer.process(job)
373
  (target, _f, kw), = client.sent
374
  assert target == -100777 and kw["force_document"] is True # the file type and the channel are still the user's
375
- assert kw["caption"] == "<b>One Piece - E5</b>" # the caption is not: this format has none, so it is the new name in bold
376
 
377
 
378
  @needs_ffmpeg
379
  async def test_a_format_sends_its_own_caption(env):
380
  from bot.utils import human_size
381
 
382
- await env.db.update_settings(42, caption="<i>THE USER'S CAPTION {filename}</i>")
383
  fmt = await make_fmt(env, caption="<b>{filename}</b> · {original} · {size}")
384
  client, renamer, job = make(env)
385
  job.fmt = fmt
@@ -391,8 +431,8 @@ async def test_a_format_sends_its_own_caption(env):
391
 
392
 
393
  @needs_ffmpeg
394
- async def test_a_format_without_a_caption_sends_the_new_name_in_bold_not_the_users_caption(env):
395
- await env.db.update_settings(42, caption="<i>THE USER'S CAPTION {filename}</i>")
396
  fmt = await make_fmt(env)
397
  client, renamer, job = make(env)
398
  job.fmt = fmt
@@ -402,24 +442,36 @@ async def test_a_format_without_a_caption_sends_the_new_name_in_bold_not_the_use
402
 
403
 
404
  @needs_ffmpeg
405
- async def test_a_rename_without_a_format_still_uses_the_users_caption(env):
406
- await env.db.update_settings(42, caption="<i>{filename}</i> ({quality})")
407
- await env.db.create_format(42, "Unused", template="X") # having formats changes nothing for the normal renames
408
  client, renamer, job = make(env)
 
409
  await renamer.process(job)
410
  assert client.sent[0][2]["caption"] == "<i>[S01E05] Tokyo Revengers [1080p]</i> (1080p)"
411
 
412
 
413
  @needs_ffmpeg
414
- async def test_a_format_deleted_while_queued_falls_back_to_the_users_thumbnail_and_still_finishes(env):
415
- await env.db.set_thumb(42, b"\xff\xd8global")
 
 
 
 
 
 
 
 
 
 
 
416
  fmt = await make_fmt(env, meta={"title": "One Piece"})
 
417
  await env.db.delete_format(42, fmt["id"])
418
  client, renamer, job = make(env)
419
  job.fmt = fmt # the snapshot taken when the file was queued
420
  await renamer.process(job)
421
  (_t, _f, kw), = client.sent
422
- assert kw["thumb"] == b"\xff\xd8global" and job.state == "done"
423
  out = await probe(client.uploads[0])
424
  assert out.tags.get("title") == "One Piece" # the snapshot's metadata is still applied
425
 
 
168
  assert kw["thumb"] is None
169
 
170
 
171
+ async def make_auto(env, thumb=None, **changes):
172
+ """The user's auto format (made on demand), with these changes and this thumbnail."""
173
+ fmt = await env.db.ensure_auto_format(42)
174
+ if changes:
175
+ await env.db.update_format(42, fmt["id"], **changes)
176
+ if thumb:
177
+ await env.db.set_format_thumb(42, fmt["id"], thumb)
178
+ return await env.db.get_format(42, fmt["id"])
179
+
180
+
181
  @needs_ffmpeg
182
  async def test_custom_thumbnail_wins_over_generated(env):
 
183
  client, renamer, job = make(env)
184
+ job.fmt = await make_auto(env, thumb=b"\xff\xd8custom")
185
  await renamer.process(job)
186
  assert client.sent[0][2]["thumb"] == b"\xff\xd8custom"
187
 
 
331
 
332
  @needs_ffmpeg
333
  async def test_a_format_brings_its_own_metadata_tracks_and_thumbnail(env):
334
+ # the default metadata is off and has a different title: a manual format must not depend on either
335
+ await env.db.update_settings(42, metadata_on=False, meta={"title": "DEFAULT"}, keep_audio=["hindi"])
336
+ await make_auto(env, thumb=b"\xff\xd8auto") # nor on the auto format
337
  fmt = await make_fmt(env, meta={"title": "One Piece", "audio": "{lang}"}, keep_audio=["english", "japanese"], keep_subtitle=["2"])
338
  await env.db.set_format_thumb(42, fmt["id"], b"\xff\xd8format")
339
  fmt = await env.db.get_format(42, fmt["id"])
 
351
 
352
 
353
  @needs_ffmpeg
354
+ async def test_a_format_without_a_thumbnail_gets_a_frame_of_the_video(env):
355
+ await make_auto(env, thumb=b"\xff\xd8auto") # another format's thumbnail is not borrowed
356
  fmt = await make_fmt(env)
357
  client, renamer, job = make(env)
358
  job.fmt = fmt
359
  await renamer.process(job)
360
  (_t, _f, kw), = client.sent
361
+ assert kw["thumb"][:2] == b"\xff\xd8" and kw["thumb"] != b"\xff\xd8auto" # cut out of the video
362
 
363
 
364
  @needs_ffmpeg
365
+ async def test_a_manual_format_that_sets_no_metadata_leaves_the_file_untouched_even_if_default_metadata_is_on(env):
366
+ await env.db.update_settings(42, metadata_on=True, meta={"title": "DEFAULT"}, keep_audio=["english"])
367
  fmt = await make_fmt(env)
368
  client, renamer, job = make(env)
369
  job.fmt = fmt
 
372
  assert job.state == "done"
373
 
374
 
375
+ @needs_ffmpeg
376
+ async def test_an_auto_format_without_metadata_uses_the_default_metadata_while_it_is_on(env):
377
+ await env.db.update_settings(42, metadata_on=True, meta={"title": "DEFAULT TITLE"})
378
+ client, renamer, job = make(env)
379
+ job.fmt = await make_auto(env) # sets no metadata of its own
380
+ await renamer.process(job)
381
+ assert (await probe(client.uploads[0])).tags.get("title") == "DEFAULT TITLE"
382
+
383
+ await env.db.update_settings(42, metadata_on=False) # switched off: the file is left alone
384
+ client, renamer, job = make(env)
385
+ job.fmt = await make_auto(env)
386
+ await renamer.process(job)
387
+ assert client.uploads[0].read_bytes() == (env.media_dir / "sample.mkv").read_bytes()
388
+
389
+
390
+ @needs_ffmpeg
391
+ async def test_an_auto_format_with_metadata_of_its_own_ignores_the_default_metadata(env):
392
+ await env.db.update_settings(42, metadata_on=True, meta={"title": "DEFAULT TITLE"})
393
+ client, renamer, job = make(env)
394
+ job.fmt = await make_auto(env, meta={"title": "OWN TITLE"})
395
+ await renamer.process(job)
396
+ assert (await probe(client.uploads[0])).tags.get("title") == "OWN TITLE"
397
+
398
+ await env.db.update_settings(42, metadata_on=False) # its own metadata applies whatever the switch says
399
+ client, renamer, job = make(env)
400
+ job.fmt = await make_auto(env)
401
+ await renamer.process(job)
402
+ assert (await probe(client.uploads[0])).tags.get("title") == "OWN TITLE"
403
+
404
+
405
  @needs_ffmpeg
406
  async def test_everything_else_still_comes_from_the_users_settings(env):
407
+ await env.db.update_settings(42, file_type="document", target_channel=-100777)
408
  fmt = await make_fmt(env, meta={"title": "T"})
409
  client, renamer, job = make(env)
410
  job.fmt = fmt
 
412
  await renamer.process(job)
413
  (target, _f, kw), = client.sent
414
  assert target == -100777 and kw["force_document"] is True # the file type and the channel are still the user's
415
+ assert kw["caption"] == "<b>One Piece - E5</b>" # the caption is the format's: it has none, so it is the new name in bold
416
 
417
 
418
  @needs_ffmpeg
419
  async def test_a_format_sends_its_own_caption(env):
420
  from bot.utils import human_size
421
 
422
+ await make_auto(env, caption="<i>THE AUTO FORMAT'S CAPTION {filename}</i>") # another format's caption is not borrowed
423
  fmt = await make_fmt(env, caption="<b>{filename}</b> · {original} · {size}")
424
  client, renamer, job = make(env)
425
  job.fmt = fmt
 
431
 
432
 
433
  @needs_ffmpeg
434
+ async def test_a_format_without_a_caption_sends_the_new_name_in_bold(env):
435
+ await make_auto(env, caption="<i>THE AUTO FORMAT'S CAPTION {filename}</i>")
436
  fmt = await make_fmt(env)
437
  client, renamer, job = make(env)
438
  job.fmt = fmt
 
442
 
443
 
444
  @needs_ffmpeg
445
+ async def test_an_auto_rename_uses_the_caption_of_its_auto_format(env):
446
+ await env.db.create_format(42, "Unused", template="X") # a manual format changes nothing for the auto renames
 
447
  client, renamer, job = make(env)
448
+ job.fmt = await make_auto(env, caption="<i>{filename}</i> ({quality})")
449
  await renamer.process(job)
450
  assert client.sent[0][2]["caption"] == "<i>[S01E05] Tokyo Revengers [1080p]</i> (1080p)"
451
 
452
 
453
  @needs_ffmpeg
454
+ async def test_an_auto_rename_without_any_format_uses_the_built_in_defaults(env):
455
+ client, renamer, job = make(env)
456
+ assert job.fmt is None
457
+ await renamer.process(job)
458
+ (_t, _f, kw), = client.sent
459
+ assert kw["caption"] == "<b>[S01E05] Tokyo Revengers [1080p]</b>" # the new name in bold
460
+ assert kw["thumb"][:2] == b"\xff\xd8" # a frame of the video
461
+ assert client.uploads[0].read_bytes() == (env.media_dir / "sample.mkv").read_bytes()
462
+ assert await env.db.list_formats(42) == [] # nothing was made for it
463
+
464
+
465
+ @needs_ffmpeg
466
+ async def test_a_format_deleted_while_queued_still_finishes_with_its_snapshot(env):
467
  fmt = await make_fmt(env, meta={"title": "One Piece"})
468
+ await env.db.set_format_thumb(42, fmt["id"], b"\xff\xd8gone")
469
  await env.db.delete_format(42, fmt["id"])
470
  client, renamer, job = make(env)
471
  job.fmt = fmt # the snapshot taken when the file was queued
472
  await renamer.process(job)
473
  (_t, _f, kw), = client.sent
474
+ assert kw["thumb"][:2] == b"\xff\xd8" and kw["thumb"] != b"\xff\xd8gone" and job.state == "done" # its thumbnail went with it
475
  out = await probe(client.uploads[0])
476
  assert out.tags.get("title") == "One Piece" # the snapshot's metadata is still applied
477
 
tests/test_resize.py CHANGED
@@ -7,7 +7,7 @@ from PIL import Image
7
  from bot import app as appmod
8
  from bot import panels
9
 
10
- from .appkit import OWNER, STRANGER, click, data_of, jpeg, say, send_doc
11
  from .tgfakes import FakeEvent, FakeMessage, dispatch
12
 
13
  FULL_HD = (1920, 1080)
@@ -144,13 +144,13 @@ async def test_it_handles_one_image_per_command_and_extra_photos_do_not_replace_
144
  assert "/resize handles one file at a time" in extra.texts[0]
145
  assert (await send_photo(ctx, mid=52)).out == []
146
  assert len(results(ctx)) == 1
147
- assert not await ctx.db.has_thumb(OWNER) # none of the extra photos became the saved thumbnail
148
  doc = await send_image_file(ctx, mid=53)
149
  assert doc.out == [] and ctx.app.queue.waiting == 0 # an image file right after is skipped as well, not auto-renamed
150
 
151
  ctx.app._recent[OWNER][1] -= appmod.ONE_SHOT_GRACE + 1
152
  await send_photo(ctx, mid=54)
153
- assert await ctx.db.has_thumb(OWNER) # after the grace a photo is a thumbnail again
154
 
155
 
156
  async def test_large_results_are_sent_as_files_so_telegram_does_not_shrink_them(ctx):
@@ -196,7 +196,7 @@ async def test_it_expires_and_cancel_ends_it(ctx):
196
  ev = await say(ctx, OWNER, "/cancel")
197
  assert "Cancelled" in ev.texts[0] and OWNER not in ctx.app.modes
198
  await send_photo(ctx)
199
- assert results(ctx) == [] and await ctx.db.has_thumb(OWNER)
200
 
201
 
202
  async def test_a_format_thumbnail_prompt_takes_priority_over_a_waiting_resize(ctx):
 
7
  from bot import app as appmod
8
  from bot import panels
9
 
10
+ from .appkit import OWNER, STRANGER, auto_thumb, autofmt, click, data_of, jpeg, say, send_doc
11
  from .tgfakes import FakeEvent, FakeMessage, dispatch
12
 
13
  FULL_HD = (1920, 1080)
 
144
  assert "/resize handles one file at a time" in extra.texts[0]
145
  assert (await send_photo(ctx, mid=52)).out == []
146
  assert len(results(ctx)) == 1
147
+ assert await autofmt(ctx) is None # none of the extra photos became a thumbnail (that would have made the auto format)
148
  doc = await send_image_file(ctx, mid=53)
149
  assert doc.out == [] and ctx.app.queue.waiting == 0 # an image file right after is skipped as well, not auto-renamed
150
 
151
  ctx.app._recent[OWNER][1] -= appmod.ONE_SHOT_GRACE + 1
152
  await send_photo(ctx, mid=54)
153
+ assert await auto_thumb(ctx) # after the grace a photo is the thumbnail of the auto format again
154
 
155
 
156
  async def test_large_results_are_sent_as_files_so_telegram_does_not_shrink_them(ctx):
 
196
  ev = await say(ctx, OWNER, "/cancel")
197
  assert "Cancelled" in ev.texts[0] and OWNER not in ctx.app.modes
198
  await send_photo(ctx)
199
+ assert results(ctx) == [] and await auto_thumb(ctx)
200
 
201
 
202
  async def test_a_format_thumbnail_prompt_takes_priority_over_a_waiting_resize(ctx):
tests/test_richtext.py CHANGED
@@ -156,6 +156,8 @@ def test_of_two_overlapping_links_the_later_one_gets_what_is_left():
156
  ("[a=https://x.io]{ti[/a]tle}", '<a href="https://x.io">{title}</a>'),
157
  ("[b]Ep[/b] {episode} [i]x[/i]", "<b>Ep</b> {episode} <i>x</i>"), # a format that does not touch a variable is left alone
158
  ("{[b]nonsense[/b]}", "{<b>nonsense</b>}"), # only the variables the caption knows are held together
 
 
159
  ])
160
  def test_a_format_never_cuts_a_variable_in_two(markup, expected):
161
  assert html_of(markup) == expected
 
156
  ("[a=https://x.io]{ti[/a]tle}", '<a href="https://x.io">{title}</a>'),
157
  ("[b]Ep[/b] {episode} [i]x[/i]", "<b>Ep</b> {episode} <i>x</i>"), # a format that does not touch a variable is left alone
158
  ("{[b]nonsense[/b]}", "{<b>nonsense</b>}"), # only the variables the caption knows are held together
159
+ ("Ep {[b]2episode[/b]} here", "Ep <b>{2episode}</b> here"), # the padded episode variables are held together too
160
+ ("[b]{3epis[/b]ode}", "<b>{3episode}</b>"),
161
  ])
162
  def test_a_format_never_cuts_a_variable_in_two(markup, expected):
163
  assert html_of(markup) == expected
tests/test_settings_ops.py CHANGED
@@ -1,6 +1,8 @@
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
@@ -18,38 +20,69 @@ async def get(db):
18
  return await db.get_settings(UID)
19
 
20
 
 
 
 
 
 
 
 
 
 
21
  async def test_template_saved_with_preview(db):
22
- ok, msg = await apply_setting(db, UID, "template", "[S{season}E{episode}] {title} [{quality}]")
23
  assert ok and "Template saved" in msg and "Preview" in msg
24
- assert (await get(db))["template"] == "[S{season}E{episode}] {title} [{quality}]"
 
25
 
26
 
27
  async def test_template_validation_and_reset(db):
28
- assert (await apply_setting(db, UID, "template", " "))[0] is False
29
- ok, msg = await apply_setting(db, UID, "template", "x" * 201)
30
  assert not ok and "too long" in msg
31
- assert (await get(db))["template"] == DEFAULT_SETTINGS["template"]
32
- await apply_setting(db, UID, "template", "{title}")
33
- ok, _ = await apply_setting(db, UID, "template", "reset")
34
- assert ok and (await get(db))["template"] == DEFAULT_SETTINGS["template"]
 
35
 
36
 
37
  async def test_template_unknown_variables_are_reported_but_saved(db):
38
- ok, msg = await apply_setting(db, UID, "template", "{title} {nope}")
39
  assert ok and "{nope}" in msg and "Unknown variables" in msg
40
- assert (await get(db))["template"] == "{title} {nope}"
 
 
 
 
 
 
 
 
41
 
42
 
43
  async def test_caption_set_validate_reset(db):
44
- ok, msg = await apply_setting(db, UID, "caption", "<b>{filename}</b> · {size}")
45
- assert ok and (await get(db))["caption"] == "<b>{filename}</b> · {size}"
46
  assert "Unknown" not in msg
47
- assert (await apply_setting(db, UID, "caption", "x" * 801))[0] is False
48
- assert (await apply_setting(db, UID, "caption", ""))[0] is False
49
- ok, msg = await apply_setting(db, UID, "caption", "{bogus}")
50
  assert ok and "{bogus}" in msg
51
- ok, _ = await apply_setting(db, UID, "caption", "reset")
52
- assert ok and (await get(db))["caption"] == "<b>{filename}</b>"
 
 
 
 
 
 
 
 
 
 
 
 
53
 
54
 
55
  async def test_remove_words(db):
 
1
  import pytest
2
 
3
+ from bot.db import DEFAULT_CAPTION, Database
4
+ from bot.formats import AutoFormatSettings
5
+ from bot.parser import DEFAULT_TEMPLATE
6
  from bot.settings_ops import META_COMMANDS, META_KINDS, apply_setting, parse_channel, preview_template
7
 
8
  UID = 7
 
20
  return await db.get_settings(UID)
21
 
22
 
23
+ async def auto(db):
24
+ """The auto format in use: the template and the caption are saved there, not in the user's settings."""
25
+ return await db.get_auto_format(UID)
26
+
27
+
28
+ def store(db):
29
+ return AutoFormatSettings(db)
30
+
31
+
32
  async def test_template_saved_with_preview(db):
33
+ ok, msg = await apply_setting(store(db), UID, "template", "[S{season}E{episode}] {title} [{quality}]")
34
  assert ok and "Template saved" in msg and "Preview" in msg
35
+ assert (await auto(db))["template"] == "[S{season}E{episode}] {title} [{quality}]"
36
+ assert "template" not in await get(db) # the user's own settings don't hold one
37
 
38
 
39
  async def test_template_validation_and_reset(db):
40
+ assert (await apply_setting(store(db), UID, "template", " "))[0] is False
41
+ ok, msg = await apply_setting(store(db), UID, "template", "x" * 201)
42
  assert not ok and "too long" in msg
43
+ assert await auto(db) is None # nothing was saved, so no auto format was made for it either
44
+ await apply_setting(store(db), UID, "template", "{title}")
45
+ assert (await auto(db))["template"] == "{title}"
46
+ ok, _ = await apply_setting(store(db), UID, "template", "reset")
47
+ assert ok and (await auto(db))["template"] == DEFAULT_TEMPLATE
48
 
49
 
50
  async def test_template_unknown_variables_are_reported_but_saved(db):
51
+ ok, msg = await apply_setting(store(db), UID, "template", "{title} {nope}")
52
  assert ok and "{nope}" in msg and "Unknown variables" in msg
53
+ assert (await auto(db))["template"] == "{title} {nope}"
54
+
55
+
56
+ async def test_the_padded_episode_variables_are_known_ones(db):
57
+ ok, msg = await apply_setting(store(db), UID, "template", "{title} E{1episode} {2episode} {3episode}")
58
+ assert ok and "Unknown" not in msg
59
+ assert "Tokyo Revengers E05 005 0005" in msg # the preview shows the padding
60
+ ok, msg = await apply_setting(store(db), UID, "caption", "Episode {2episode} of {title}")
61
+ assert ok and "Unknown" not in msg
62
 
63
 
64
  async def test_caption_set_validate_reset(db):
65
+ ok, msg = await apply_setting(store(db), UID, "caption", "<b>{filename}</b> · {size}")
66
+ assert ok and (await auto(db))["caption"] == "<b>{filename}</b> · {size}"
67
  assert "Unknown" not in msg
68
+ assert (await apply_setting(store(db), UID, "caption", "x" * 801))[0] is False
69
+ assert (await apply_setting(store(db), UID, "caption", ""))[0] is False
70
+ ok, msg = await apply_setting(store(db), UID, "caption", "{bogus}")
71
  assert ok and "{bogus}" in msg
72
+ ok, _ = await apply_setting(store(db), UID, "caption", "reset")
73
+ assert ok and (await auto(db))["caption"] == DEFAULT_CAPTION == "<b>{filename}</b>"
74
+
75
+
76
+ async def test_the_first_change_makes_an_auto_format_called_default_and_uses_it(db):
77
+ assert await auto(db) is None
78
+ assert (await store(db).get_settings(UID))["template"] == DEFAULT_TEMPLATE # what is shown before there is a format
79
+ await apply_setting(store(db), UID, "template", "{title}")
80
+ (made,) = await db.list_formats(UID, "auto")
81
+ assert made["name"] == "Default" and made["kind"] == "auto" and made["template"] == "{title}"
82
+ assert (await get(db))["auto_format"] == made["id"] and await auto(db) == made
83
+ await apply_setting(store(db), UID, "caption", "<i>{filename}</i>") # the same format, not a second one
84
+ assert [f["id"] for f in await db.list_formats(UID, "auto")] == [made["id"]]
85
+ assert (await auto(db))["template"] == "{title}" and (await auto(db))["caption"] == "<i>{filename}</i>"
86
 
87
 
88
  async def test_remove_words(db):
tests/test_share_app.py CHANGED
@@ -310,7 +310,8 @@ async def test_adding_a_shared_format_gives_the_user_their_own_copy(ctx):
310
  assert mine["has_thumb"] and await ctx.db.get_format_thumb(ADMIN_CFG, mine["id"]) == jpeg()
311
  assert cb.toasts == ["Added to your formats."]
312
  text, buttons = cb.edits[-1]
313
- assert "is now in your formats" in text and data_of(buttons) == [f"fm:v:{mine['id']}", "x:close"]
 
314
 
315
  # the copy is theirs: changing or deleting it leaves the owner's alone, and it shows up in their lists
316
  await ctx.db.update_format(ADMIN_CFG, mine["id"], template="Mine - {title}")
@@ -332,7 +333,7 @@ async def test_a_copy_is_renamed_when_the_name_is_taken_and_stops_at_the_limit(c
332
  for i in range(MAX_FORMATS - 2):
333
  await make_format(ctx, f"F{i}", uid=ADMIN_CFG)
334
  cb = await click(ctx, ADMIN_CFG, f"fs:add:{token}")
335
- assert cb.alerts == [f"You already have {MAX_FORMATS} formats. Delete one first."] and cb.edits == []
336
  assert await ctx.db.count_formats(ADMIN_CFG) == MAX_FORMATS
337
 
338
 
@@ -362,3 +363,52 @@ async def test_the_shared_link_works_for_many_people_and_many_times(ctx):
362
  await click(ctx, ADMIN_CFG, f"fs:add:{token}")
363
  assert [f["name"] for f in await ctx.db.list_formats(ADMIN_CFG)] == ["One Piece", "One Piece (2)"]
364
  assert await ctx.db.count_formats(OWNER) == 1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
310
  assert mine["has_thumb"] and await ctx.db.get_format_thumb(ADMIN_CFG, mine["id"]) == jpeg()
311
  assert cb.toasts == ["Added to your formats."]
312
  text, buttons = cb.edits[-1]
313
+ assert "is now in your manual formats" in text and "/rename" in text and data_of(buttons) == [f"fm:v:{mine['id']}", "x:close"]
314
+ assert mine["kind"] == "manual" and await ctx.db.count_formats(ADMIN_CFG, "auto") == 0
315
 
316
  # the copy is theirs: changing or deleting it leaves the owner's alone, and it shows up in their lists
317
  await ctx.db.update_format(ADMIN_CFG, mine["id"], template="Mine - {title}")
 
333
  for i in range(MAX_FORMATS - 2):
334
  await make_format(ctx, f"F{i}", uid=ADMIN_CFG)
335
  cb = await click(ctx, ADMIN_CFG, f"fs:add:{token}")
336
+ assert cb.alerts == [f"You already have {MAX_FORMATS} manual formats. Delete one first."] and cb.edits == []
337
  assert await ctx.db.count_formats(ADMIN_CFG) == MAX_FORMATS
338
 
339
 
 
363
  await click(ctx, ADMIN_CFG, f"fs:add:{token}")
364
  assert [f["name"] for f in await ctx.db.list_formats(ADMIN_CFG)] == ["One Piece", "One Piece (2)"]
365
  assert await ctx.db.count_formats(OWNER) == 1
366
+
367
+
368
+ # ---------------------------------------------------------------------------
369
+ # auto formats are shared and added as auto formats
370
+ # ---------------------------------------------------------------------------
371
+
372
+
373
+ async def test_a_shared_auto_format_is_added_as_an_auto_format(ctx):
374
+ fmt, token = await share(ctx, "Auto One", kind="auto", meta={"title": "One Piece"})
375
+ ev = await say(ctx, ADMIN_CFG, f"/start fmt_{token}")
376
+ assert "🤖 Auto" in ev.texts[0] and "An auto rename format shared with you" in ev.texts[0] and "your auto formats" in ev.texts[0]
377
+ cb = await click(ctx, ADMIN_CFG, f"fs:add:{token}")
378
+ (mine,) = await ctx.db.list_formats(ADMIN_CFG)
379
+ assert mine["kind"] == "auto" and mine["name"] == "Auto One" and mine["meta"]["title"] == "One Piece"
380
+ text, buttons = cb.edits[-1]
381
+ assert "is now in your auto formats" in text and "/start menu" in text and data_of(buttons) == [f"fm:v:{mine['id']}", "x:close"]
382
+ assert (await ctx.db.get_auto_format(ADMIN_CFG))["id"] == mine["id"] # their first auto format is the one in use
383
+ assert await ctx.db.count_formats(ADMIN_CFG, "manual") == 0 and (await ctx.db.get_format(OWNER, fmt["id"]))["kind"] == "auto"
384
+
385
+ title, description, card = panels.share_card(fmt)
386
+ assert "An auto rename format for this bot" in card and title == "🗂 Auto One" and description
387
+
388
+
389
+ async def test_a_shared_format_keeps_its_kind_whoever_opens_it(ctx):
390
+ _fmt, token = await share(ctx, "Auto One", kind="auto")
391
+ ev = await say(ctx, OWNER, f"/start fmt_{token}") # the owner looking at their own card
392
+ assert "your own format" in ev.texts[0] and "🤖 Auto" in ev.texts[0]
393
+ _plain, plain_token = await share(ctx, "Manual One")
394
+ ev = await say(ctx, ADMIN_CFG, f"/start fmt_{plain_token}")
395
+ assert "A manual rename format shared with you" in ev.texts[0] and "✋ Manual" in ev.texts[0]
396
+
397
+
398
+ async def test_the_limit_of_a_shared_format_is_the_one_of_its_kind(ctx):
399
+ _a, auto_token = await share(ctx, "Auto One", kind="auto")
400
+ _m, manual_token = await share(ctx, "Manual One")
401
+ for i in range(MAX_FORMATS):
402
+ await make_format(ctx, f"F{i}", uid=ADMIN_CFG) # twelve manual formats
403
+ cb = await click(ctx, ADMIN_CFG, f"fs:add:{manual_token}")
404
+ assert cb.alerts == [f"You already have {MAX_FORMATS} manual formats. Delete one first."]
405
+ cb = await click(ctx, ADMIN_CFG, f"fs:add:{auto_token}") # the auto formats are counted apart
406
+ assert cb.alerts == [] and await ctx.db.count_formats(ADMIN_CFG, "auto") == 1
407
+ assert await ctx.db.count_formats(ADMIN_CFG, "manual") == MAX_FORMATS
408
+
409
+
410
+ async def test_the_share_card_of_an_auto_format_is_made_by_the_inline_query(ctx):
411
+ fmt = await make_format(ctx, "Auto One", kind="auto")
412
+ query = await ask(ctx, OWNER, f"share_{fmt['id']}")
413
+ (card,) = query.results
414
+ assert "An auto rename format" in card.send_message.message
tests/test_smedia.py CHANGED
@@ -10,7 +10,7 @@ from telethon import types
10
 
11
  from bot import app as appmod
12
 
13
- from .appkit import ADMIN_CFG, OWNER, STRANGER, click, data_of, jpeg, say, send_doc
14
  from .tgfakes import FakeEvent, FakeMessage, dispatch, make_file
15
 
16
  GIF_BYTES = b"\x00\x00\x00\x18ftypmp42-fake-animation-bytes"
@@ -235,10 +235,10 @@ async def test_extra_files_right_after_are_skipped_and_do_not_become_the_thumbna
235
  assert "/smedia handles one file at a time" in extra.texts[0]
236
  assert (await send_photo(ctx, mid=52)).out == [] # the hint is given once
237
  assert (await send_gif(ctx)).out == [] and (await send_image_file(ctx, mid=53)).out == []
238
- assert len(uploads(ctx)) == 1 and not await ctx.db.has_thumb(OWNER) and ctx.app.queue.waiting == 0
239
  ctx.app._recent[OWNER][1] -= appmod.ONE_SHOT_GRACE + 1
240
  await send_photo(ctx, mid=54)
241
- assert await ctx.db.has_thumb(OWNER) # after the grace a photo is a thumbnail again
242
 
243
 
244
  # ---------------------------------------------------------------------------
@@ -280,7 +280,7 @@ async def test_when_nothing_can_be_sent_start_still_shows_the_menu(ctx):
280
  await send_photo(ctx)
281
  ctx.client.fail_send_file = True
282
  ev = await say(ctx, OWNER, "/start")
283
- assert "Auto Rename Bot" in ev.texts[0] and data_of(ev.out[0][1])[0] == "m:rename"
284
 
285
 
286
  async def test_damaged_saved_media_never_breaks_start(ctx):
 
10
 
11
  from bot import app as appmod
12
 
13
+ from .appkit import ADMIN_CFG, OWNER, STRANGER, auto_thumb, autofmt, click, data_of, jpeg, say, send_doc
14
  from .tgfakes import FakeEvent, FakeMessage, dispatch, make_file
15
 
16
  GIF_BYTES = b"\x00\x00\x00\x18ftypmp42-fake-animation-bytes"
 
235
  assert "/smedia handles one file at a time" in extra.texts[0]
236
  assert (await send_photo(ctx, mid=52)).out == [] # the hint is given once
237
  assert (await send_gif(ctx)).out == [] and (await send_image_file(ctx, mid=53)).out == []
238
+ assert len(uploads(ctx)) == 1 and await autofmt(ctx) is None and ctx.app.queue.waiting == 0 # no photo was saved as a thumbnail
239
  ctx.app._recent[OWNER][1] -= appmod.ONE_SHOT_GRACE + 1
240
  await send_photo(ctx, mid=54)
241
+ assert await auto_thumb(ctx) # after the grace a photo is the thumbnail of the auto format again
242
 
243
 
244
  # ---------------------------------------------------------------------------
 
280
  await send_photo(ctx)
281
  ctx.client.fail_send_file = True
282
  ev = await say(ctx, OWNER, "/start")
283
+ assert "Auto Rename Bot" in ev.texts[0] and data_of(ev.out[0][1])[:2] == ["m:auto", "m:rename"]
284
 
285
 
286
  async def test_damaged_saved_media_never_breaks_start(ctx):
tests/test_stream_rename.py CHANGED
@@ -142,6 +142,16 @@ def attributes_by_type(kwargs):
142
  return {type(a): a for a in kwargs["attributes"]}
143
 
144
 
 
 
 
 
 
 
 
 
 
 
145
  # ---------------------------------------------------------------------------
146
  # the streamed way
147
  # ---------------------------------------------------------------------------
@@ -170,9 +180,8 @@ async def test_a_big_plain_rename_is_streamed_with_the_details_telegram_already_
170
  assert not (Path(env.cfg.temp_dir) / "job1").exists()
171
 
172
 
173
- async def test_the_users_thumbnail_wins_and_the_original_preview_is_then_not_even_fetched(env):
174
- await env.db.set_thumb(42, b"\xff\xd8mine")
175
- client, _job = await rename(env)
176
  assert client.sent[0][2]["thumb"] == b"\xff\xd8mine" and client.preview_downloads == 0 and len(client.relays) == 1
177
 
178
  fmt = await env.db.create_format(42, "One Piece")
@@ -182,7 +191,7 @@ async def test_the_users_thumbnail_wins_and_the_original_preview_is_then_not_eve
182
 
183
 
184
  async def test_a_format_streams_with_its_own_caption(env):
185
- await env.db.update_settings(42, caption="<i>THE USER'S {filename}</i>")
186
  fmt = await env.db.create_format(42, "One Piece", template="One Piece - E{episode}")
187
  await env.db.update_format(42, fmt["id"], caption="<b>{filename}</b> · {size} · {duration}")
188
  client, _job = await rename(env, new_name="One Piece - E5.mkv", fmt=await env.db.get_format(42, fmt["id"]))
@@ -201,8 +210,7 @@ async def test_a_file_that_gets_no_thumbnail_anyway_is_streamed_without_one(env)
201
 
202
 
203
  async def test_an_original_sent_as_a_file_is_streamed_as_a_file(env):
204
- await env.db.set_thumb(42, b"\xff\xd8mine")
205
- client, _job = await rename(env, FakeMessage()) # an .mkv without any video details: Telegram sent it as a file
206
  (_t, _f, kw), = client.sent
207
  assert len(client.relays) == 1 and kw["force_document"] is True and kw["thumb"] == b"\xff\xd8mine"
208
  assert types.DocumentAttributeVideo not in attributes_by_type(kw)
@@ -225,8 +233,7 @@ async def test_a_forced_type_is_followed_when_telegram_has_the_details_for_it(en
225
 
226
 
227
  async def test_the_caption_may_use_the_duration_when_telegram_has_it(env):
228
- await env.db.update_settings(42, caption="<b>{filename}</b> · {duration}")
229
- client, _job = await rename(env)
230
  assert client.sent[0][2]["caption"] == f"<b>[S01E05] Tokyo Revengers [1080p]</b> · {human_duration(1400.5)}" and len(client.relays) == 1
231
 
232
 
@@ -315,9 +322,8 @@ async def test_a_forced_video_or_audio_type_needs_the_details_only_the_file_can_
315
 
316
  @needs_ffmpeg
317
  async def test_a_caption_that_wants_the_duration_needs_the_file_when_telegram_does_not_know_it(env):
318
- await env.db.update_settings(42, caption="<b>{filename}</b> · {duration}")
319
- await env.db.set_thumb(42, b"\xff\xd8mine")
320
- client, job = await rename(env, FakeMessage()) # sent as a file: no duration
321
  usual_way(client, job)
322
  duration = (await probe(env.media_dir / "sample.mkv")).duration
323
  assert client.sent[0][2]["caption"] == f"<b>[S01E05] Tokyo Revengers [1080p]</b> · {human_duration(duration)}" # read from the file
@@ -421,6 +427,15 @@ def test_make_caption_fills_in_what_it_is_given():
421
  assert len(make_caption("x" * 5000, new_name="a.mkv", orig_name="a.mkv", ext=".mkv", size=1, duration=0)) == 1000
422
 
423
 
 
 
 
 
 
 
 
 
 
424
  async def test_the_embedded_preview_is_only_fetched_when_it_is_worth_having():
425
  class Client:
426
  def __init__(self, data=PREVIEW, error=None):
 
142
  return {type(a): a for a in kwargs["attributes"]}
143
 
144
 
145
+ async def auto(env, thumb=None, **changes):
146
+ """The user's auto format (made on demand) with these changes and this thumbnail: what renames the files they just send."""
147
+ fmt = await env.db.ensure_auto_format(42)
148
+ if changes:
149
+ await env.db.update_format(42, fmt["id"], **changes)
150
+ if thumb:
151
+ await env.db.set_format_thumb(42, fmt["id"], thumb)
152
+ return await env.db.get_format(42, fmt["id"])
153
+
154
+
155
  # ---------------------------------------------------------------------------
156
  # the streamed way
157
  # ---------------------------------------------------------------------------
 
180
  assert not (Path(env.cfg.temp_dir) / "job1").exists()
181
 
182
 
183
+ async def test_the_formats_thumbnail_wins_and_the_original_preview_is_then_not_even_fetched(env):
184
+ client, _job = await rename(env, fmt=await auto(env, thumb=b"\xff\xd8mine"))
 
185
  assert client.sent[0][2]["thumb"] == b"\xff\xd8mine" and client.preview_downloads == 0 and len(client.relays) == 1
186
 
187
  fmt = await env.db.create_format(42, "One Piece")
 
191
 
192
 
193
  async def test_a_format_streams_with_its_own_caption(env):
194
+ await auto(env, caption="<i>THE AUTO FORMAT'S {filename}</i>") # another format's caption is not borrowed
195
  fmt = await env.db.create_format(42, "One Piece", template="One Piece - E{episode}")
196
  await env.db.update_format(42, fmt["id"], caption="<b>{filename}</b> · {size} · {duration}")
197
  client, _job = await rename(env, new_name="One Piece - E5.mkv", fmt=await env.db.get_format(42, fmt["id"]))
 
210
 
211
 
212
  async def test_an_original_sent_as_a_file_is_streamed_as_a_file(env):
213
+ client, _job = await rename(env, FakeMessage(), fmt=await auto(env, thumb=b"\xff\xd8mine")) # an .mkv without any video details: Telegram sent it as a file
 
214
  (_t, _f, kw), = client.sent
215
  assert len(client.relays) == 1 and kw["force_document"] is True and kw["thumb"] == b"\xff\xd8mine"
216
  assert types.DocumentAttributeVideo not in attributes_by_type(kw)
 
233
 
234
 
235
  async def test_the_caption_may_use_the_duration_when_telegram_has_it(env):
236
+ client, _job = await rename(env, fmt=await auto(env, caption="<b>{filename}</b> · {duration}"))
 
237
  assert client.sent[0][2]["caption"] == f"<b>[S01E05] Tokyo Revengers [1080p]</b> · {human_duration(1400.5)}" and len(client.relays) == 1
238
 
239
 
 
322
 
323
  @needs_ffmpeg
324
  async def test_a_caption_that_wants_the_duration_needs_the_file_when_telegram_does_not_know_it(env):
325
+ fmt = await auto(env, thumb=b"\xff\xd8mine", caption="<b>{filename}</b> · {duration}")
326
+ client, job = await rename(env, FakeMessage(), fmt=fmt) # sent as a file: no duration
 
327
  usual_way(client, job)
328
  duration = (await probe(env.media_dir / "sample.mkv")).duration
329
  assert client.sent[0][2]["caption"] == f"<b>[S01E05] Tokyo Revengers [1080p]</b> · {human_duration(duration)}" # read from the file
 
427
  assert len(make_caption("x" * 5000, new_name="a.mkv", orig_name="a.mkv", ext=".mkv", size=1, duration=0)) == 1000
428
 
429
 
430
+ def test_make_caption_pads_the_episode_when_asked_to():
431
+ text = make_caption("Ep {episode} / {1episode} / {2episode} / {3episode}", new_name="x.mkv",
432
+ orig_name="Show - 7 [720p].mkv", ext=".mkv", size=1, duration=0)
433
+ assert text == "Ep 07 / 07 / 007 / 0007"
434
+ assert make_caption("{3episode} {3episode}", new_name="x.mkv", orig_name="Show - 321 [720p].mkv", ext=".mkv", size=1, duration=0) == "0321 0321"
435
+ assert make_caption("{3episode}", new_name="x.mkv", orig_name="Show - 1321 [720p].mkv", ext=".mkv", size=1, duration=0) == "1321"
436
+ assert make_caption("<b>{2episode}</b>", new_name="x.mkv", orig_name="Plain.mkv", ext=".mkv", size=1, duration=0) == "<b></b>"
437
+
438
+
439
  async def test_the_embedded_preview_is_only_fetched_when_it_is_worth_having():
440
  class Client:
441
  def __init__(self, data=PREVIEW, error=None):