Spaces:
Running
Running
github-actions[bot] commited on
Commit ·
6c3a31d
1
Parent(s): 21cde0c
Automated sync from GitHub
Browse files- .gitattributes +1 -1
- bot/app.py +194 -62
- bot/db.py +162 -56
- bot/formats.py +76 -19
- bot/panels.py +176 -60
- bot/parser.py +16 -3
- bot/pipeline.py +3 -8
- bot/queue.py +1 -1
- bot/settings_ops.py +7 -3
- tests/appkit.py +18 -0
- tests/test_app.py +114 -26
- tests/test_auto_formats_app.py +319 -0
- tests/test_caption_formatting_app.py +25 -23
- tests/test_check.py +15 -0
- tests/test_db.py +160 -21
- tests/test_ethumb.py +2 -2
- tests/test_formats.py +285 -12
- tests/test_formats_app.py +187 -81
- tests/test_panels.py +80 -26
- tests/test_parser.py +73 -0
- tests/test_pipeline.py +72 -20
- tests/test_resize.py +4 -4
- tests/test_richtext.py +2 -0
- tests/test_settings_ops.py +51 -18
- tests/test_share_app.py +52 -2
- tests/test_smedia.py +4 -4
- tests/test_stream_rename.py +26 -11
.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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 43 |
-
("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
|
| 48 |
-
("caption", "Set the caption
|
| 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
|
| 59 |
-
("delthumb", "Delete
|
| 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 |
-
|
|
|
|
| 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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 537 |
await self._reply(event, msg)
|
| 538 |
return
|
| 539 |
-
|
| 540 |
await self._reply(
|
| 541 |
event,
|
| 542 |
-
f"<b>
|
|
|
|
| 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 |
-
|
|
|
|
| 553 |
await self._reply(event, msg)
|
| 554 |
return
|
| 555 |
-
|
| 556 |
await self._reply(
|
| 557 |
event,
|
| 558 |
-
f"<b>
|
| 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.
|
| 634 |
if not data:
|
| 635 |
-
await self._reply(event, "
|
| 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="
|
|
|
|
| 644 |
)
|
| 645 |
|
| 646 |
async def cmd_delthumb(self, event: Any) -> None:
|
| 647 |
-
if await self.
|
| 648 |
-
await self.db.set_thumb(event.sender_id, None)
|
| 649 |
await self._reply(event, "🗑 Thumbnail deleted.")
|
| 650 |
else:
|
| 651 |
-
await self._reply(event, "
|
| 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.
|
| 677 |
-
await self.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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=
|
| 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.
|
| 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.
|
| 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.
|
| 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.
|
| 1625 |
if not data_bytes:
|
| 1626 |
-
await event.answer("
|
| 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 =
|
| 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
|
| 1718 |
-
|
| 1719 |
-
|
|
|
|
|
|
|
|
|
|
| 1720 |
return
|
| 1721 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 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 |
-
|
| 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,
|
| 1793 |
-
msg =
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 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 |
-
|
| 44 |
-
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
DEFAULT_FORMAT: Dict[str, Any] = {
|
| 47 |
"template": DEFAULT_TEMPLATE,
|
| 48 |
-
"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
|
| 159 |
try:
|
| 160 |
stored = json.loads(raw or "{}")
|
| 161 |
except ValueError:
|
| 162 |
-
|
| 163 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 202 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|
| 210 |
-
|
| 211 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
await session.commit()
|
| 213 |
-
|
| 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
|
| 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 |
-
|
|
|
|
| 301 |
|
| 302 |
-
async def count_formats(self, uid: int) -> int:
|
| 303 |
-
|
| 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 |
-
|
| 315 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 316 |
async with self._sessions() as session:
|
| 317 |
-
names =
|
| 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=
|
| 326 |
session.add(row)
|
| 327 |
await session.flush()
|
| 328 |
-
fid = row.id
|
| 329 |
await session.commit()
|
| 330 |
-
return _format_dict(fid, name,
|
| 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 |
-
|
| 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 =
|
| 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(
|
|
|
|
|
|
|
| 419 |
"""Give ``uid`` a copy of ``owner_id``'s format (thumbnail included), under a free name.
|
| 420 |
|
| 421 |
-
``
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 428 |
if len(names) >= MAX_FORMATS:
|
| 429 |
raise FormatError("limit")
|
| 430 |
name = _free_name(source.name, names, name_max)
|
| 431 |
-
|
|
|
|
| 432 |
session.add(row)
|
| 433 |
await session.flush()
|
| 434 |
-
new_id,
|
| 435 |
await session.commit()
|
| 436 |
-
return _format_dict(new_id, name,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 6 |
-
file name in bold
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
"""The user's settings with the format's template, caption, metadata and keep lists laid over them.
|
| 42 |
|
| 43 |
-
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
| 45 |
"""
|
|
|
|
| 46 |
out = copy.deepcopy(settings)
|
| 47 |
out["template"] = fmt["template"]
|
| 48 |
-
out["caption"] = fmt.get("caption") or
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 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
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 90 |
-
f"<b>
|
|
|
|
| 91 |
f"<b>Send to:</b> {channel}"
|
| 92 |
)
|
| 93 |
rows: List[Row] = [
|
| 94 |
-
[("
|
| 95 |
-
[("
|
| 96 |
-
[("
|
|
|
|
| 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
|
| 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(
|
|
|
|
| 122 |
text = (
|
| 123 |
-
"<b>CAPTION & THUMBNAIL</b>\n
|
| 124 |
-
f"
|
|
|
|
| 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
|
| 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
|
| 213 |
-
"2. /
|
| 214 |
-
"/
|
| 215 |
-
"
|
| 216 |
-
"
|
| 217 |
-
"
|
| 218 |
-
"
|
| 219 |
-
"
|
| 220 |
-
"
|
| 221 |
-
"
|
| 222 |
-
"
|
| 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
|
|
|
|
| 271 |
text = (
|
| 272 |
-
|
| 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>
|
| 275 |
-
"
|
|
|
|
|
|
|
| 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("
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"] !=
|
|
|
|
|
|
|
|
|
|
| 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. ``
|
| 309 |
-
while inline mode is on for the bot (Telegram refuses the message otherwise), so
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
lines
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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>\
|
| 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 |
-
|
| 350 |
-
lines
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
| 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
|
| 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
|
| 378 |
-
rows = _format_buttons(formats, "rn:p") + [[("⚙️ Manage formats", "
|
| 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 & 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 = (
|
|
|
|
|
|
|
| 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
|
| 158 |
-
|
| 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
|
| 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
|
| 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 =
|
| 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 =
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 170 |
-
assert "
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
|
| 172 |
await say(ctx, OWNER, "/caption <b>{filename}</b> {size}")
|
| 173 |
-
assert (await
|
| 174 |
-
assert "
|
|
|
|
| 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) ==
|
| 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) ==
|
| 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
|
|
|
|
| 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 "
|
| 254 |
-
|
|
|
|
|
|
|
|
|
|
| 255 |
assert stored[:2] == b"\xff\xd8"
|
| 256 |
-
assert await ctx
|
| 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
|
| 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
|
| 276 |
stranger = FakeEvent(STRANGER, message=FakeMessage("", photo=True), photo_bytes=jpeg())
|
| 277 |
await dispatch(ctx.client, stranger)
|
| 278 |
-
assert stranger.out == [] and await
|
| 279 |
|
| 280 |
|
| 281 |
-
async def
|
| 282 |
cb = await click(ctx, OWNER, "th:view")
|
| 283 |
assert cb.alerts and "no thumbnail" in cb.alerts[0]
|
| 284 |
-
await
|
| 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
|
| 290 |
assert cb.toasts == ["Thumbnail deleted."] and not cb.deleted
|
| 291 |
|
| 292 |
-
await
|
| 293 |
cb = await click(ctx, OWNER, "th:rm") # under a standalone thumbnail message
|
| 294 |
-
assert await
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
|
|
|
|
|
|
| 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
|
| 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
|
| 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
|
| 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,
|
| 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
|
| 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 "
|
| 28 |
assert "<b>" not in text and "<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
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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 "<b>" 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
|
| 108 |
await click(ctx, OWNER, "in:caption")
|
| 109 |
await say_formatted(ctx, OWNER, "[b]reset[/b]")
|
| 110 |
-
assert (await
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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 "<b>" not in text and "<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"] == "<b>{filename}</b> <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 "<b>" 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
|
| 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.
|
| 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
|
| 125 |
-
assert (await db.
|
|
|
|
| 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
|
| 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
|
| 148 |
fresh = Database(db.url) # a new instance has no cache
|
| 149 |
try:
|
|
|
|
| 150 |
s1 = await fresh.get_settings(1)
|
| 151 |
-
assert s1["
|
| 152 |
-
|
| 153 |
-
assert
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
assert await fresh.get_settings(4) == DEFAULT_SETTINGS and await fresh.get_settings(5) == DEFAULT_SETTINGS
|
| 155 |
-
await fresh.
|
| 156 |
finally:
|
| 157 |
await fresh.close()
|
| 158 |
await db.init()
|
| 159 |
again = Database(db.url)
|
| 160 |
try:
|
| 161 |
-
assert (await again.
|
|
|
|
| 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
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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="
|
| 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
|
| 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
|
| 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) #
|
| 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
|
| 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>" ==
|
| 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
|
| 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
|
| 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,
|
| 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
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
a, b = await make_format(ctx, "One Piece"), await make_format(ctx, "Dragon Ball")
|
| 33 |
-
|
| 34 |
-
data = data_of(
|
| 35 |
-
assert "(2/12)" in
|
| 36 |
-
|
| 37 |
-
assert "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 48 |
-
|
| 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]) == ["
|
| 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["
|
| 58 |
(chat, text, kw), = ctx.client.edited
|
| 59 |
-
assert kw["message"] == 500 and "created" in text and "🗂 One Piece" in text and
|
|
|
|
| 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, "
|
| 70 |
-
assert OWNER not in ctx.app.pending and "FORMATS" in cb.edits[-1][0]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
|
| 72 |
|
| 73 |
-
|
|
|
|
|
|
|
| 74 |
for i in range(MAX_FORMATS):
|
| 75 |
-
await make_format(ctx, f"F{i}")
|
| 76 |
-
|
| 77 |
-
assert "all 12 formats" in
|
| 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
|
| 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:
|
| 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
|
| 133 |
mine = await ctx.db.get_settings(OWNER)
|
| 134 |
-
assert mine["meta"]["title"] == "" and mine["keep_audio"] == [] and mine["
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 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
|
| 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 "
|
| 216 |
stored = await ctx.db.get_format_thumb(OWNER, i)
|
| 217 |
-
assert stored[:2] == b"\xff\xd8" and await
|
| 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
|
| 224 |
|
| 225 |
|
| 226 |
-
async def
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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
|
| 247 |
fmt = await make_format(ctx)
|
| 248 |
await ctx.db.delete_format(OWNER, fmt["id"])
|
| 249 |
-
for data in (
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 269 |
ev = await say(ctx, ADMIN_CFG, "/formats")
|
| 270 |
-
assert "
|
| 271 |
ev = await say(ctx, ADMIN_CFG, "/rename")
|
| 272 |
-
assert "no
|
| 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
|
| 290 |
ev = await say(ctx, OWNER, "/rename")
|
| 291 |
-
assert "no
|
| 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
|
|
|
|
|
|
|
|
|
|
| 294 |
|
| 295 |
|
| 296 |
-
async def
|
| 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 "
|
| 302 |
-
|
| 303 |
-
assert "One Piece" in
|
| 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
|
| 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
|
| 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
|
| 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 "
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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), "
|
| 467 |
-
"
|
|
|
|
|
|
|
| 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) <=
|
| 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 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 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 |
-
|
| 60 |
-
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
assert len(visible(text)) <= CAPTION_LIMIT, f"{name} is {len(visible(text))} characters"
|
| 63 |
|
| 64 |
|
| 65 |
-
@pytest.mark.parametrize("s
|
| 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(
|
| 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(
|
| 215 |
-
|
| 216 |
-
assert "
|
|
|
|
|
|
|
| 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 "<b>" 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
|
| 325 |
-
await env.db.update_settings(42, metadata_on=False, meta={"title": "
|
| 326 |
-
await
|
| 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
|
| 345 |
-
await
|
| 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\
|
| 352 |
|
| 353 |
|
| 354 |
@needs_ffmpeg
|
| 355 |
-
async def
|
| 356 |
-
await env.db.update_settings(42, metadata_on=True, meta={"title": "
|
| 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",
|
| 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
|
| 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
|
| 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
|
| 395 |
-
await
|
| 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
|
| 406 |
-
await env.db.
|
| 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
|
| 415 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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\
|
| 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
|
| 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
|
| 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
|
| 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
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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
|
| 32 |
-
await apply_setting(db, UID, "template", "{title}")
|
| 33 |
-
|
| 34 |
-
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 239 |
ctx.app._recent[OWNER][1] -= appmod.ONE_SHOT_GRACE + 1
|
| 240 |
await send_photo(ctx, mid=54)
|
| 241 |
-
assert await
|
| 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])[
|
| 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
|
| 174 |
-
await env
|
| 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
|
| 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
|
| 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
|
| 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
|
| 319 |
-
await env
|
| 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):
|