Spaces:
Running
Running
github-actions[bot] commited on
Commit ·
49b8170
1
Parent(s): a173f46
Automated sync from GitHub
Browse files- .env.example +2 -1
- .gitattributes +4 -4
- Dockerfile +1 -15
- bot/app.py +33 -678
- bot/config.py +2 -0
- bot/db.py +1 -242
- bot/formats.py +0 -160
- bot/media.py +0 -87
- bot/panels.py +9 -164
- bot/parser.py +8 -19
- bot/pipeline.py +6 -11
- bot/queue.py +1 -6
- bot/sequence.py +0 -100
- bot/speedtest.py +0 -115
- bot/telegraph.py +0 -146
- bot/utils.py +0 -19
- docs/superpowers/plans/2026-10-20-media-tools-and-formats.md +0 -1523
- docs/superpowers/specs/2026-10-20-media-tools-and-formats-design.md +0 -522
- tests/conftest.py +0 -11
- tests/test_app.py +33 -312
- tests/test_check.py +0 -119
- tests/test_db.py +8 -62
- tests/test_formats.py +0 -67
- tests/test_media.py +0 -41
- tests/test_sequence.py +0 -57
- tests/test_settings_ops.py +9 -1
- tests/test_speedtest.py +0 -51
- tests/test_telegraph.py +0 -44
- tests/tgfakes.py +1 -11
.env.example
CHANGED
|
@@ -15,7 +15,8 @@ DATABASE_URL=postgresql://user:password@host/dbname?sslmode=require
|
|
| 15 |
ADMIN_IDS=
|
| 16 |
# Numeric chat id of a channel/group where the bot posts start-up, job and error notices.
|
| 17 |
LOG_CHANNEL=
|
| 18 |
-
#
|
|
|
|
| 19 |
# Optional Telethon StringSession to log in with (otherwise stored in the database).
|
| 20 |
SESSION_STRING=
|
| 21 |
|
|
|
|
| 15 |
ADMIN_IDS=
|
| 16 |
# Numeric chat id of a channel/group where the bot posts start-up, job and error notices.
|
| 17 |
LOG_CHANNEL=
|
| 18 |
+
# Banner for /start: an https image URL or a file path.
|
| 19 |
+
START_IMAGE=
|
| 20 |
# Optional Telethon StringSession to log in with (otherwise stored in the database).
|
| 21 |
SESSION_STRING=
|
| 22 |
|
.gitattributes
CHANGED
|
@@ -1,8 +1,8 @@
|
|
| 1 |
-
*.mp4 filter=lfs diff=lfs merge=lfs -text
|
| 2 |
-
*.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 3 |
-
*.jpg filter=lfs diff=lfs merge=lfs -text
|
| 4 |
-
*.jpeg filter=lfs diff=lfs merge=lfs -text
|
| 5 |
*.png filter=lfs diff=lfs merge=lfs -text
|
| 6 |
*.ttf filter=lfs diff=lfs merge=lfs -text
|
| 7 |
*.otf filter=lfs diff=lfs merge=lfs -text
|
| 8 |
*.webp filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
*.png filter=lfs diff=lfs merge=lfs -text
|
| 2 |
*.ttf filter=lfs diff=lfs merge=lfs -text
|
| 3 |
*.otf filter=lfs diff=lfs merge=lfs -text
|
| 4 |
*.webp filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.mp4 filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.jpg filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.jpeg filter=lfs diff=lfs merge=lfs -text
|
Dockerfile
CHANGED
|
@@ -2,23 +2,9 @@ FROM python:3.12-slim
|
|
| 2 |
|
| 3 |
# ffmpeg/ffprobe: metadata + track keep/reorder (stream copy, no re-encoding)
|
| 4 |
RUN apt-get update \
|
| 5 |
-
&& apt-get install -y --no-install-recommends ffmpeg ca-certificates
|
| 6 |
&& rm -rf /var/lib/apt/lists/*
|
| 7 |
|
| 8 |
-
# Ookla speedtest CLI: /speed needs it. The version is pinned so a rebuild
|
| 9 |
-
# cannot silently change the tool. The tarball unpacks to a binary called
|
| 10 |
-
# "speedtest"; it is installed as "ookla-speedtest" to match the bot.
|
| 11 |
-
RUN set -eux; \
|
| 12 |
-
case "$(uname -m)" in \
|
| 13 |
-
x86_64) arch=x86_64 ;; \
|
| 14 |
-
aarch64|arm64) arch=aarch64 ;; \
|
| 15 |
-
*) echo "unsupported architecture: $(uname -m)" >&2; exit 1 ;; \
|
| 16 |
-
esac; \
|
| 17 |
-
curl -fsSL --retry 3 "https://install.speedtest.net/app/cli/ookla-speedtest-1.2.0-linux-${arch}.tgz" -o /tmp/ookla.tgz; \
|
| 18 |
-
tar -xzf /tmp/ookla.tgz -C /tmp; \
|
| 19 |
-
install -m 0755 /tmp/speedtest /usr/local/bin/ookla-speedtest; \
|
| 20 |
-
rm -rf /tmp/ookla.tgz /tmp/speedtest
|
| 21 |
-
|
| 22 |
# Hugging Face Spaces run Docker containers as user 1000
|
| 23 |
RUN useradd -m -u 1000 user
|
| 24 |
USER user
|
|
|
|
| 2 |
|
| 3 |
# ffmpeg/ffprobe: metadata + track keep/reorder (stream copy, no re-encoding)
|
| 4 |
RUN apt-get update \
|
| 5 |
+
&& apt-get install -y --no-install-recommends ffmpeg ca-certificates \
|
| 6 |
&& rm -rf /var/lib/apt/lists/*
|
| 7 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
# Hugging Face Spaces run Docker containers as user 1000
|
| 9 |
RUN useradd -m -u 1000 user
|
| 10 |
USER user
|
bot/app.py
CHANGED
|
@@ -17,16 +17,13 @@ import telethon
|
|
| 17 |
from telethon import errors, events, functions, types
|
| 18 |
|
| 19 |
from . import fasttransfer as transfer
|
| 20 |
-
from . import media, panels, sysinfo
|
| 21 |
from .config import Config
|
| 22 |
-
from .db import
|
| 23 |
-
from .
|
| 24 |
-
from .parser import compute_new_name, effective_info, missing_variables, parse_info, split_ext
|
| 25 |
from .pipeline import Renamer, original_name_of, source_ext
|
| 26 |
from .queue import Job, JobQueue
|
| 27 |
-
from .sequence import detect_episodes, format_report, plan_sort
|
| 28 |
from .settings_ops import META_COMMANDS, apply_setting, parse_channel, preview_template
|
| 29 |
-
from .speedtest import format_speed, run_speedtest
|
| 30 |
from .utils import clip, esc, human_size
|
| 31 |
|
| 32 |
log = logging.getLogger(__name__)
|
|
@@ -34,16 +31,7 @@ log = logging.getLogger(__name__)
|
|
| 34 |
COMMANDS = [
|
| 35 |
("start", "Open the main menu"),
|
| 36 |
("help", "How to use the bot"),
|
| 37 |
-
("rename", "
|
| 38 |
-
("formats", "Manage your saved rename formats"),
|
| 39 |
-
("sequence", "Collect files to sort by episode"),
|
| 40 |
-
("sort", "Sort the collected files and send them back"),
|
| 41 |
-
("check", "See what the bot reads from a file"),
|
| 42 |
-
("ethumb", "Extract a thumbnail from a video"),
|
| 43 |
-
("resize", "Resize an image (reply to it)"),
|
| 44 |
-
("speed", "Check the bot's internet speed"),
|
| 45 |
-
("mediainfo", "Full media info of a file, published to telegra.ph"),
|
| 46 |
-
("smedia", "Set the start image or GIF (owner only)"),
|
| 47 |
("mode", "Choose Filename or Caption mode"),
|
| 48 |
("template", "Set the filename template"),
|
| 49 |
("caption", "Set the caption template"),
|
|
@@ -59,18 +47,17 @@ COMMANDS = [
|
|
| 59 |
("status", "Show basic bot uptime, ping, and version information."),
|
| 60 |
("stats", "Show detailed system stats such as users, CPU, RAM, and disk usage."),
|
| 61 |
]
|
| 62 |
-
OWNER_COMMANDS = {"addadmin", "deladmin", "admins"
|
| 63 |
BACK = {
|
| 64 |
"template": "rename", "remove": "rename", "caption": "cap",
|
| 65 |
"mt_title": "meta", "mt_author": "meta", "mt_artist": "meta",
|
| 66 |
"mt_audio": "meta", "mt_subtitle": "meta", "mt_video": "meta",
|
| 67 |
"keep_audio": "tool", "keep_subtitle": "tool",
|
| 68 |
}
|
| 69 |
-
PANEL_NAMES = {"home", "rename", "cap", "meta", "tool", "ftype", "help", "admin"
|
| 70 |
QUEUE_SETTLE = 1.5 # seconds an idle worker waits so files sent together are queued in message order
|
| 71 |
TRACKS_PROBE_BYTES = 12 * 1024 * 1024
|
| 72 |
TRACKS_FULL_LIMIT = 700 * 1024 * 1024
|
| 73 |
-
COPY_DELAY = 0.4 # a short pause so Telegram keeps the copies in order
|
| 74 |
|
| 75 |
|
| 76 |
@dataclass
|
|
@@ -81,7 +68,6 @@ class Pending:
|
|
| 81 |
back: str
|
| 82 |
is_photo: bool
|
| 83 |
expires: float
|
| 84 |
-
format_id: Optional[int] = None
|
| 85 |
|
| 86 |
|
| 87 |
def cmd_pattern(name: str) -> "re.Pattern[str]":
|
|
@@ -103,14 +89,6 @@ def is_renameable(message: Any) -> bool:
|
|
| 103 |
)
|
| 104 |
|
| 105 |
|
| 106 |
-
def is_image(message: Any) -> bool:
|
| 107 |
-
"""A photo, or a document with an image mime type."""
|
| 108 |
-
if getattr(message, "photo", None) is not None:
|
| 109 |
-
return True
|
| 110 |
-
mime = getattr(getattr(message, "file", None), "mime_type", "") or ""
|
| 111 |
-
return mime.startswith("image/")
|
| 112 |
-
|
| 113 |
-
|
| 114 |
class App:
|
| 115 |
PENDING_TTL = 300
|
| 116 |
|
|
@@ -123,9 +101,6 @@ class App:
|
|
| 123 |
self.renamer = Renamer(client, cfg, db, self._log_send)
|
| 124 |
self.queue = JobQueue(self.renamer.process, workers=cfg.max_jobs, on_cancelled=self._on_job_cancelled, settle=QUEUE_SETTLE)
|
| 125 |
self._warned: Set[int] = set()
|
| 126 |
-
self._seq_status: Dict[int, Any] = {}
|
| 127 |
-
self._ethumbs: Dict[int, bytes] = {}
|
| 128 |
-
self._resize_source: Dict[int, bytes] = {}
|
| 129 |
|
| 130 |
# ------------------------------------------------------------------ auth
|
| 131 |
def is_owner(self, uid: Optional[int]) -> bool:
|
|
@@ -220,7 +195,6 @@ class App:
|
|
| 220 |
)
|
| 221 |
add(self._guard(self.on_media), events.NewMessage(incoming=True, func=lambda e: e.is_private and is_renameable(e.message)))
|
| 222 |
add(self._guard(self.on_photo), events.NewMessage(incoming=True, func=lambda e: e.is_private and e.message.photo is not None))
|
| 223 |
-
add(self._guard(self.on_animation), events.NewMessage(incoming=True, func=lambda e: e.is_private and e.message.gif is not None))
|
| 224 |
add(self._guard(self.on_text), events.NewMessage(incoming=True, func=self._wants_text))
|
| 225 |
add(self._guard(self.on_callback), events.CallbackQuery())
|
| 226 |
add(self._on_stranger, events.NewMessage(incoming=True, func=lambda e: e.is_private and not self.is_allowed(e.sender_id)))
|
|
@@ -241,12 +215,6 @@ class App:
|
|
| 241 |
"tracks": self.cmd_tracks, "thumb": self.cmd_thumb, "delthumb": self.cmd_delthumb,
|
| 242 |
"removewords": self.cmd_remove, "setchannel": self.cmd_setchannel, "queue": self.cmd_queue,
|
| 243 |
"cancel": self.cmd_cancel, "status": self.cmd_status, "stats": self.cmd_stats,
|
| 244 |
-
"formats": self.cmd_formats,
|
| 245 |
-
"sequence": self.cmd_sequence, "sort": self.cmd_sort,
|
| 246 |
-
"check": self.cmd_check,
|
| 247 |
-
"ethumb": self.cmd_ethumb, "resize": self.cmd_resize,
|
| 248 |
-
"speed": self.cmd_speed,
|
| 249 |
-
"mediainfo": self.cmd_mediainfo, "smedia": self.cmd_smedia,
|
| 250 |
"addadmin": self.cmd_addadmin, "deladmin": self.cmd_deladmin, "admins": self.cmd_admins,
|
| 251 |
}
|
| 252 |
for command, kind in META_COMMANDS.items():
|
|
@@ -278,8 +246,6 @@ class App:
|
|
| 278 |
return panels.filetype(s)
|
| 279 |
if name == "help":
|
| 280 |
return panels.help_panel()
|
| 281 |
-
if name == "formats":
|
| 282 |
-
return panels.formats_list(await self.db.list_formats(uid), for_rename=False)
|
| 283 |
if name == "admin":
|
| 284 |
return panels.admin(
|
| 285 |
self.cfg.owner_id, sorted(self.admins), self.queue.running, self.queue.waiting, self.cfg.log_channel
|
|
@@ -306,31 +272,16 @@ class App:
|
|
| 306 |
async def cmd_start(self, event: Any) -> None:
|
| 307 |
self.pending.pop(event.sender_id, None)
|
| 308 |
text, rows = await self.panel(event.sender_id, "home")
|
| 309 |
-
|
| 310 |
-
if banner:
|
| 311 |
try:
|
| 312 |
-
await self.
|
|
|
|
|
|
|
| 313 |
return
|
| 314 |
except Exception as exc: # noqa: BLE001
|
| 315 |
-
log.warning("could not send
|
| 316 |
await self._reply(event, text, rows)
|
| 317 |
|
| 318 |
-
async def _send_banner(self, chat_id: int, banner: Dict[str, Any], text: str, rows: List[panels.Row]) -> None:
|
| 319 |
-
is_gif = banner["kind"] == "gif"
|
| 320 |
-
bio = io.BytesIO(banner["data"])
|
| 321 |
-
bio.name = "start.gif" if is_gif else "start.jpg"
|
| 322 |
-
kwargs: Dict[str, Any] = dict(
|
| 323 |
-
caption=text, buttons=panels.to_buttons(rows), parse_mode="html",
|
| 324 |
-
)
|
| 325 |
-
if is_gif:
|
| 326 |
-
# a GIF only animates as a document carrying the animated attribute
|
| 327 |
-
kwargs["force_document"] = True
|
| 328 |
-
kwargs["attributes"] = [
|
| 329 |
-
types.DocumentAttributeFilename("start.gif"),
|
| 330 |
-
types.DocumentAttributeAnimated(),
|
| 331 |
-
]
|
| 332 |
-
await self.client.send_file(chat_id, bio, **kwargs)
|
| 333 |
-
|
| 334 |
async def cmd_help(self, event: Any) -> None:
|
| 335 |
text, _ = panels.help_panel()
|
| 336 |
await self._reply(event, text, [[("✖ Close", "x:close")]])
|
|
@@ -488,39 +439,6 @@ class App:
|
|
| 488 |
await self._reply(event, "You have no thumbnail.")
|
| 489 |
|
| 490 |
async def on_photo(self, event: Any) -> None:
|
| 491 |
-
uid = event.sender_id
|
| 492 |
-
p = self.pending.get(uid)
|
| 493 |
-
if p is not None and p.kind == "smedia_image":
|
| 494 |
-
raw = await event.download_media(bytes)
|
| 495 |
-
if not raw:
|
| 496 |
-
await self._reply(event, "❌ I couldn't download that photo.")
|
| 497 |
-
return
|
| 498 |
-
try:
|
| 499 |
-
data = await asyncio.to_thread(media.prepare_thumb, raw)
|
| 500 |
-
except Exception: # noqa: BLE001
|
| 501 |
-
await self._reply(event, "❌ I couldn't read that image.")
|
| 502 |
-
return
|
| 503 |
-
await self.db.set_start_media("image", data)
|
| 504 |
-
self.pending.pop(uid, None)
|
| 505 |
-
await self._reply(event, "✅ Start image saved.")
|
| 506 |
-
return
|
| 507 |
-
if p is not None and p.kind == "fmt_thumb" and p.format_id is not None:
|
| 508 |
-
raw = await event.download_media(bytes)
|
| 509 |
-
if not raw:
|
| 510 |
-
await self._reply(event, "❌ I couldn't download that photo.")
|
| 511 |
-
return
|
| 512 |
-
try:
|
| 513 |
-
thumb = await asyncio.to_thread(media.prepare_thumb, raw)
|
| 514 |
-
except Exception: # noqa: BLE001
|
| 515 |
-
await self._reply(event, "❌ I couldn't read that image.")
|
| 516 |
-
return
|
| 517 |
-
await self.db.update_format(uid, p.format_id, thumb=thumb)
|
| 518 |
-
self.pending.pop(uid, None)
|
| 519 |
-
text, rows = await self.format_panel(uid, p.format_id)
|
| 520 |
-
await self.client.edit_message(
|
| 521 |
-
p.chat_id, p.msg_id, text, buttons=panels.to_buttons(rows), parse_mode="html"
|
| 522 |
-
)
|
| 523 |
-
return
|
| 524 |
raw = await event.download_media(bytes)
|
| 525 |
if not raw:
|
| 526 |
await self._reply(event, "❌ I couldn't download that photo.")
|
|
@@ -533,162 +451,45 @@ class App:
|
|
| 533 |
await self.db.set_thumb(event.sender_id, thumb)
|
| 534 |
await self._reply(event, "✅ Thumbnail saved. It will be used for the files I send back.", [[("🗑 Delete", "th:rm"), ("✖ Close", "x:close")]])
|
| 535 |
|
| 536 |
-
# --------------------------------------------------------------- sequence
|
| 537 |
-
async def cmd_sequence(self, event: Any) -> None:
|
| 538 |
-
uid = event.sender_id
|
| 539 |
-
seq = await self.db.get_sequence(uid)
|
| 540 |
-
if seq is not None:
|
| 541 |
-
await self._reply(
|
| 542 |
-
event,
|
| 543 |
-
f"🗂 You already have <b>{len(seq['items'])}</b> file(s) collected.\n"
|
| 544 |
-
"Send <code>/sort</code> to sort them, or tap Cancel.",
|
| 545 |
-
[[("✖ Cancel", "sq:cancel")]],
|
| 546 |
-
)
|
| 547 |
-
return
|
| 548 |
-
await self.db.set_sequence(uid, [])
|
| 549 |
-
status = await self._reply(
|
| 550 |
-
event,
|
| 551 |
-
"🗂 <b>Send me the files to sort.</b>\n\n"
|
| 552 |
-
"I'll work out the episode of each one and send them back in order.\n"
|
| 553 |
-
"When you're done, send <code>/sort</code>.",
|
| 554 |
-
[[("✖ Cancel", "sq:cancel")]],
|
| 555 |
-
)
|
| 556 |
-
self._seq_status[uid] = status
|
| 557 |
-
|
| 558 |
-
async def cmd_sort(self, event: Any) -> None:
|
| 559 |
-
uid = event.sender_id
|
| 560 |
-
seq = await self.db.get_sequence(uid)
|
| 561 |
-
if seq is None or not seq["items"]:
|
| 562 |
-
await self.db.clear_sequence(uid)
|
| 563 |
-
self._seq_status.pop(uid, None)
|
| 564 |
-
await self._reply(event, "You have no files to sort. Send <code>/sequence</code> first.")
|
| 565 |
-
return
|
| 566 |
-
status = await self._reply(event, "🔀 Sorting…")
|
| 567 |
-
settings = await self.db.get_settings(uid)
|
| 568 |
-
result = plan_sort(detect_episodes(seq["items"], settings["mode"]))
|
| 569 |
-
copied = int(seq.get("copied") or 0)
|
| 570 |
-
for index, item in enumerate(result.ordered):
|
| 571 |
-
if index < copied:
|
| 572 |
-
continue
|
| 573 |
-
try:
|
| 574 |
-
await self._copy_one(event.chat_id, item["msg_id"])
|
| 575 |
-
except Exception as exc: # noqa: BLE001
|
| 576 |
-
await self.client.edit_message(
|
| 577 |
-
status,
|
| 578 |
-
f"❌ <b>Stopped after {copied} file(s).</b>\n"
|
| 579 |
-
f"{esc(clip(str(exc), 200))}\n\nSend <code>/sort</code> to try the rest.",
|
| 580 |
-
parse_mode="html",
|
| 581 |
-
)
|
| 582 |
-
return
|
| 583 |
-
copied = index + 1
|
| 584 |
-
await self.db.set_sequence(uid, seq["items"], copied=copied)
|
| 585 |
-
await asyncio.sleep(COPY_DELAY)
|
| 586 |
-
await self.db.clear_sequence(uid)
|
| 587 |
-
self._seq_status.pop(uid, None)
|
| 588 |
-
await self.client.edit_message(status, format_report(result, len(result.ordered)), parse_mode="html")
|
| 589 |
-
|
| 590 |
-
async def _copy_one(self, chat_id: int, msg_id: int) -> None:
|
| 591 |
-
"""Send one message back into the chat, without a "forwarded from" header.
|
| 592 |
-
|
| 593 |
-
Telethon has no true copy (that is Bot-API only), so this forwards the
|
| 594 |
-
message with ``drop_author`` — the attribution line is dropped, so the
|
| 595 |
-
file reads as if the bot had posted it. Waiting out a flood wait.
|
| 596 |
-
"""
|
| 597 |
-
try:
|
| 598 |
-
await self.client.forward_messages(chat_id, [msg_id], chat_id, drop_author=True)
|
| 599 |
-
except errors.FloodWaitError as exc:
|
| 600 |
-
await asyncio.sleep(exc.seconds)
|
| 601 |
-
await self.client.forward_messages(chat_id, [msg_id], chat_id, drop_author=True)
|
| 602 |
-
|
| 603 |
-
async def _collect_file(self, event: Any) -> None:
|
| 604 |
-
"""Add an incoming file to the open sequence instead of renaming it."""
|
| 605 |
-
uid = event.sender_id
|
| 606 |
-
msg = event.message
|
| 607 |
-
seq = await self.db.get_sequence(uid) or {"items": [], "copied": 0}
|
| 608 |
-
items = list(seq["items"])
|
| 609 |
-
items.append({"msg_id": msg.id, "name": original_name_of(msg), "caption": msg.raw_text or ""})
|
| 610 |
-
await self.db.set_sequence(uid, items, copied=int(seq.get("copied") or 0))
|
| 611 |
-
text = (
|
| 612 |
-
f"🗂 <b>{len(items)}</b> file(s) collected.\n"
|
| 613 |
-
"Send <code>/sort</code> when you're done."
|
| 614 |
-
)
|
| 615 |
-
status = self._seq_status.get(uid)
|
| 616 |
-
if status is not None:
|
| 617 |
-
try:
|
| 618 |
-
await self.client.edit_message(status, text, parse_mode="html")
|
| 619 |
-
return
|
| 620 |
-
except Exception: # noqa: BLE001
|
| 621 |
-
self._seq_status.pop(uid, None)
|
| 622 |
-
self._seq_status[uid] = await self._reply(event, text)
|
| 623 |
-
|
| 624 |
# ------------------------------------------------------------ rename flow
|
| 625 |
async def cmd_rename(self, event: Any) -> None:
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
"❌ /rename doesn't take a name any more.\nPick a format, then send me the file.",
|
| 631 |
-
)
|
| 632 |
return
|
| 633 |
-
|
| 634 |
-
text, rows = panels.formats_list(formats, for_rename=True)
|
| 635 |
-
await self._reply(event, text, rows)
|
| 636 |
-
|
| 637 |
-
async def cmd_formats(self, event: Any) -> None:
|
| 638 |
-
formats = await self.db.list_formats(event.sender_id)
|
| 639 |
-
text, rows = panels.formats_list(formats, for_rename=False)
|
| 640 |
-
await self._reply(event, text, rows)
|
| 641 |
-
|
| 642 |
-
async def format_panel(self, uid: int, fmt_id: int) -> panels.Panel:
|
| 643 |
-
fmt = await self.db.get_format(uid, fmt_id)
|
| 644 |
-
if fmt is None:
|
| 645 |
-
return panels.formats_list(await self.db.list_formats(uid), for_rename=False)
|
| 646 |
-
return panels.format_editor(fmt)
|
| 647 |
|
| 648 |
async def on_media(self, event: Any) -> None:
|
| 649 |
-
|
| 650 |
-
p = self.pending.get(uid)
|
| 651 |
-
fmt = None
|
| 652 |
-
if p is not None and p.kind == "rename_format" and p.format_id is not None:
|
| 653 |
-
fmt = await self.db.get_format(uid, p.format_id)
|
| 654 |
-
self.pending.pop(uid, None)
|
| 655 |
-
if fmt is None:
|
| 656 |
-
await self._reply(event, "❌ That format is gone — pick another one with /rename.")
|
| 657 |
-
return
|
| 658 |
-
elif await self.db.get_sequence(uid) is not None:
|
| 659 |
-
await self._collect_file(event)
|
| 660 |
-
return
|
| 661 |
-
await self.enqueue(event, event.message, fmt=fmt)
|
| 662 |
|
| 663 |
-
async def enqueue(self, event: Any, msg: Any,
|
| 664 |
uid = event.sender_id
|
| 665 |
size = int(msg.file.size or 0) if msg.file is not None else 0
|
| 666 |
if size > self.cfg.max_file_bytes:
|
| 667 |
await self._reply(event, f"❌ That file is {human_size(size)}; the limit is {human_size(self.cfg.max_file_bytes)}.")
|
| 668 |
return
|
| 669 |
s = await self.db.get_settings(uid)
|
| 670 |
-
thumb_mode, thumb = "auto", None
|
| 671 |
-
if fmt is not None:
|
| 672 |
-
s = resolve_format(fmt, s)
|
| 673 |
-
thumb_mode, thumb = resolve_thumbnail(fmt, await self.db.get_thumb(uid))
|
| 674 |
orig = original_name_of(msg)
|
| 675 |
ext = source_ext(msg, orig)
|
| 676 |
notes: List[str] = []
|
| 677 |
-
|
| 678 |
-
|
| 679 |
-
|
| 680 |
-
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
where_from = "caption" if s["mode"] == "caption" else "file name"
|
| 684 |
-
notes.append(
|
| 685 |
-
"Couldn't find " + ", ".join("{%s}" % g for g in gaps) + f" in the {where_from}, so it is left empty. "
|
| 686 |
-
"Cancel and use /rename, or change the template."
|
| 687 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 688 |
job = Job(
|
| 689 |
id=self.queue.new_id(), user_id=uid, chat_id=event.chat_id, msg_id=msg.id,
|
| 690 |
-
file_name=orig, size=size, new_name=new_name, notes=notes, order=event.id,
|
| 691 |
-
settings_override=s, thumbnail_mode=thumb_mode, thumbnail=thumb,
|
| 692 |
)
|
| 693 |
ahead = self.queue.running + self.queue.waiting
|
| 694 |
where = "starting soon" if ahead == 0 else f"{ahead} ahead of it"
|
|
@@ -735,179 +536,6 @@ class App:
|
|
| 735 |
count = self.queue.cancel_all() if (arg == "all" and self.is_owner(uid)) else self.queue.cancel_user(uid)
|
| 736 |
await self._reply(event, f"🛑 Cancelling {count} job(s)…" if count else "You have no active jobs.")
|
| 737 |
|
| 738 |
-
# ------------------------------------------------------------------ check
|
| 739 |
-
async def cmd_check(self, event: Any) -> None:
|
| 740 |
-
reply = await event.get_reply_message()
|
| 741 |
-
if reply is None or not is_renameable(reply):
|
| 742 |
-
await self._reply(event, "Reply to a file with <code>/check</code> to see what I can read from it.")
|
| 743 |
-
return
|
| 744 |
-
name = original_name_of(reply)
|
| 745 |
-
stem, _ext = split_ext(name)
|
| 746 |
-
file_info = parse_info(stem)
|
| 747 |
-
caption_info = effective_info(stem, reply.raw_text or "", "caption")
|
| 748 |
-
await self._reply(event, panels.check_report(file_info, caption_info))
|
| 749 |
-
|
| 750 |
-
# ------------------------------------------------------------- thumbnails
|
| 751 |
-
async def cmd_ethumb(self, event: Any) -> None:
|
| 752 |
-
reply = await event.get_reply_message()
|
| 753 |
-
if reply is None or not is_renameable(reply):
|
| 754 |
-
await self._reply(event, "Reply to a video or file with <code>/ethumb</code> to take a thumbnail from it.")
|
| 755 |
-
return
|
| 756 |
-
status = await self._reply(event, "🔎 Looking for a frame…")
|
| 757 |
-
name = original_name_of(reply)
|
| 758 |
-
ext = source_ext(reply, name)
|
| 759 |
-
tmp_dir = Path(self.cfg.temp_dir) / f"ethumb{event.chat_id}_{event.id}"
|
| 760 |
-
tmp_dir.mkdir(parents=True, exist_ok=True)
|
| 761 |
-
try:
|
| 762 |
-
path = tmp_dir / f"src{ext}"
|
| 763 |
-
await transfer.download(self.client, reply, path, fast=self.cfg.fast_transfer, connections=2)
|
| 764 |
-
try:
|
| 765 |
-
info = await media.probe(path)
|
| 766 |
-
except media.MediaError:
|
| 767 |
-
info = None
|
| 768 |
-
if info is None or not info.of("video"):
|
| 769 |
-
await self.client.edit_message(
|
| 770 |
-
status, "❌ That file has no video — I can't take a thumbnail from it.", parse_mode="html"
|
| 771 |
-
)
|
| 772 |
-
return
|
| 773 |
-
frame = tmp_dir / "thumb.jpg"
|
| 774 |
-
if not await media.extract_frame(path, frame, info.duration):
|
| 775 |
-
await self.client.edit_message(
|
| 776 |
-
status, "❌ I couldn't grab a frame from that file.", parse_mode="html"
|
| 777 |
-
)
|
| 778 |
-
return
|
| 779 |
-
data = frame.read_bytes()
|
| 780 |
-
self._ethumbs[event.sender_id] = data
|
| 781 |
-
await self.client.edit_message(status, "🖼 Here's the frame:", parse_mode="html")
|
| 782 |
-
await self.client.send_file(
|
| 783 |
-
event.chat_id, io.BytesIO(data), caption="Extracted frame",
|
| 784 |
-
buttons=panels.to_buttons([[("Set as my thumbnail", "eth:set"), ("✖ Close", "x:close")]]),
|
| 785 |
-
)
|
| 786 |
-
finally:
|
| 787 |
-
shutil.rmtree(tmp_dir, ignore_errors=True)
|
| 788 |
-
|
| 789 |
-
async def cmd_resize(self, event: Any) -> None:
|
| 790 |
-
reply = await event.get_reply_message()
|
| 791 |
-
data = None
|
| 792 |
-
if reply is not None and is_image(reply):
|
| 793 |
-
data = await reply.download_media(bytes)
|
| 794 |
-
if not data:
|
| 795 |
-
await self._reply(event, "Reply to an image with <code>/resize</code>.")
|
| 796 |
-
return
|
| 797 |
-
self._resize_source[event.sender_id] = data
|
| 798 |
-
await self._reply(
|
| 799 |
-
event,
|
| 800 |
-
"🖼 How big should it be?",
|
| 801 |
-
[
|
| 802 |
-
[("320", "rz:320"), ("640", "rz:640"), ("1280", "rz:1280")],
|
| 803 |
-
[("Original", "rz:orig"), ("Custom", "rz:custom")],
|
| 804 |
-
[("✖ Close", "x:close")],
|
| 805 |
-
],
|
| 806 |
-
)
|
| 807 |
-
|
| 808 |
-
async def _cb_resize(self, event: Any, uid: int, size: str) -> None:
|
| 809 |
-
source = self._resize_source.get(uid)
|
| 810 |
-
if source is None:
|
| 811 |
-
await event.answer("That image is gone — run /resize again.", alert=True)
|
| 812 |
-
return
|
| 813 |
-
if size == "custom":
|
| 814 |
-
self.pending[uid] = Pending(
|
| 815 |
-
kind="resize_size", chat_id=event.chat_id, msg_id=event.message_id,
|
| 816 |
-
back="formats", is_photo=False, expires=time.time() + self.PENDING_TTL,
|
| 817 |
-
)
|
| 818 |
-
await event.answer()
|
| 819 |
-
await self._edit_panel(
|
| 820 |
-
event,
|
| 821 |
-
"Send the size: a number for the width (<code>800</code>) or <code>800x450</code>.",
|
| 822 |
-
[[("✖ Cancel", "x:close")]],
|
| 823 |
-
)
|
| 824 |
-
return
|
| 825 |
-
try:
|
| 826 |
-
data = source if size == "orig" else media.resize_image(source, width=int(size))
|
| 827 |
-
except Exception as exc: # noqa: BLE001
|
| 828 |
-
await event.answer(f"I couldn't resize that: {exc}", alert=True)
|
| 829 |
-
return
|
| 830 |
-
await self._send_resized(event, uid, data)
|
| 831 |
-
|
| 832 |
-
async def _send_resized(self, event: Any, uid: int, data: bytes) -> None:
|
| 833 |
-
self._ethumbs[uid] = data
|
| 834 |
-
await self.client.send_file(
|
| 835 |
-
event.chat_id, io.BytesIO(data), caption="Resized image",
|
| 836 |
-
buttons=panels.to_buttons([[("Set as my thumbnail", "eth:set"), ("✖ Close", "x:close")]]),
|
| 837 |
-
)
|
| 838 |
-
await event.answer()
|
| 839 |
-
|
| 840 |
-
# --------------------------------------------------------------- mediainfo
|
| 841 |
-
async def cmd_mediainfo(self, event: Any) -> None:
|
| 842 |
-
reply = await event.get_reply_message()
|
| 843 |
-
if reply is None or not is_renameable(reply):
|
| 844 |
-
await self._reply(event, "Reply to a file with <code>/mediainfo</code> to see everything about it.")
|
| 845 |
-
return
|
| 846 |
-
status = await self._reply(event, "🔎 Reading the file…")
|
| 847 |
-
name = original_name_of(reply)
|
| 848 |
-
ext = source_ext(reply, name)
|
| 849 |
-
tmp_dir = Path(self.cfg.temp_dir) / f"mi{event.chat_id}_{event.id}"
|
| 850 |
-
tmp_dir.mkdir(parents=True, exist_ok=True)
|
| 851 |
-
try:
|
| 852 |
-
path = tmp_dir / f"src{ext}"
|
| 853 |
-
await transfer.download(
|
| 854 |
-
self.client, reply, path, fast=self.cfg.fast_transfer, connections=self.cfg.connections
|
| 855 |
-
)
|
| 856 |
-
probe = await media.probe_full(path)
|
| 857 |
-
url = await self._publish_media_info(probe, name)
|
| 858 |
-
if url:
|
| 859 |
-
await self.client.edit_message(
|
| 860 |
-
status, f"📋 <b>Media info</b>\n<a href=\"{esc(url)}\">open the report</a>", parse_mode="html"
|
| 861 |
-
)
|
| 862 |
-
return
|
| 863 |
-
summary = clip(telegraph.build_summary(probe, name), 3500)
|
| 864 |
-
await self.client.edit_message(
|
| 865 |
-
status, f"📋 <b>Media info</b> (telegra.ph is unreachable, so here it is)\n\n{esc(summary)}",
|
| 866 |
-
parse_mode="html",
|
| 867 |
-
)
|
| 868 |
-
except Exception as exc: # noqa: BLE001
|
| 869 |
-
await self.client.edit_message(
|
| 870 |
-
status, f"❌ I couldn't read that file: {esc(clip(str(exc), 200))}", parse_mode="html"
|
| 871 |
-
)
|
| 872 |
-
finally:
|
| 873 |
-
shutil.rmtree(tmp_dir, ignore_errors=True)
|
| 874 |
-
|
| 875 |
-
async def _publish_media_info(self, probe: Dict[str, Any], name: str) -> str:
|
| 876 |
-
"""Upload the report to telegra.ph and return its URL ('' when it fails)."""
|
| 877 |
-
try:
|
| 878 |
-
token = await self.db.kv_get("telegraph_token")
|
| 879 |
-
if not token:
|
| 880 |
-
token = await telegraph.Telegraph().create_account()
|
| 881 |
-
if token:
|
| 882 |
-
await self.db.kv_set("telegraph_token", token)
|
| 883 |
-
if not token:
|
| 884 |
-
return ""
|
| 885 |
-
page = telegraph.build_page(probe, name)
|
| 886 |
-
return await telegraph.Telegraph(token).upload_page(f"Media info — {name}", [page])
|
| 887 |
-
except Exception as exc: # noqa: BLE001
|
| 888 |
-
log.warning("telegra.ph upload failed: %s", exc)
|
| 889 |
-
return ""
|
| 890 |
-
|
| 891 |
-
# ------------------------------------------------------------------ speed
|
| 892 |
-
async def cmd_speed(self, event: Any) -> None:
|
| 893 |
-
status = await self._reply(event, "📡 Testing your connection…")
|
| 894 |
-
try:
|
| 895 |
-
result = await run_speedtest()
|
| 896 |
-
except FileNotFoundError:
|
| 897 |
-
await self.client.edit_message(
|
| 898 |
-
status, "❌ The speedtest tool isn't installed in this container.", parse_mode="html"
|
| 899 |
-
)
|
| 900 |
-
return
|
| 901 |
-
except asyncio.TimeoutError:
|
| 902 |
-
await self.client.edit_message(status, "❌ The speed test timed out.", parse_mode="html")
|
| 903 |
-
return
|
| 904 |
-
except Exception as exc: # noqa: BLE001
|
| 905 |
-
await self.client.edit_message(
|
| 906 |
-
status, f"❌ The speed test failed: {esc(clip(str(exc), 200))}", parse_mode="html"
|
| 907 |
-
)
|
| 908 |
-
return
|
| 909 |
-
await self.client.edit_message(status, format_speed(result), parse_mode="html")
|
| 910 |
-
|
| 911 |
# ------------------------------------------------------------------ tracks
|
| 912 |
async def cmd_tracks(self, event: Any) -> None:
|
| 913 |
reply = await event.get_reply_message()
|
|
@@ -1002,15 +630,6 @@ class App:
|
|
| 1002 |
if p is None or p.expires < time.time():
|
| 1003 |
self.pending.pop(uid, None)
|
| 1004 |
return
|
| 1005 |
-
if p.kind == "rename_format":
|
| 1006 |
-
await self._reply(event, "Send me the <b>file</b> to rename, not text.")
|
| 1007 |
-
return
|
| 1008 |
-
if p.kind == "resize_size":
|
| 1009 |
-
await self._on_resize_text(event, uid, p)
|
| 1010 |
-
return
|
| 1011 |
-
if p.format_id is not None or p.kind == "fmt_name":
|
| 1012 |
-
await self._on_format_text(event, uid, p)
|
| 1013 |
-
return
|
| 1014 |
ok, msg = await apply_setting(self.db, uid, p.kind, event.raw_text)
|
| 1015 |
if not ok:
|
| 1016 |
await self._reply(event, msg + "\n\nTry again, or tap Cancel.")
|
|
@@ -1028,179 +647,7 @@ class App:
|
|
| 1028 |
except Exception: # noqa: BLE001
|
| 1029 |
await self._reply(event, full, rows)
|
| 1030 |
|
| 1031 |
-
async def _on_resize_text(self, event: Any, uid: int, p: Pending) -> None:
|
| 1032 |
-
"""Handle the size the user typed for a custom /resize."""
|
| 1033 |
-
width, height, error = media.parse_size(event.raw_text or "")
|
| 1034 |
-
if error:
|
| 1035 |
-
await self._reply(event, "❌ " + error)
|
| 1036 |
-
return
|
| 1037 |
-
source = self._resize_source.get(uid)
|
| 1038 |
-
if not source:
|
| 1039 |
-
self.pending.pop(uid, None)
|
| 1040 |
-
await self._reply(event, "❌ That image is gone — run /resize again.")
|
| 1041 |
-
return
|
| 1042 |
-
try:
|
| 1043 |
-
data = media.resize_image(source, width=width, height=height)
|
| 1044 |
-
except Exception as exc: # noqa: BLE001
|
| 1045 |
-
await self._reply(event, f"❌ I couldn't resize that: {esc(clip(str(exc), 120))}")
|
| 1046 |
-
return
|
| 1047 |
-
self.pending.pop(uid, None)
|
| 1048 |
-
self._ethumbs[uid] = data
|
| 1049 |
-
await self.client.send_file(
|
| 1050 |
-
event.chat_id, io.BytesIO(data), caption="Resized image",
|
| 1051 |
-
buttons=panels.to_buttons([[("Set as my thumbnail", "eth:set"), ("✖ Close", "x:close")]]),
|
| 1052 |
-
)
|
| 1053 |
-
|
| 1054 |
-
# ------------------------------------------------------------------ smedia
|
| 1055 |
-
async def cmd_smedia(self, event: Any) -> None:
|
| 1056 |
-
banner = await self.db.get_start_media()
|
| 1057 |
-
kind = banner["kind"] if banner else None
|
| 1058 |
-
text, rows = panels.smedia_panel(kind, banner is not None)
|
| 1059 |
-
await self._reply(event, text, rows)
|
| 1060 |
-
|
| 1061 |
-
async def on_animation(self, event: Any) -> None:
|
| 1062 |
-
"""Catches a GIF sent while the owner is setting the start screen."""
|
| 1063 |
-
uid = event.sender_id
|
| 1064 |
-
p = self.pending.get(uid)
|
| 1065 |
-
if p is None or p.kind != "smedia_gif":
|
| 1066 |
-
return
|
| 1067 |
-
raw = await event.download_media(bytes)
|
| 1068 |
-
if not raw:
|
| 1069 |
-
await self._reply(event, "❌ I couldn't download that GIF.")
|
| 1070 |
-
return
|
| 1071 |
-
await self.db.set_start_media("gif", raw)
|
| 1072 |
-
self.pending.pop(uid, None)
|
| 1073 |
-
await self._reply(event, "✅ Start GIF saved.")
|
| 1074 |
-
|
| 1075 |
# ---------------------------------------------------------------- callbacks
|
| 1076 |
-
async def _on_format_text(self, event: Any, uid: int, p: Pending) -> None:
|
| 1077 |
-
"""Handle a text reply that fills in one field of a saved format."""
|
| 1078 |
-
text = (event.raw_text or "").strip()
|
| 1079 |
-
fmt_id = p.format_id
|
| 1080 |
-
if p.kind == "fmt_name" and fmt_id is None:
|
| 1081 |
-
created = await self.db.create_format(uid, text)
|
| 1082 |
-
if created is None:
|
| 1083 |
-
await self._reply(event, "❌ That name won't do — send a different one.")
|
| 1084 |
-
return
|
| 1085 |
-
fmt_id = created["id"]
|
| 1086 |
-
message = f"✅ Created <b>{esc(created['name'])}</b>."
|
| 1087 |
-
else:
|
| 1088 |
-
ok, message = await apply_format_field(self.db, uid, fmt_id, p.kind, text)
|
| 1089 |
-
if not ok:
|
| 1090 |
-
await self._reply(event, message + "\n\nTry again, or tap Cancel.")
|
| 1091 |
-
return
|
| 1092 |
-
self.pending.pop(uid, None)
|
| 1093 |
-
try:
|
| 1094 |
-
await event.delete()
|
| 1095 |
-
except Exception: # noqa: BLE001
|
| 1096 |
-
pass
|
| 1097 |
-
panel_text, rows = await self.format_panel(uid, fmt_id)
|
| 1098 |
-
full = (
|
| 1099 |
-
f"{message}\n\n{panel_text}"
|
| 1100 |
-
if visible_len(message) + visible_len(panel_text) < 3900
|
| 1101 |
-
else f"{message}\n\n(Open the menu to see the update.)"
|
| 1102 |
-
)
|
| 1103 |
-
try:
|
| 1104 |
-
await self.client.edit_message(
|
| 1105 |
-
p.chat_id, p.msg_id, full, buttons=panels.to_buttons(rows), parse_mode="html"
|
| 1106 |
-
)
|
| 1107 |
-
except Exception: # noqa: BLE001
|
| 1108 |
-
await self._reply(event, full, rows)
|
| 1109 |
-
|
| 1110 |
-
async def _cb_use_format(self, event: Any, uid: int, fmt_id: int) -> None:
|
| 1111 |
-
fmt = await self.db.get_format(uid, fmt_id)
|
| 1112 |
-
if fmt is None:
|
| 1113 |
-
await event.answer("That format is gone.", alert=True)
|
| 1114 |
-
return
|
| 1115 |
-
self.pending[uid] = Pending(
|
| 1116 |
-
kind="rename_format", chat_id=event.chat_id, msg_id=event.message_id,
|
| 1117 |
-
back="formats", is_photo=False, expires=time.time() + self.PENDING_TTL, format_id=fmt_id,
|
| 1118 |
-
)
|
| 1119 |
-
await event.answer()
|
| 1120 |
-
await self._edit_panel(
|
| 1121 |
-
event, f"✅ <b>{esc(fmt['name'])}</b>\n\nSend me the file to rename with it.", [[("✖ Close", "x:close")]]
|
| 1122 |
-
)
|
| 1123 |
-
|
| 1124 |
-
async def _cb_open_format(self, event: Any, uid: int, fmt_id: int) -> None:
|
| 1125 |
-
text, rows = await self.format_panel(uid, fmt_id)
|
| 1126 |
-
await self._edit_panel(event, text, rows)
|
| 1127 |
-
await event.answer()
|
| 1128 |
-
|
| 1129 |
-
async def _cb_new_format(self, event: Any, uid: int) -> None:
|
| 1130 |
-
if await self.db.count_formats(uid) >= MAX_FORMATS:
|
| 1131 |
-
await event.answer(f"You already have {MAX_FORMATS} formats.", alert=True)
|
| 1132 |
-
return
|
| 1133 |
-
self.pending[uid] = Pending(
|
| 1134 |
-
kind="fmt_name", chat_id=event.chat_id, msg_id=event.message_id,
|
| 1135 |
-
back="formats", is_photo=False, expires=time.time() + self.PENDING_TTL,
|
| 1136 |
-
)
|
| 1137 |
-
await event.answer()
|
| 1138 |
-
await self._edit_panel(
|
| 1139 |
-
event, panels.FORMAT_PROMPTS["fmt_name"], [[("✖ Cancel", "x:close")]]
|
| 1140 |
-
)
|
| 1141 |
-
|
| 1142 |
-
async def _cb_set_format_field(self, event: Any, uid: int, kind: str, fmt_id: int) -> None:
|
| 1143 |
-
if await self.db.get_format(uid, fmt_id) is None:
|
| 1144 |
-
await event.answer("That format is gone.", alert=True)
|
| 1145 |
-
return
|
| 1146 |
-
self.pending[uid] = Pending(
|
| 1147 |
-
kind=kind, chat_id=event.chat_id, msg_id=event.message_id,
|
| 1148 |
-
back="formats", is_photo=False, expires=time.time() + self.PENDING_TTL, format_id=fmt_id,
|
| 1149 |
-
)
|
| 1150 |
-
await event.answer()
|
| 1151 |
-
await self._edit_panel(
|
| 1152 |
-
event, panels.FORMAT_PROMPTS[kind], [[("✖ Cancel", "x:close")]]
|
| 1153 |
-
)
|
| 1154 |
-
|
| 1155 |
-
async def _cb_format_thumb(self, event: Any, uid: int, action: str, fmt_id: int) -> None:
|
| 1156 |
-
if await self.db.get_format(uid, fmt_id) is None:
|
| 1157 |
-
await event.answer("That format is gone.", alert=True)
|
| 1158 |
-
return
|
| 1159 |
-
if action == "fthumb":
|
| 1160 |
-
self.pending[uid] = Pending(
|
| 1161 |
-
kind="fmt_thumb", chat_id=event.chat_id, msg_id=event.message_id,
|
| 1162 |
-
back="formats", is_photo=True, expires=time.time() + self.PENDING_TTL, format_id=fmt_id,
|
| 1163 |
-
)
|
| 1164 |
-
await event.answer()
|
| 1165 |
-
await self._edit_panel(
|
| 1166 |
-
event, "Send me the photo to use as this format's thumbnail.", [[("✖ Cancel", "x:close")]]
|
| 1167 |
-
)
|
| 1168 |
-
return
|
| 1169 |
-
if action == "fthumbdef":
|
| 1170 |
-
await self.db.update_format(uid, fmt_id, thumb=None)
|
| 1171 |
-
toast = "Thumbnail back to your default."
|
| 1172 |
-
else:
|
| 1173 |
-
await self.db.update_format(uid, fmt_id, thumb=b"")
|
| 1174 |
-
toast = "No thumbnail for this format."
|
| 1175 |
-
text, rows = await self.format_panel(uid, fmt_id)
|
| 1176 |
-
await self._edit_panel(event, text, rows)
|
| 1177 |
-
await event.answer(toast)
|
| 1178 |
-
|
| 1179 |
-
async def _cb_rename_format(self, event: Any, uid: int, fmt_id: int) -> None:
|
| 1180 |
-
if await self.db.get_format(uid, fmt_id) is None:
|
| 1181 |
-
await event.answer("That format is gone.", alert=True)
|
| 1182 |
-
return
|
| 1183 |
-
self.pending[uid] = Pending(
|
| 1184 |
-
kind="fmt_name", chat_id=event.chat_id, msg_id=event.message_id,
|
| 1185 |
-
back="formats", is_photo=False, expires=time.time() + self.PENDING_TTL, format_id=fmt_id,
|
| 1186 |
-
)
|
| 1187 |
-
await event.answer()
|
| 1188 |
-
await self._edit_panel(
|
| 1189 |
-
event, panels.FORMAT_PROMPTS["fmt_name"], [[("✖ Cancel", "x:close")]]
|
| 1190 |
-
)
|
| 1191 |
-
|
| 1192 |
-
async def _cb_delete_format(self, event: Any, uid: int, fmt_id: int) -> None:
|
| 1193 |
-
fmt = await self.db.get_format(uid, fmt_id)
|
| 1194 |
-
if fmt is None:
|
| 1195 |
-
await event.answer("That format is gone.", alert=True)
|
| 1196 |
-
return
|
| 1197 |
-
await self._edit_panel(
|
| 1198 |
-
event,
|
| 1199 |
-
f"🗑 Delete <b>{esc(fmt['name'])}</b>?",
|
| 1200 |
-
[[("Yes, delete it", f"fdelok:{fmt_id}"), ("✖ Keep it", f"fl:{fmt_id}")]],
|
| 1201 |
-
)
|
| 1202 |
-
await event.answer()
|
| 1203 |
-
|
| 1204 |
async def on_callback(self, event: Any) -> None:
|
| 1205 |
uid = event.sender_id
|
| 1206 |
data = event.data.decode("utf-8", "ignore")
|
|
@@ -1213,62 +660,6 @@ class App:
|
|
| 1213 |
await self._remove_message(event)
|
| 1214 |
return
|
| 1215 |
|
| 1216 |
-
if data == "sq:cancel":
|
| 1217 |
-
await self.db.clear_sequence(uid)
|
| 1218 |
-
self._seq_status.pop(uid, None)
|
| 1219 |
-
await event.answer("Sequence cancelled.")
|
| 1220 |
-
await self._remove_message(event)
|
| 1221 |
-
return
|
| 1222 |
-
|
| 1223 |
-
if data == "eth:set":
|
| 1224 |
-
data_bytes = self._ethumbs.get(uid)
|
| 1225 |
-
if not data_bytes:
|
| 1226 |
-
await event.answer("That image is gone — run /ethumb or /resize again.", alert=True)
|
| 1227 |
-
return
|
| 1228 |
-
try:
|
| 1229 |
-
thumb = await asyncio.to_thread(media.prepare_thumb, data_bytes)
|
| 1230 |
-
except Exception: # noqa: BLE001
|
| 1231 |
-
await event.answer("I couldn't read that image.", alert=True)
|
| 1232 |
-
return
|
| 1233 |
-
await self.db.set_thumb(uid, thumb)
|
| 1234 |
-
await event.answer("Thumbnail saved.")
|
| 1235 |
-
return
|
| 1236 |
-
|
| 1237 |
-
if head == "rz" and len(parts) == 2 and parts[1]:
|
| 1238 |
-
await self._cb_resize(event, uid, parts[1])
|
| 1239 |
-
return
|
| 1240 |
-
|
| 1241 |
-
if data in ("smedia:image", "smedia:gif"):
|
| 1242 |
-
kind = "smedia_image" if data.endswith("image") else "smedia_gif"
|
| 1243 |
-
self.pending[uid] = Pending(
|
| 1244 |
-
kind=kind, chat_id=event.chat_id, msg_id=event.message_id,
|
| 1245 |
-
back="home", is_photo=kind == "smedia_image",
|
| 1246 |
-
expires=time.time() + self.PENDING_TTL,
|
| 1247 |
-
)
|
| 1248 |
-
await event.answer()
|
| 1249 |
-
await self._edit_panel(
|
| 1250 |
-
event,
|
| 1251 |
-
"Send me the image." if kind == "smedia_image" else "Send me the GIF.",
|
| 1252 |
-
[[("✖ Cancel", "x:close")]],
|
| 1253 |
-
)
|
| 1254 |
-
return
|
| 1255 |
-
|
| 1256 |
-
if data == "smedia:view":
|
| 1257 |
-
banner = await self.db.get_start_media()
|
| 1258 |
-
if not banner:
|
| 1259 |
-
await event.answer("There is no start screen yet.", alert=True)
|
| 1260 |
-
return
|
| 1261 |
-
await event.answer()
|
| 1262 |
-
await self._send_banner(event.chat_id, banner, "This is the start screen.", [])
|
| 1263 |
-
return
|
| 1264 |
-
|
| 1265 |
-
if data == "smedia:del":
|
| 1266 |
-
await self.db.clear_start_media()
|
| 1267 |
-
text, rows = panels.smedia_panel(None, False)
|
| 1268 |
-
await self._edit_panel(event, text, rows)
|
| 1269 |
-
await event.answer("Start screen deleted.")
|
| 1270 |
-
return
|
| 1271 |
-
|
| 1272 |
if head == "m" and len(parts) == 2 and parts[1] in PANEL_NAMES:
|
| 1273 |
self.pending.pop(uid, None)
|
| 1274 |
if parts[1] == "admin" and not self.is_owner(uid):
|
|
@@ -1283,42 +674,6 @@ class App:
|
|
| 1283 |
await self._cb_set(event, uid, parts[1], parts[2])
|
| 1284 |
return
|
| 1285 |
|
| 1286 |
-
if head == "fm" and len(parts) == 2 and parts[1].isdigit():
|
| 1287 |
-
await self._cb_use_format(event, uid, int(parts[1]))
|
| 1288 |
-
return
|
| 1289 |
-
|
| 1290 |
-
if head == "fl" and len(parts) == 2 and parts[1].isdigit():
|
| 1291 |
-
await self._cb_open_format(event, uid, int(parts[1]))
|
| 1292 |
-
return
|
| 1293 |
-
|
| 1294 |
-
if data == "fnew":
|
| 1295 |
-
await self._cb_new_format(event, uid)
|
| 1296 |
-
return
|
| 1297 |
-
|
| 1298 |
-
if head == "fset" and len(parts) == 3 and parts[1] in panels.FORMAT_PROMPTS and parts[2].isdigit():
|
| 1299 |
-
await self._cb_set_format_field(event, uid, parts[1], int(parts[2]))
|
| 1300 |
-
return
|
| 1301 |
-
|
| 1302 |
-
if head in ("fthumb", "fthumbdef", "fthumbnone") and len(parts) == 2 and parts[1].isdigit():
|
| 1303 |
-
await self._cb_format_thumb(event, uid, head, int(parts[1]))
|
| 1304 |
-
return
|
| 1305 |
-
|
| 1306 |
-
if head == "frename" and len(parts) == 2 and parts[1].isdigit():
|
| 1307 |
-
await self._cb_rename_format(event, uid, int(parts[1]))
|
| 1308 |
-
return
|
| 1309 |
-
|
| 1310 |
-
if head == "fdel" and len(parts) == 2 and parts[1].isdigit():
|
| 1311 |
-
await self._cb_delete_format(event, uid, int(parts[1]))
|
| 1312 |
-
return
|
| 1313 |
-
|
| 1314 |
-
if head == "fdelok" and len(parts) == 2 and parts[1].isdigit():
|
| 1315 |
-
fmt_id = int(parts[1])
|
| 1316 |
-
ok = await self.db.delete_format(uid, fmt_id)
|
| 1317 |
-
text, rows = panels.formats_list(await self.db.list_formats(uid), for_rename=False)
|
| 1318 |
-
await self._edit_panel(event, text, rows)
|
| 1319 |
-
await event.answer("Format deleted." if ok else "That format is gone.")
|
| 1320 |
-
return
|
| 1321 |
-
|
| 1322 |
if head == "in":
|
| 1323 |
await self._cb_input(event, uid, parts)
|
| 1324 |
return
|
|
@@ -1415,7 +770,7 @@ class App:
|
|
| 1415 |
await event.answer("Cancelled.")
|
| 1416 |
return
|
| 1417 |
kind = parts[1] if len(parts) == 2 else ""
|
| 1418 |
-
if kind not in panels.PROMPTS
|
| 1419 |
await event.answer()
|
| 1420 |
return
|
| 1421 |
msg_obj = await event.get_message()
|
|
|
|
| 17 |
from telethon import errors, events, functions, types
|
| 18 |
|
| 19 |
from . import fasttransfer as transfer
|
| 20 |
+
from . import media, panels, sysinfo
|
| 21 |
from .config import Config
|
| 22 |
+
from .db import Database
|
| 23 |
+
from .parser import build_manual_name, compute_new_name, missing_variables
|
|
|
|
| 24 |
from .pipeline import Renamer, original_name_of, source_ext
|
| 25 |
from .queue import Job, JobQueue
|
|
|
|
| 26 |
from .settings_ops import META_COMMANDS, apply_setting, parse_channel, preview_template
|
|
|
|
| 27 |
from .utils import clip, esc, human_size
|
| 28 |
|
| 29 |
log = logging.getLogger(__name__)
|
|
|
|
| 31 |
COMMANDS = [
|
| 32 |
("start", "Open the main menu"),
|
| 33 |
("help", "How to use the bot"),
|
| 34 |
+
("rename", "Reply to a file with a new name"),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
("mode", "Choose Filename or Caption mode"),
|
| 36 |
("template", "Set the filename template"),
|
| 37 |
("caption", "Set the caption template"),
|
|
|
|
| 47 |
("status", "Show basic bot uptime, ping, and version information."),
|
| 48 |
("stats", "Show detailed system stats such as users, CPU, RAM, and disk usage."),
|
| 49 |
]
|
| 50 |
+
OWNER_COMMANDS = {"addadmin", "deladmin", "admins"}
|
| 51 |
BACK = {
|
| 52 |
"template": "rename", "remove": "rename", "caption": "cap",
|
| 53 |
"mt_title": "meta", "mt_author": "meta", "mt_artist": "meta",
|
| 54 |
"mt_audio": "meta", "mt_subtitle": "meta", "mt_video": "meta",
|
| 55 |
"keep_audio": "tool", "keep_subtitle": "tool",
|
| 56 |
}
|
| 57 |
+
PANEL_NAMES = {"home", "rename", "cap", "meta", "tool", "ftype", "help", "admin"}
|
| 58 |
QUEUE_SETTLE = 1.5 # seconds an idle worker waits so files sent together are queued in message order
|
| 59 |
TRACKS_PROBE_BYTES = 12 * 1024 * 1024
|
| 60 |
TRACKS_FULL_LIMIT = 700 * 1024 * 1024
|
|
|
|
| 61 |
|
| 62 |
|
| 63 |
@dataclass
|
|
|
|
| 68 |
back: str
|
| 69 |
is_photo: bool
|
| 70 |
expires: float
|
|
|
|
| 71 |
|
| 72 |
|
| 73 |
def cmd_pattern(name: str) -> "re.Pattern[str]":
|
|
|
|
| 89 |
)
|
| 90 |
|
| 91 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
class App:
|
| 93 |
PENDING_TTL = 300
|
| 94 |
|
|
|
|
| 101 |
self.renamer = Renamer(client, cfg, db, self._log_send)
|
| 102 |
self.queue = JobQueue(self.renamer.process, workers=cfg.max_jobs, on_cancelled=self._on_job_cancelled, settle=QUEUE_SETTLE)
|
| 103 |
self._warned: Set[int] = set()
|
|
|
|
|
|
|
|
|
|
| 104 |
|
| 105 |
# ------------------------------------------------------------------ auth
|
| 106 |
def is_owner(self, uid: Optional[int]) -> bool:
|
|
|
|
| 195 |
)
|
| 196 |
add(self._guard(self.on_media), events.NewMessage(incoming=True, func=lambda e: e.is_private and is_renameable(e.message)))
|
| 197 |
add(self._guard(self.on_photo), events.NewMessage(incoming=True, func=lambda e: e.is_private and e.message.photo is not None))
|
|
|
|
| 198 |
add(self._guard(self.on_text), events.NewMessage(incoming=True, func=self._wants_text))
|
| 199 |
add(self._guard(self.on_callback), events.CallbackQuery())
|
| 200 |
add(self._on_stranger, events.NewMessage(incoming=True, func=lambda e: e.is_private and not self.is_allowed(e.sender_id)))
|
|
|
|
| 215 |
"tracks": self.cmd_tracks, "thumb": self.cmd_thumb, "delthumb": self.cmd_delthumb,
|
| 216 |
"removewords": self.cmd_remove, "setchannel": self.cmd_setchannel, "queue": self.cmd_queue,
|
| 217 |
"cancel": self.cmd_cancel, "status": self.cmd_status, "stats": self.cmd_stats,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
"addadmin": self.cmd_addadmin, "deladmin": self.cmd_deladmin, "admins": self.cmd_admins,
|
| 219 |
}
|
| 220 |
for command, kind in META_COMMANDS.items():
|
|
|
|
| 246 |
return panels.filetype(s)
|
| 247 |
if name == "help":
|
| 248 |
return panels.help_panel()
|
|
|
|
|
|
|
| 249 |
if name == "admin":
|
| 250 |
return panels.admin(
|
| 251 |
self.cfg.owner_id, sorted(self.admins), self.queue.running, self.queue.waiting, self.cfg.log_channel
|
|
|
|
| 272 |
async def cmd_start(self, event: Any) -> None:
|
| 273 |
self.pending.pop(event.sender_id, None)
|
| 274 |
text, rows = await self.panel(event.sender_id, "home")
|
| 275 |
+
if self.cfg.start_image:
|
|
|
|
| 276 |
try:
|
| 277 |
+
await self.client.send_file(
|
| 278 |
+
event.chat_id, self.cfg.start_image, caption=text, buttons=panels.to_buttons(rows), parse_mode="html"
|
| 279 |
+
)
|
| 280 |
return
|
| 281 |
except Exception as exc: # noqa: BLE001
|
| 282 |
+
log.warning("could not send START_IMAGE (%s); sending text only", exc)
|
| 283 |
await self._reply(event, text, rows)
|
| 284 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 285 |
async def cmd_help(self, event: Any) -> None:
|
| 286 |
text, _ = panels.help_panel()
|
| 287 |
await self._reply(event, text, [[("✖ Close", "x:close")]])
|
|
|
|
| 439 |
await self._reply(event, "You have no thumbnail.")
|
| 440 |
|
| 441 |
async def on_photo(self, event: Any) -> None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 442 |
raw = await event.download_media(bytes)
|
| 443 |
if not raw:
|
| 444 |
await self._reply(event, "❌ I couldn't download that photo.")
|
|
|
|
| 451 |
await self.db.set_thumb(event.sender_id, thumb)
|
| 452 |
await self._reply(event, "✅ Thumbnail saved. It will be used for the files I send back.", [[("🗑 Delete", "th:rm"), ("✖ Close", "x:close")]])
|
| 453 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 454 |
# ------------------------------------------------------------ rename flow
|
| 455 |
async def cmd_rename(self, event: Any) -> None:
|
| 456 |
+
arg = self._args(event)
|
| 457 |
+
reply = await event.get_reply_message()
|
| 458 |
+
if reply is None or not is_renameable(reply) or not arg:
|
| 459 |
+
await self._reply(event, "Reply to a file with <code>/rename New Name</code>.\nThe extension always stays the same.")
|
|
|
|
|
|
|
| 460 |
return
|
| 461 |
+
await self.enqueue(event, reply, manual=arg)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 462 |
|
| 463 |
async def on_media(self, event: Any) -> None:
|
| 464 |
+
await self.enqueue(event, event.message)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 465 |
|
| 466 |
+
async def enqueue(self, event: Any, msg: Any, manual: Optional[str] = None) -> None:
|
| 467 |
uid = event.sender_id
|
| 468 |
size = int(msg.file.size or 0) if msg.file is not None else 0
|
| 469 |
if size > self.cfg.max_file_bytes:
|
| 470 |
await self._reply(event, f"❌ That file is {human_size(size)}; the limit is {human_size(self.cfg.max_file_bytes)}.")
|
| 471 |
return
|
| 472 |
s = await self.db.get_settings(uid)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 473 |
orig = original_name_of(msg)
|
| 474 |
ext = source_ext(msg, orig)
|
| 475 |
notes: List[str] = []
|
| 476 |
+
if manual:
|
| 477 |
+
new_name = build_manual_name(manual, ext)
|
| 478 |
+
else:
|
| 479 |
+
new_name, info = compute_new_name(
|
| 480 |
+
template=s["template"], mode=s["mode"], original_name=orig, original_ext=ext,
|
| 481 |
+
caption=msg.raw_text or "", remove_words=s["remove_words"],
|
|
|
|
|
|
|
|
|
|
|
|
|
| 482 |
)
|
| 483 |
+
gaps = missing_variables(s["template"], info)
|
| 484 |
+
if gaps:
|
| 485 |
+
where_from = "caption" if s["mode"] == "caption" else "file name"
|
| 486 |
+
notes.append(
|
| 487 |
+
"Couldn't find " + ", ".join("{%s}" % g for g in gaps) + f" in the {where_from}, so it is left empty. "
|
| 488 |
+
"Cancel and use /rename, or change the template."
|
| 489 |
+
)
|
| 490 |
job = Job(
|
| 491 |
id=self.queue.new_id(), user_id=uid, chat_id=event.chat_id, msg_id=msg.id,
|
| 492 |
+
file_name=orig, size=size, new_name=new_name, manual=bool(manual), notes=notes, order=event.id,
|
|
|
|
| 493 |
)
|
| 494 |
ahead = self.queue.running + self.queue.waiting
|
| 495 |
where = "starting soon" if ahead == 0 else f"{ahead} ahead of it"
|
|
|
|
| 536 |
count = self.queue.cancel_all() if (arg == "all" and self.is_owner(uid)) else self.queue.cancel_user(uid)
|
| 537 |
await self._reply(event, f"🛑 Cancelling {count} job(s)…" if count else "You have no active jobs.")
|
| 538 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 539 |
# ------------------------------------------------------------------ tracks
|
| 540 |
async def cmd_tracks(self, event: Any) -> None:
|
| 541 |
reply = await event.get_reply_message()
|
|
|
|
| 630 |
if p is None or p.expires < time.time():
|
| 631 |
self.pending.pop(uid, None)
|
| 632 |
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 633 |
ok, msg = await apply_setting(self.db, uid, p.kind, event.raw_text)
|
| 634 |
if not ok:
|
| 635 |
await self._reply(event, msg + "\n\nTry again, or tap Cancel.")
|
|
|
|
| 647 |
except Exception: # noqa: BLE001
|
| 648 |
await self._reply(event, full, rows)
|
| 649 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 650 |
# ---------------------------------------------------------------- callbacks
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 651 |
async def on_callback(self, event: Any) -> None:
|
| 652 |
uid = event.sender_id
|
| 653 |
data = event.data.decode("utf-8", "ignore")
|
|
|
|
| 660 |
await self._remove_message(event)
|
| 661 |
return
|
| 662 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 663 |
if head == "m" and len(parts) == 2 and parts[1] in PANEL_NAMES:
|
| 664 |
self.pending.pop(uid, None)
|
| 665 |
if parts[1] == "admin" and not self.is_owner(uid):
|
|
|
|
| 674 |
await self._cb_set(event, uid, parts[1], parts[2])
|
| 675 |
return
|
| 676 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 677 |
if head == "in":
|
| 678 |
await self._cb_input(event, uid, parts)
|
| 679 |
return
|
|
|
|
| 770 |
await event.answer("Cancelled.")
|
| 771 |
return
|
| 772 |
kind = parts[1] if len(parts) == 2 else ""
|
| 773 |
+
if kind not in panels.PROMPTS:
|
| 774 |
await event.answer()
|
| 775 |
return
|
| 776 |
msg_obj = await event.get_message()
|
bot/config.py
CHANGED
|
@@ -50,6 +50,7 @@ class Config:
|
|
| 50 |
database_url: str
|
| 51 |
session_string: str
|
| 52 |
log_channel: Optional[int]
|
|
|
|
| 53 |
port: int
|
| 54 |
temp_dir: str
|
| 55 |
max_jobs: int
|
|
@@ -90,6 +91,7 @@ def load_config(env: Optional[Mapping[str, str]] = None) -> Config:
|
|
| 90 |
database_url=env.get("DATABASE_URL", "").strip() or "sqlite+aiosqlite:///bot.db",
|
| 91 |
session_string=env.get("SESSION_STRING", "").strip(),
|
| 92 |
log_channel=log_channel,
|
|
|
|
| 93 |
port=_get_int(env, "PORT", 7860),
|
| 94 |
temp_dir=env.get("TEMP_DIR", "").strip() or "/tmp/rename",
|
| 95 |
max_jobs=max_jobs,
|
|
|
|
| 50 |
database_url: str
|
| 51 |
session_string: str
|
| 52 |
log_channel: Optional[int]
|
| 53 |
+
start_image: Optional[str]
|
| 54 |
port: int
|
| 55 |
temp_dir: str
|
| 56 |
max_jobs: int
|
|
|
|
| 91 |
database_url=env.get("DATABASE_URL", "").strip() or "sqlite+aiosqlite:///bot.db",
|
| 92 |
session_string=env.get("SESSION_STRING", "").strip(),
|
| 93 |
log_channel=log_channel,
|
| 94 |
+
start_image=env.get("START_IMAGE", "").strip() or None,
|
| 95 |
port=_get_int(env, "PORT", 7860),
|
| 96 |
temp_dir=env.get("TEMP_DIR", "").strip() or "/tmp/rename",
|
| 97 |
max_jobs=max_jobs,
|
bot/db.py
CHANGED
|
@@ -12,7 +12,7 @@ from datetime import datetime, timezone
|
|
| 12 |
from typing import Any, Dict, List, Optional, Tuple
|
| 13 |
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
| 14 |
|
| 15 |
-
from sqlalchemy import BigInteger, DateTime, Integer, LargeBinary, String, Text,
|
| 16 |
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
| 17 |
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
| 18 |
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine
|
|
@@ -20,9 +20,6 @@ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
| 20 |
|
| 21 |
from .parser import DEFAULT_TEMPLATE
|
| 22 |
|
| 23 |
-
MAX_FORMATS = 12
|
| 24 |
-
META_FIELDS = ("title", "author", "artist", "audio", "subtitle", "video")
|
| 25 |
-
|
| 26 |
DEFAULT_SETTINGS: Dict[str, Any] = {
|
| 27 |
"mode": "filename", # filename | caption
|
| 28 |
"template": DEFAULT_TEMPLATE,
|
|
@@ -71,50 +68,6 @@ class KV(Base):
|
|
| 71 |
value: Mapped[str] = mapped_column(Text, default="")
|
| 72 |
|
| 73 |
|
| 74 |
-
class Format(Base):
|
| 75 |
-
"""A saved, named rename format.
|
| 76 |
-
|
| 77 |
-
Every field is tri-state: ``NULL`` means "inherit the user's global
|
| 78 |
-
setting", while a stored value overrides it. ``keep_audio`` /
|
| 79 |
-
``keep_subtitle`` store JSON, so ``NULL`` (inherit) stays distinct from
|
| 80 |
-
``[]`` (explicitly keep every track).
|
| 81 |
-
"""
|
| 82 |
-
|
| 83 |
-
__tablename__ = "formats"
|
| 84 |
-
__table_args__ = (UniqueConstraint("user_id", "name"),)
|
| 85 |
-
|
| 86 |
-
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
| 87 |
-
user_id: Mapped[int] = mapped_column(BigInteger, index=True)
|
| 88 |
-
name: Mapped[str] = mapped_column(String(64))
|
| 89 |
-
position: Mapped[int] = mapped_column(Integer, default=0)
|
| 90 |
-
template: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
| 91 |
-
thumb: Mapped[Optional[bytes]] = mapped_column(LargeBinary, nullable=True)
|
| 92 |
-
meta: Mapped[str] = mapped_column(Text, default="{}")
|
| 93 |
-
keep_audio: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
| 94 |
-
keep_subtitle: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
class SequenceRow(Base):
|
| 98 |
-
"""Files collected for a ``/sequence`` batch, waiting to be sorted."""
|
| 99 |
-
|
| 100 |
-
__tablename__ = "sequences"
|
| 101 |
-
|
| 102 |
-
user_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
| 103 |
-
items: Mapped[str] = mapped_column(Text, default="[]")
|
| 104 |
-
copied: Mapped[int] = mapped_column(Integer, default=0)
|
| 105 |
-
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
class BotMedia(Base):
|
| 109 |
-
"""The banner shown on ``/start`` (one row, id 1)."""
|
| 110 |
-
|
| 111 |
-
__tablename__ = "bot_media"
|
| 112 |
-
|
| 113 |
-
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
| 114 |
-
kind: Mapped[str] = mapped_column(String(8))
|
| 115 |
-
data: Mapped[bytes] = mapped_column(LargeBinary)
|
| 116 |
-
|
| 117 |
-
|
| 118 |
def normalize_database_url(url: str) -> Tuple[str, Dict[str, Any]]:
|
| 119 |
"""Turn a provider URL into something SQLAlchemy's async drivers accept."""
|
| 120 |
connect_args: Dict[str, Any] = {}
|
|
@@ -133,17 +86,6 @@ def normalize_database_url(url: str) -> Tuple[str, Dict[str, Any]]:
|
|
| 133 |
return url, connect_args
|
| 134 |
|
| 135 |
|
| 136 |
-
def _decode_list(raw: Optional[str]) -> Optional[List[str]]:
|
| 137 |
-
"""``NULL`` stays ``None`` (inherit); a JSON array decodes to a list."""
|
| 138 |
-
if raw is None:
|
| 139 |
-
return None
|
| 140 |
-
try:
|
| 141 |
-
value = json.loads(raw)
|
| 142 |
-
except ValueError:
|
| 143 |
-
return None
|
| 144 |
-
return list(value) if isinstance(value, list) else None
|
| 145 |
-
|
| 146 |
-
|
| 147 |
def _merge_settings(stored: Dict[str, Any]) -> Dict[str, Any]:
|
| 148 |
merged = copy.deepcopy(DEFAULT_SETTINGS)
|
| 149 |
for key, value in stored.items():
|
|
@@ -271,189 +213,6 @@ class Database:
|
|
| 271 |
await session.commit()
|
| 272 |
return True
|
| 273 |
|
| 274 |
-
# ----- formats --------------------------------------------------------------------
|
| 275 |
-
@staticmethod
|
| 276 |
-
def _format_dict(row: Format) -> Dict[str, Any]:
|
| 277 |
-
try:
|
| 278 |
-
meta = json.loads(row.meta or "{}")
|
| 279 |
-
except ValueError:
|
| 280 |
-
meta = {}
|
| 281 |
-
if not isinstance(meta, dict):
|
| 282 |
-
meta = {}
|
| 283 |
-
return {
|
| 284 |
-
"id": row.id,
|
| 285 |
-
"name": row.name,
|
| 286 |
-
"template": row.template,
|
| 287 |
-
"thumb": bytes(row.thumb) if row.thumb is not None else None,
|
| 288 |
-
"meta": {field: meta.get(field) for field in META_FIELDS},
|
| 289 |
-
"keep_audio": _decode_list(row.keep_audio),
|
| 290 |
-
"keep_subtitle": _decode_list(row.keep_subtitle),
|
| 291 |
-
}
|
| 292 |
-
|
| 293 |
-
async def list_formats(self, uid: int) -> List[Dict[str, Any]]:
|
| 294 |
-
async with self._sessions() as session:
|
| 295 |
-
rows = (
|
| 296 |
-
await session.execute(
|
| 297 |
-
select(Format).where(Format.user_id == uid).order_by(Format.position, Format.id)
|
| 298 |
-
)
|
| 299 |
-
).scalars().all()
|
| 300 |
-
return [self._format_dict(row) for row in rows]
|
| 301 |
-
|
| 302 |
-
async def get_format(self, uid: int, fmt_id: int) -> Optional[Dict[str, Any]]:
|
| 303 |
-
async with self._sessions() as session:
|
| 304 |
-
row = await session.get(Format, fmt_id)
|
| 305 |
-
if row is None or row.user_id != uid:
|
| 306 |
-
return None
|
| 307 |
-
return self._format_dict(row)
|
| 308 |
-
|
| 309 |
-
async def count_formats(self, uid: int) -> int:
|
| 310 |
-
async with self._sessions() as session:
|
| 311 |
-
result = await session.execute(
|
| 312 |
-
select(func.count()).select_from(Format).where(Format.user_id == uid)
|
| 313 |
-
)
|
| 314 |
-
return int(result.scalar() or 0)
|
| 315 |
-
|
| 316 |
-
async def create_format(self, uid: int, name: str) -> Optional[Dict[str, Any]]:
|
| 317 |
-
"""Create a format. ``None`` when the name is taken, blank or the 12-slot limit is hit."""
|
| 318 |
-
name = (name or "").strip()
|
| 319 |
-
if not name:
|
| 320 |
-
return None
|
| 321 |
-
async with self._sessions() as session:
|
| 322 |
-
clash = (
|
| 323 |
-
await session.execute(select(Format).where(Format.user_id == uid, Format.name == name))
|
| 324 |
-
).scalars().first()
|
| 325 |
-
if clash is not None:
|
| 326 |
-
return None
|
| 327 |
-
count = int(
|
| 328 |
-
(
|
| 329 |
-
await session.execute(
|
| 330 |
-
select(func.count()).select_from(Format).where(Format.user_id == uid)
|
| 331 |
-
)
|
| 332 |
-
).scalar()
|
| 333 |
-
or 0
|
| 334 |
-
)
|
| 335 |
-
if count >= MAX_FORMATS:
|
| 336 |
-
return None
|
| 337 |
-
row = Format(
|
| 338 |
-
user_id=uid,
|
| 339 |
-
name=name,
|
| 340 |
-
position=count,
|
| 341 |
-
meta=json.dumps({field: None for field in META_FIELDS}),
|
| 342 |
-
)
|
| 343 |
-
session.add(row)
|
| 344 |
-
await session.commit()
|
| 345 |
-
return self._format_dict(row)
|
| 346 |
-
|
| 347 |
-
async def update_format(self, uid: int, fmt_id: int, **changes: Any) -> bool:
|
| 348 |
-
"""Update a format. Only the keys passed in are touched."""
|
| 349 |
-
allowed = {"name", "template", "thumb", "meta", "keep_audio", "keep_subtitle"}
|
| 350 |
-
if not changes or not set(changes) <= allowed:
|
| 351 |
-
return False
|
| 352 |
-
async with self._sessions() as session:
|
| 353 |
-
row = await session.get(Format, fmt_id)
|
| 354 |
-
if row is None or row.user_id != uid:
|
| 355 |
-
return False
|
| 356 |
-
if "name" in changes:
|
| 357 |
-
name = (changes["name"] or "").strip()
|
| 358 |
-
if not name:
|
| 359 |
-
return False
|
| 360 |
-
clash = (
|
| 361 |
-
await session.execute(
|
| 362 |
-
select(Format).where(
|
| 363 |
-
Format.user_id == uid, Format.name == name, Format.id != fmt_id
|
| 364 |
-
)
|
| 365 |
-
)
|
| 366 |
-
).scalars().first()
|
| 367 |
-
if clash is not None:
|
| 368 |
-
return False
|
| 369 |
-
row.name = name
|
| 370 |
-
if "template" in changes:
|
| 371 |
-
row.template = changes["template"]
|
| 372 |
-
if "thumb" in changes:
|
| 373 |
-
value = changes["thumb"]
|
| 374 |
-
row.thumb = None if value is None else bytes(value)
|
| 375 |
-
if "meta" in changes:
|
| 376 |
-
try:
|
| 377 |
-
current = json.loads(row.meta or "{}")
|
| 378 |
-
except ValueError:
|
| 379 |
-
current = {}
|
| 380 |
-
if not isinstance(current, dict):
|
| 381 |
-
current = {}
|
| 382 |
-
for field, value in (changes["meta"] or {}).items():
|
| 383 |
-
if field in META_FIELDS:
|
| 384 |
-
current[field] = value
|
| 385 |
-
row.meta = json.dumps(current, ensure_ascii=False)
|
| 386 |
-
for key in ("keep_audio", "keep_subtitle"):
|
| 387 |
-
if key in changes:
|
| 388 |
-
value = changes[key]
|
| 389 |
-
setattr(row, key, None if value is None else json.dumps(list(value), ensure_ascii=False))
|
| 390 |
-
await session.commit()
|
| 391 |
-
return True
|
| 392 |
-
|
| 393 |
-
async def delete_format(self, uid: int, fmt_id: int) -> bool:
|
| 394 |
-
async with self._sessions() as session:
|
| 395 |
-
row = await session.get(Format, fmt_id)
|
| 396 |
-
if row is None or row.user_id != uid:
|
| 397 |
-
return False
|
| 398 |
-
await session.delete(row)
|
| 399 |
-
await session.commit()
|
| 400 |
-
return True
|
| 401 |
-
|
| 402 |
-
# ----- sequences ---------------------------------------------------------------------
|
| 403 |
-
async def get_sequence(self, uid: int) -> Optional[Dict[str, Any]]:
|
| 404 |
-
async with self._sessions() as session:
|
| 405 |
-
row = await session.get(SequenceRow, uid)
|
| 406 |
-
if row is None:
|
| 407 |
-
return None
|
| 408 |
-
try:
|
| 409 |
-
items = json.loads(row.items or "[]")
|
| 410 |
-
except ValueError:
|
| 411 |
-
items = []
|
| 412 |
-
return {"items": items if isinstance(items, list) else [], "copied": int(row.copied or 0)}
|
| 413 |
-
|
| 414 |
-
async def set_sequence(self, uid: int, items: List[Dict[str, Any]], copied: int = 0) -> None:
|
| 415 |
-
async with self._sessions() as session:
|
| 416 |
-
row = await session.get(SequenceRow, uid)
|
| 417 |
-
if row is None:
|
| 418 |
-
row = SequenceRow(user_id=uid, items="[]", copied=0)
|
| 419 |
-
session.add(row)
|
| 420 |
-
row.items = json.dumps(items, ensure_ascii=False)
|
| 421 |
-
row.copied = int(copied)
|
| 422 |
-
row.updated_at = _utcnow()
|
| 423 |
-
await session.commit()
|
| 424 |
-
|
| 425 |
-
async def clear_sequence(self, uid: int) -> None:
|
| 426 |
-
async with self._sessions() as session:
|
| 427 |
-
row = await session.get(SequenceRow, uid)
|
| 428 |
-
if row is not None:
|
| 429 |
-
await session.delete(row)
|
| 430 |
-
await session.commit()
|
| 431 |
-
|
| 432 |
-
# ----- start banner ---------------------------------------------------------------------
|
| 433 |
-
async def get_start_media(self) -> Optional[Dict[str, Any]]:
|
| 434 |
-
async with self._sessions() as session:
|
| 435 |
-
row = await session.get(BotMedia, 1)
|
| 436 |
-
if row is None:
|
| 437 |
-
return None
|
| 438 |
-
return {"kind": row.kind, "data": bytes(row.data)}
|
| 439 |
-
|
| 440 |
-
async def set_start_media(self, kind: str, data: bytes) -> None:
|
| 441 |
-
async with self._sessions() as session:
|
| 442 |
-
row = await session.get(BotMedia, 1)
|
| 443 |
-
if row is None:
|
| 444 |
-
session.add(BotMedia(id=1, kind=kind, data=data))
|
| 445 |
-
else:
|
| 446 |
-
row.kind = kind
|
| 447 |
-
row.data = data
|
| 448 |
-
await session.commit()
|
| 449 |
-
|
| 450 |
-
async def clear_start_media(self) -> None:
|
| 451 |
-
async with self._sessions() as session:
|
| 452 |
-
row = await session.get(BotMedia, 1)
|
| 453 |
-
if row is not None:
|
| 454 |
-
await session.delete(row)
|
| 455 |
-
await session.commit()
|
| 456 |
-
|
| 457 |
# ----- key/value (session string etc.) ---------------------------------------------
|
| 458 |
async def kv_get(self, key: str) -> Optional[str]:
|
| 459 |
async with self._sessions() as session:
|
|
|
|
| 12 |
from typing import Any, Dict, List, Optional, Tuple
|
| 13 |
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
| 14 |
|
| 15 |
+
from sqlalchemy import BigInteger, DateTime, Integer, LargeBinary, String, Text, func, select
|
| 16 |
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
| 17 |
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
| 18 |
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine
|
|
|
|
| 20 |
|
| 21 |
from .parser import DEFAULT_TEMPLATE
|
| 22 |
|
|
|
|
|
|
|
|
|
|
| 23 |
DEFAULT_SETTINGS: Dict[str, Any] = {
|
| 24 |
"mode": "filename", # filename | caption
|
| 25 |
"template": DEFAULT_TEMPLATE,
|
|
|
|
| 68 |
value: Mapped[str] = mapped_column(Text, default="")
|
| 69 |
|
| 70 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
def normalize_database_url(url: str) -> Tuple[str, Dict[str, Any]]:
|
| 72 |
"""Turn a provider URL into something SQLAlchemy's async drivers accept."""
|
| 73 |
connect_args: Dict[str, Any] = {}
|
|
|
|
| 86 |
return url, connect_args
|
| 87 |
|
| 88 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
def _merge_settings(stored: Dict[str, Any]) -> Dict[str, Any]:
|
| 90 |
merged = copy.deepcopy(DEFAULT_SETTINGS)
|
| 91 |
for key, value in stored.items():
|
|
|
|
| 213 |
await session.commit()
|
| 214 |
return True
|
| 215 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
# ----- key/value (session string etc.) ---------------------------------------------
|
| 217 |
async def kv_get(self, key: str) -> Optional[str]:
|
| 218 |
async with self._sessions() as session:
|
bot/formats.py
DELETED
|
@@ -1,160 +0,0 @@
|
|
| 1 |
-
"""Saved rename formats: the per-user named templates.
|
| 2 |
-
|
| 3 |
-
A format carries its own template, thumbnail, metadata and track-keep lists.
|
| 4 |
-
Every field is **tri-state**:
|
| 5 |
-
|
| 6 |
-
* ``None`` — inherit the user's global setting for that field
|
| 7 |
-
* ``""`` / ``[]`` — explicitly empty, overriding the global setting
|
| 8 |
-
* a value — an override
|
| 9 |
-
|
| 10 |
-
The distinction matters: without it there would be no way to say "this format keeps
|
| 11 |
-
every track" when the global keep-audio list is set, and no way to say "this format
|
| 12 |
-
has no title" when the global title is set.
|
| 13 |
-
"""
|
| 14 |
-
from __future__ import annotations
|
| 15 |
-
|
| 16 |
-
import copy
|
| 17 |
-
from typing import Any, Dict, List, Optional, Tuple
|
| 18 |
-
|
| 19 |
-
from .db import META_FIELDS, MAX_FORMATS, Database
|
| 20 |
-
from .media import parse_track_tokens, validate_track_tokens
|
| 21 |
-
from .panels import fmt_tokens
|
| 22 |
-
from .settings_ops import CLEAR_WORDS, preview_template
|
| 23 |
-
from .utils import clip, esc
|
| 24 |
-
|
| 25 |
-
META_KINDS = {
|
| 26 |
-
"fmt_title": "title",
|
| 27 |
-
"fmt_author": "author",
|
| 28 |
-
"fmt_artist": "artist",
|
| 29 |
-
"fmt_audio": "audio",
|
| 30 |
-
"fmt_subtitle": "subtitle",
|
| 31 |
-
"fmt_video": "video",
|
| 32 |
-
}
|
| 33 |
-
|
| 34 |
-
KEEP_KINDS = {
|
| 35 |
-
"fmt_keep_audio": "keep_audio",
|
| 36 |
-
"fmt_keep_subtitle": "keep_subtitle",
|
| 37 |
-
}
|
| 38 |
-
|
| 39 |
-
MAX_TEMPLATE_LEN = 200
|
| 40 |
-
MAX_META_LEN = 200
|
| 41 |
-
MAX_TRACKS = 12
|
| 42 |
-
DEFAULT_WORDS = {"default", "inherit", "my default"}
|
| 43 |
-
|
| 44 |
-
__all__ = [
|
| 45 |
-
"MAX_FORMATS",
|
| 46 |
-
"META_FIELDS",
|
| 47 |
-
"META_KINDS",
|
| 48 |
-
"apply_format_field",
|
| 49 |
-
"resolve_format",
|
| 50 |
-
"resolve_thumbnail",
|
| 51 |
-
]
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
def _is_clear(text: str) -> bool:
|
| 55 |
-
return text.strip().lower() in CLEAR_WORDS
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
def _is_default(text: str) -> bool:
|
| 59 |
-
"""``default`` means "go back to my global setting"."""
|
| 60 |
-
return text.strip().lower() in DEFAULT_WORDS
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
def resolve_format(fmt: Dict[str, Any], settings: Dict[str, Any]) -> Dict[str, Any]:
|
| 64 |
-
"""Overlay a format's fields onto the user's global settings.
|
| 65 |
-
|
| 66 |
-
Only fields the format actually sets are overlaid; everything else keeps the
|
| 67 |
-
global value. The input settings are not modified.
|
| 68 |
-
"""
|
| 69 |
-
out = copy.deepcopy(settings)
|
| 70 |
-
if fmt.get("template") is not None:
|
| 71 |
-
out["template"] = fmt["template"]
|
| 72 |
-
meta = dict(out.get("meta") or {})
|
| 73 |
-
for field in META_FIELDS:
|
| 74 |
-
value = (fmt.get("meta") or {}).get(field)
|
| 75 |
-
if value is not None:
|
| 76 |
-
meta[field] = value
|
| 77 |
-
out["meta"] = meta
|
| 78 |
-
if fmt.get("keep_audio") is not None:
|
| 79 |
-
out["keep_audio"] = list(fmt["keep_audio"])
|
| 80 |
-
if fmt.get("keep_subtitle") is not None:
|
| 81 |
-
out["keep_subtitle"] = list(fmt["keep_subtitle"])
|
| 82 |
-
return out
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
def resolve_thumbnail(fmt: Dict[str, Any], global_thumb: Optional[bytes]) -> Tuple[str, Optional[bytes]]:
|
| 86 |
-
"""Work out which thumbnail a format sends, as ``(mode, bytes)``.
|
| 87 |
-
|
| 88 |
-
* ``"auto"`` — inherit the user's global thumbnail; a frame of the video
|
| 89 |
-
stands in when there is none
|
| 90 |
-
* ``"custom"`` — the format's own image
|
| 91 |
-
* ``"none"`` — no thumbnail at all, and never a video frame
|
| 92 |
-
"""
|
| 93 |
-
thumb = fmt.get("thumb")
|
| 94 |
-
if thumb is None:
|
| 95 |
-
return "auto", global_thumb
|
| 96 |
-
if not thumb:
|
| 97 |
-
return "none", None
|
| 98 |
-
return "custom", bytes(thumb)
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
async def apply_format_field(db: Database, uid: int, fmt_id: int, kind: str, text: str) -> Tuple[bool, str]:
|
| 102 |
-
"""Validate and store one format field. Returns ``(ok, html_message)``."""
|
| 103 |
-
text = (text or "").strip()
|
| 104 |
-
if await db.get_format(uid, fmt_id) is None:
|
| 105 |
-
return False, "❌ That format is gone."
|
| 106 |
-
|
| 107 |
-
if kind == "fmt_name":
|
| 108 |
-
if not text or _is_clear(text):
|
| 109 |
-
return False, "❌ Send a name for the format."
|
| 110 |
-
if len(text) > 60:
|
| 111 |
-
return False, "❌ That name is too long (60 characters max)."
|
| 112 |
-
if not await db.update_format(uid, fmt_id, name=text):
|
| 113 |
-
return False, "❌ You already have a format with that name."
|
| 114 |
-
return True, f"✅ Renamed to <code>{esc(clip(text, 40))}</code>."
|
| 115 |
-
|
| 116 |
-
if kind == "fmt_template":
|
| 117 |
-
if _is_default(text) or _is_clear(text):
|
| 118 |
-
await db.update_format(uid, fmt_id, template=None)
|
| 119 |
-
return True, "✅ Template back to my default."
|
| 120 |
-
if not text:
|
| 121 |
-
return False, "❌ The template can't be empty. Send <code>default</code> to use my default."
|
| 122 |
-
if len(text) > MAX_TEMPLATE_LEN:
|
| 123 |
-
return False, f"❌ That template is too long ({MAX_TEMPLATE_LEN} characters max)."
|
| 124 |
-
await db.update_format(uid, fmt_id, template=text)
|
| 125 |
-
return True, "✅ Template saved.\n" + preview_template(text)
|
| 126 |
-
|
| 127 |
-
if kind in META_KINDS:
|
| 128 |
-
field = META_KINDS[kind]
|
| 129 |
-
if _is_default(text):
|
| 130 |
-
await db.update_format(uid, fmt_id, meta={field: None})
|
| 131 |
-
return True, f"✅ {field.title()} back to your default."
|
| 132 |
-
if not text:
|
| 133 |
-
return False, "❌ Send a value, <code>off</code> to clear it, or <code>default</code> for mine."
|
| 134 |
-
value = "" if _is_clear(text) else text
|
| 135 |
-
if len(value) > MAX_META_LEN:
|
| 136 |
-
return False, f"❌ That's too long ({MAX_META_LEN} characters max)."
|
| 137 |
-
await db.update_format(uid, fmt_id, meta={field: value})
|
| 138 |
-
if not value:
|
| 139 |
-
return True, f"✅ {field.title()} cleared for this format."
|
| 140 |
-
return True, f"✅ {field.title()} set to <code>{esc(clip(value, 80))}</code>."
|
| 141 |
-
|
| 142 |
-
if kind in KEEP_KINDS:
|
| 143 |
-
label = "Keep Audio" if kind == "fmt_keep_audio" else "Keep Subtitle"
|
| 144 |
-
key = KEEP_KINDS[kind]
|
| 145 |
-
if _is_default(text):
|
| 146 |
-
await db.update_format(uid, fmt_id, **{key: None})
|
| 147 |
-
return True, f"✅ {label} back to your default."
|
| 148 |
-
if _is_clear(text):
|
| 149 |
-
await db.update_format(uid, fmt_id, **{key: []})
|
| 150 |
-
return True, f"✅ {label}: keep every track."
|
| 151 |
-
tokens: List[str] = parse_track_tokens(text)
|
| 152 |
-
problem = validate_track_tokens(tokens)
|
| 153 |
-
if problem:
|
| 154 |
-
return False, "❌ " + esc(problem)
|
| 155 |
-
if len(tokens) > MAX_TRACKS:
|
| 156 |
-
return False, f"❌ That's too many tracks ({MAX_TRACKS} max)."
|
| 157 |
-
await db.update_format(uid, fmt_id, **{key: tokens})
|
| 158 |
-
return True, f"✅ {label}: {esc(fmt_tokens(tokens))}"
|
| 159 |
-
|
| 160 |
-
raise ValueError(f"unknown format field kind {kind!r}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
bot/media.py
CHANGED
|
@@ -391,93 +391,6 @@ async def make_video_thumbnail(src: Path, dst: Path, duration: float, cancel: Op
|
|
| 391 |
return code == 0 and dst.exists() and dst.stat().st_size > 0
|
| 392 |
|
| 393 |
|
| 394 |
-
async def probe_full(path: Path | str, cancel: Optional[asyncio.Event] = None) -> Dict[str, Any]:
|
| 395 |
-
"""The raw ffprobe JSON: format, streams, chapters and programs.
|
| 396 |
-
|
| 397 |
-
Unlike :func:`probe` this hands back everything ffprobe found, not a
|
| 398 |
-
parsed subset — it is used to publish a full media report.
|
| 399 |
-
"""
|
| 400 |
-
code, out, err = await _run(
|
| 401 |
-
[
|
| 402 |
-
"ffprobe", "-v", "quiet", "-print_format", "json",
|
| 403 |
-
"-show_format", "-show_streams", "-show_chapters", "-show_programs", str(path),
|
| 404 |
-
],
|
| 405 |
-
cancel,
|
| 406 |
-
)
|
| 407 |
-
try:
|
| 408 |
-
data = json.loads(out.decode("utf-8", "replace") or "{}")
|
| 409 |
-
except ValueError:
|
| 410 |
-
data = {}
|
| 411 |
-
return data if isinstance(data, dict) else {}
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
async def extract_frame(src: Path, dst: Path, duration: float, cancel: Optional[asyncio.Event] = None) -> bool:
|
| 415 |
-
"""Grab one frame at ~10% of the duration, at the source width.
|
| 416 |
-
|
| 417 |
-
Unlike ``make_video_thumbnail`` this keeps the full width — it is for
|
| 418 |
-
showing a frame, not for Telegram's 320px preview. Failure is non-fatal.
|
| 419 |
-
"""
|
| 420 |
-
seek = 1.0 if duration <= 0 else min(max(duration * 0.1, 0.0), 60.0)
|
| 421 |
-
cmd = [
|
| 422 |
-
"ffmpeg", "-hide_banner", "-nostdin", "-y", "-loglevel", "error", "-ss", f"{seek:.2f}", "-i", str(src),
|
| 423 |
-
"-frames:v", "1", "-q:v", "2", str(dst),
|
| 424 |
-
]
|
| 425 |
-
try:
|
| 426 |
-
code, _o, _e = await _run(cmd, cancel)
|
| 427 |
-
except MediaError:
|
| 428 |
-
return False
|
| 429 |
-
return code == 0 and dst.exists() and dst.stat().st_size > 0
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
def parse_size(text: str) -> Tuple[Optional[int], Optional[int], Optional[str]]:
|
| 433 |
-
"""``"800"`` -> ``(800, None, None)``; ``"800x450"`` -> ``(800, 450, None)``.
|
| 434 |
-
|
| 435 |
-
A bad input gives ``(None, None, "an error message")``.
|
| 436 |
-
"""
|
| 437 |
-
raw = (text or "").strip().lower().replace("×", "x")
|
| 438 |
-
bad = "Send a size, like <code>800</code> or <code>800x450</code>."
|
| 439 |
-
if not raw:
|
| 440 |
-
return None, None, bad
|
| 441 |
-
if "x" in raw:
|
| 442 |
-
parts = raw.split("x")
|
| 443 |
-
if len(parts) != 2 or not all(part.strip().isdigit() for part in parts):
|
| 444 |
-
return None, None, bad
|
| 445 |
-
width, height = int(parts[0]), int(parts[1])
|
| 446 |
-
if width < 1 or height < 1:
|
| 447 |
-
return None, None, "The size must be at least 1 pixel."
|
| 448 |
-
return width, height, None
|
| 449 |
-
if not raw.isdigit():
|
| 450 |
-
return None, None, bad
|
| 451 |
-
width = int(raw)
|
| 452 |
-
if width < 1:
|
| 453 |
-
return None, None, "The size must be at least 1 pixel."
|
| 454 |
-
return width, None, None
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
def resize_image(data: bytes, width: Optional[int] = None, height: Optional[int] = None) -> bytes:
|
| 458 |
-
"""Resize an image and return it as a JPEG.
|
| 459 |
-
|
| 460 |
-
With one dimension the aspect ratio is kept; with both the exact size is
|
| 461 |
-
forced.
|
| 462 |
-
"""
|
| 463 |
-
from PIL import Image
|
| 464 |
-
|
| 465 |
-
resample = Image.Resampling.LANCZOS if hasattr(Image, "Resampling") else Image.LANCZOS
|
| 466 |
-
with Image.open(io.BytesIO(data)) as img:
|
| 467 |
-
img = img.convert("RGB")
|
| 468 |
-
if width and height:
|
| 469 |
-
img = img.resize((width, height), resample)
|
| 470 |
-
elif width:
|
| 471 |
-
ratio = width / max(img.width, 1)
|
| 472 |
-
img = img.resize((width, max(1, round(img.height * ratio))), resample)
|
| 473 |
-
elif height:
|
| 474 |
-
ratio = height / max(img.height, 1)
|
| 475 |
-
img = img.resize((max(1, round(img.width * ratio)), height), resample)
|
| 476 |
-
buf = io.BytesIO()
|
| 477 |
-
img.save(buf, "JPEG", quality=90, optimize=True)
|
| 478 |
-
return buf.getvalue()
|
| 479 |
-
|
| 480 |
-
|
| 481 |
def prepare_thumb(data: bytes) -> bytes:
|
| 482 |
"""Resize/re-encode an image to Telegram's thumbnail limits (<=320px, <=200 KB JPEG)."""
|
| 483 |
from PIL import Image
|
|
|
|
| 391 |
return code == 0 and dst.exists() and dst.stat().st_size > 0
|
| 392 |
|
| 393 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 394 |
def prepare_thumb(data: bytes) -> bytes:
|
| 395 |
"""Resize/re-encode an image to Telegram's thumbnail limits (<=320px, <=200 KB JPEG)."""
|
| 396 |
from PIL import Image
|
bot/panels.py
CHANGED
|
@@ -6,12 +6,10 @@ limit because the home banner makes every panel a photo caption.
|
|
| 6 |
"""
|
| 7 |
from __future__ import annotations
|
| 8 |
|
| 9 |
-
from typing import Any, Dict, Iterable, List,
|
| 10 |
|
| 11 |
-
from .db import MAX_FORMATS
|
| 12 |
from .media import LANGUAGES, canonical_lang
|
| 13 |
-
from .
|
| 14 |
-
from .utils import clip, esc, to_small_caps
|
| 15 |
|
| 16 |
Row = List[Tuple[str, str]]
|
| 17 |
Panel = Tuple[str, List[Row]]
|
|
@@ -185,134 +183,19 @@ def help_panel() -> Panel:
|
|
| 185 |
text = (
|
| 186 |
"<b>How to use</b>\n\n"
|
| 187 |
"1. Send a file — I rename it with your template and send it back.\n"
|
| 188 |
-
"2. <code>/rename</code> —
|
| 189 |
-
"3.
|
| 190 |
-
"4.
|
| 191 |
-
"5.
|
| 192 |
-
"6.
|
| 193 |
-
"7.
|
| 194 |
-
"8.
|
| 195 |
-
"9. <code>/filetype</code> — send as document, video or audio.\n"
|
| 196 |
-
"10. <code>/ethumb</code> and <code>/resize</code> — thumbnails and images.\n"
|
| 197 |
-
"11. <code>/speed</code> and <code>/mediainfo</code> — my line speed and a file's media info.\n"
|
| 198 |
-
"12. <code>/queue</code> and <code>/cancel</code> — manage running jobs.\n\n"
|
| 199 |
"Template variables: <code>{title} {season} {episode} {quality} {year} {codec} {filename} {ext}</code>\n"
|
| 200 |
"A [bracket] group is dropped when a variable inside it is empty."
|
| 201 |
)
|
| 202 |
return text, nav("home")
|
| 203 |
|
| 204 |
|
| 205 |
-
def check_report(file_info: Info, caption_info: Info) -> str:
|
| 206 |
-
"""The ``/check`` body: what the bot extracted from the name and from the caption."""
|
| 207 |
-
|
| 208 |
-
def field(label: str, value: str) -> str:
|
| 209 |
-
return f"\u27a6 {label} \u2b46 {esc(value) if value else 'None'}"
|
| 210 |
-
|
| 211 |
-
def section(heading: str, info: Info) -> str:
|
| 212 |
-
lines = [
|
| 213 |
-
f"{to_small_caps(heading)}:",
|
| 214 |
-
field("Title", info.title),
|
| 215 |
-
field("Season", info.season),
|
| 216 |
-
field("Episode", info.episode),
|
| 217 |
-
field("Quality", info.quality),
|
| 218 |
-
]
|
| 219 |
-
if info.year:
|
| 220 |
-
lines.append(field("Year", info.year))
|
| 221 |
-
if info.codec:
|
| 222 |
-
lines.append(field("Codec", info.codec))
|
| 223 |
-
return "\n".join(lines)
|
| 224 |
-
|
| 225 |
-
header = f"{to_small_caps('Your Can Change Mode By')} /mode"
|
| 226 |
-
return "\n\n".join([header, section("FileName", file_info), section("Caption Mode", caption_info)])
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
def _keep_label(value: Any) -> str:
|
| 230 |
-
"""``None`` (inherit) / ``[]`` (every track) / a list of tokens."""
|
| 231 |
-
if value is None:
|
| 232 |
-
return "my default"
|
| 233 |
-
if not value:
|
| 234 |
-
return "every track"
|
| 235 |
-
return esc(fmt_tokens(value))
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
def _thumb_label(value: Any) -> str:
|
| 239 |
-
if value is None:
|
| 240 |
-
return "my default"
|
| 241 |
-
return "none" if not value else "✅ set"
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
def formats_list(formats: Sequence[Dict[str, Any]], *, for_rename: bool) -> Panel:
|
| 245 |
-
"""The saved-format picker (``/rename``) and manager (``/formats``)."""
|
| 246 |
-
if for_rename:
|
| 247 |
-
title = "<b>PICK A FORMAT</b>"
|
| 248 |
-
lead = "Choose the format to use, then send me the file."
|
| 249 |
-
action = "fm"
|
| 250 |
-
else:
|
| 251 |
-
title = "<b>MY FORMATS</b>"
|
| 252 |
-
lead = "Your saved rename formats. Tap one to edit it."
|
| 253 |
-
action = "fl"
|
| 254 |
-
|
| 255 |
-
if not formats:
|
| 256 |
-
text = (
|
| 257 |
-
f"{title}\n\n{lead}\n\n"
|
| 258 |
-
"You have no saved formats yet — create one first.\n"
|
| 259 |
-
"A format keeps its own template, thumbnail, metadata and track lists; "
|
| 260 |
-
"everything else uses your normal settings."
|
| 261 |
-
)
|
| 262 |
-
return text, [[("➕ New format", "fnew")]] + nav("home")
|
| 263 |
-
|
| 264 |
-
lines = [title, "", lead, ""]
|
| 265 |
-
rows: List[Row] = []
|
| 266 |
-
for f in formats:
|
| 267 |
-
own = " ★" if f.get("template") else ""
|
| 268 |
-
lines.append(f"{DOT} <b>{esc(f['name'])}</b>{own}")
|
| 269 |
-
rows.append([(clip(f["name"], 30), f"{action}:{f['id']}")])
|
| 270 |
-
lines.append("")
|
| 271 |
-
lines.append("★ = this format has its own template.")
|
| 272 |
-
if len(formats) < MAX_FORMATS:
|
| 273 |
-
rows.append([("➕ New format", "fnew")])
|
| 274 |
-
return "\n".join(lines), rows + nav("home")
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
def format_editor(fmt: Dict[str, Any]) -> Panel:
|
| 278 |
-
"""The editor for one saved format."""
|
| 279 |
-
fid = fmt["id"]
|
| 280 |
-
lines = [f"<b>FORMAT · {esc(fmt['name'])}</b>", ""]
|
| 281 |
-
template = fmt.get("template")
|
| 282 |
-
lines.append(f"{DOT} <b>Template</b> ▸ {_code(template, 120) if template else 'my default'}")
|
| 283 |
-
for label, key in (("Title", "title"), ("Author", "author"), ("Artist", "artist"),
|
| 284 |
-
("Audio", "audio"), ("Subtitle", "subtitle"), ("Video", "video")):
|
| 285 |
-
value = (fmt.get("meta") or {}).get(key)
|
| 286 |
-
lines.append(f"{DOT} <b>{label}</b> ▸ {_code(value, 60) if value is not None else 'my default'}")
|
| 287 |
-
lines.append(f"{DOT} <b>Keep Audio</b> ▸ {_keep_label(fmt.get('keep_audio'))}")
|
| 288 |
-
lines.append(f"{DOT} <b>Keep Subtitle</b> ▸ {_keep_label(fmt.get('keep_subtitle'))}")
|
| 289 |
-
lines.append(f"{DOT} <b>Thumbnail</b> ▸ {_thumb_label(fmt.get('thumb'))}")
|
| 290 |
-
rows: List[Row] = [
|
| 291 |
-
[("📝 Template", f"fset:fmt_template:{fid}"), ("🖼 Thumbnail", f"fthumb:{fid}")],
|
| 292 |
-
[("🎬 Title", f"fset:fmt_title:{fid}"), ("👤 Author", f"fset:fmt_author:{fid}")],
|
| 293 |
-
[("🎵 Artist", f"fset:fmt_artist:{fid}"), ("🔊 Audio", f"fset:fmt_audio:{fid}")],
|
| 294 |
-
[("💬 Subtitle", f"fset:fmt_subtitle:{fid}"), ("🎥 Video", f"fset:fmt_video:{fid}")],
|
| 295 |
-
[("🔊 Keep Audio", f"fset:fmt_keep_audio:{fid}"), ("💬 Keep Subtitle", f"fset:fmt_keep_subtitle:{fid}")],
|
| 296 |
-
[("🖼 Use default", f"fthumbdef:{fid}"), ("🚫 No thumbnail", f"fthumbnone:{fid}")],
|
| 297 |
-
[("✏️ Name", f"frename:{fid}"), ("🗑 Delete", f"fdel:{fid}")],
|
| 298 |
-
]
|
| 299 |
-
return "\n".join(lines), rows + nav("formats")
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
def smedia_panel(kind: Optional[str], has_media: bool) -> Panel:
|
| 303 |
-
"""The owner's panel for the banner shown on ``/start``."""
|
| 304 |
-
what = {"gif": "a GIF", "image": "an image"}.get(kind or "", "nothing")
|
| 305 |
-
text = (
|
| 306 |
-
"<b>🖼 START SCREEN</b>\n\n"
|
| 307 |
-
f"The banner shown on <code>/start</code> is currently <b>{what}</b>.\n\n"
|
| 308 |
-
"Pick what you want, then send me the file."
|
| 309 |
-
)
|
| 310 |
-
rows: List[Row] = [[("🖼 Image", "smedia:image"), ("🎞 GIF", "smedia:gif")]]
|
| 311 |
-
if has_media:
|
| 312 |
-
rows.append([("👁 View", "smedia:view"), ("🗑 Delete", "smedia:del")])
|
| 313 |
-
return text, rows + nav("home")
|
| 314 |
-
|
| 315 |
-
|
| 316 |
def admin(owner_id: int, admins: Sequence[int], running: int, waiting: int, log_channel: Any) -> Panel:
|
| 317 |
shown = ", ".join(f"<code>{a}</code>" for a in list(admins)[:10]) or "none"
|
| 318 |
more = f" (+{len(admins) - 10} more)" if len(admins) > 10 else ""
|
|
@@ -378,43 +261,5 @@ def input_prompt(kind: str, back: str) -> Panel:
|
|
| 378 |
return PROMPTS[kind], [[("✖ Cancel", f"in:cancel:{back}")]]
|
| 379 |
|
| 380 |
|
| 381 |
-
FORMAT_PROMPTS: Dict[str, str] = {
|
| 382 |
-
"fmt_name": (
|
| 383 |
-
"<b>Send the name for this format.</b>\n\n"
|
| 384 |
-
"Something you'll recognise later, like <code>One Piece format</code>."
|
| 385 |
-
),
|
| 386 |
-
"fmt_template": (
|
| 387 |
-
"<b>Send the filename template for this format.</b>\n\n"
|
| 388 |
-
"Variables: <code>{title} {season} {episode} {quality} {year} {codec} {filename} {ext}</code>\n"
|
| 389 |
-
"A [bracket] group disappears when a variable inside it is empty.\n\n"
|
| 390 |
-
"Send <code>default</code> to go back to your normal template."
|
| 391 |
-
),
|
| 392 |
-
"fmt_title": "<b>Send the Title</b> to write into the files' metadata.\nSend <code>off</code> to clear it, or <code>default</code> to use your normal one.",
|
| 393 |
-
"fmt_author": "<b>Send the Author</b> to write into the files' metadata.\nSend <code>off</code> to clear it, or <code>default</code> to use your normal one.",
|
| 394 |
-
"fmt_artist": "<b>Send the Artist</b> to write into the files' metadata.\nSend <code>off</code> to clear it, or <code>default</code> to use your normal one.",
|
| 395 |
-
"fmt_audio": (
|
| 396 |
-
"<b>Send the title for audio tracks</b>.\n<code>{lang}</code> becomes each track's language, "
|
| 397 |
-
"e.g. <code>{lang} Audio</code>.\nSend <code>off</code> to clear it, or <code>default</code> to use your normal one."
|
| 398 |
-
),
|
| 399 |
-
"fmt_subtitle": (
|
| 400 |
-
"<b>Send the title for subtitle tracks</b>.\n<code>{lang}</code> becomes each track's language.\n"
|
| 401 |
-
"Send <code>off</code> to clear it, or <code>default</code> to use your normal one."
|
| 402 |
-
),
|
| 403 |
-
"fmt_video": "<b>Send the title for the video track</b>.\nSend <code>off</code> to clear it, or <code>default</code> to use your normal one.",
|
| 404 |
-
"fmt_keep_audio": (
|
| 405 |
-
"<b>Which audio tracks should this format keep?</b>\n\n"
|
| 406 |
-
"Send language names (<code>English Japanese</code>) or track numbers (<code>2 1</code>). "
|
| 407 |
-
"The order you give is the output order; all other tracks are dropped.\n"
|
| 408 |
-
"Send <code>off</code> to keep every track, or <code>default</code> to use your normal list."
|
| 409 |
-
),
|
| 410 |
-
"fmt_keep_subtitle": (
|
| 411 |
-
"<b>Which subtitle tracks should this format keep?</b>\n\n"
|
| 412 |
-
"Send language names (<code>English</code>) or track numbers (<code>2</code>). "
|
| 413 |
-
"The order you give is the output order; all other tracks are dropped.\n"
|
| 414 |
-
"Send <code>off</code> to keep every track, or <code>default</code> to use your normal list."
|
| 415 |
-
),
|
| 416 |
-
}
|
| 417 |
-
|
| 418 |
-
|
| 419 |
def known_languages() -> str:
|
| 420 |
return ", ".join(sorted(LANGUAGES))
|
|
|
|
| 6 |
"""
|
| 7 |
from __future__ import annotations
|
| 8 |
|
| 9 |
+
from typing import Any, Dict, Iterable, List, Sequence, Tuple
|
| 10 |
|
|
|
|
| 11 |
from .media import LANGUAGES, canonical_lang
|
| 12 |
+
from .utils import clip, esc
|
|
|
|
| 13 |
|
| 14 |
Row = List[Tuple[str, str]]
|
| 15 |
Panel = Tuple[str, List[Row]]
|
|
|
|
| 183 |
text = (
|
| 184 |
"<b>How to use</b>\n\n"
|
| 185 |
"1. Send a file — I rename it with your template and send it back.\n"
|
| 186 |
+
"2. <code>/rename New Name</code> — reply to a file for a one-off name.\n"
|
| 187 |
+
"3. /template and /caption — set the naming and caption templates.\n"
|
| 188 |
+
"4. /mode — read details from the file name or the caption.\n"
|
| 189 |
+
"5. /metadata, /keepaudio, /keepsubtitle — tags and track clean-up.\n"
|
| 190 |
+
"6. /filetype — send as document, video or audio.\n"
|
| 191 |
+
"7. Send a photo — it becomes your thumbnail.\n"
|
| 192 |
+
"8. /queue and /cancel — manage running jobs.\n\n"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
"Template variables: <code>{title} {season} {episode} {quality} {year} {codec} {filename} {ext}</code>\n"
|
| 194 |
"A [bracket] group is dropped when a variable inside it is empty."
|
| 195 |
)
|
| 196 |
return text, nav("home")
|
| 197 |
|
| 198 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
def admin(owner_id: int, admins: Sequence[int], running: int, waiting: int, log_channel: Any) -> Panel:
|
| 200 |
shown = ", ".join(f"<code>{a}</code>" for a in list(admins)[:10]) or "none"
|
| 201 |
more = f" (+{len(admins) - 10} more)" if len(admins) > 10 else ""
|
|
|
|
| 261 |
return PROMPTS[kind], [[("✖ Cancel", f"in:cancel:{back}")]]
|
| 262 |
|
| 263 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
def known_languages() -> str:
|
| 265 |
return ", ".join(sorted(LANGUAGES))
|
bot/parser.py
CHANGED
|
@@ -327,24 +327,6 @@ def parse_caption(caption: str) -> Info:
|
|
| 327 |
return first
|
| 328 |
|
| 329 |
|
| 330 |
-
def effective_info(stem: str, caption: str, mode: str) -> Info:
|
| 331 |
-
"""The details a rename would use for this file.
|
| 332 |
-
|
| 333 |
-
Caption mode only takes the caption when it yields a structured field; an
|
| 334 |
-
unstructured caption is discarded and the filename is used unchanged.
|
| 335 |
-
"""
|
| 336 |
-
info = parse_info(stem)
|
| 337 |
-
if mode != "caption" or not (caption or "").strip():
|
| 338 |
-
return info
|
| 339 |
-
cinfo = parse_caption(caption)
|
| 340 |
-
if not cinfo.structured:
|
| 341 |
-
return info
|
| 342 |
-
for field in ("title", "season", "episode", "quality", "year", "codec"):
|
| 343 |
-
if not getattr(cinfo, field) and getattr(info, field):
|
| 344 |
-
setattr(cinfo, field, getattr(info, field))
|
| 345 |
-
return cinfo
|
| 346 |
-
|
| 347 |
-
|
| 348 |
# ---------------------------------------------------------------------------
|
| 349 |
# templates
|
| 350 |
# ---------------------------------------------------------------------------
|
|
@@ -446,7 +428,14 @@ def compute_new_name(
|
|
| 446 |
stem, ext = split_ext(original_name)
|
| 447 |
if not ext:
|
| 448 |
ext = original_ext or ""
|
| 449 |
-
info =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 450 |
values = build_values(info, stem, ext)
|
| 451 |
rendered = render_template(template or DEFAULT_TEMPLATE, values)
|
| 452 |
rendered = apply_remove_words(rendered, remove_words)
|
|
|
|
| 327 |
return first
|
| 328 |
|
| 329 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 330 |
# ---------------------------------------------------------------------------
|
| 331 |
# templates
|
| 332 |
# ---------------------------------------------------------------------------
|
|
|
|
| 428 |
stem, ext = split_ext(original_name)
|
| 429 |
if not ext:
|
| 430 |
ext = original_ext or ""
|
| 431 |
+
info = parse_info(stem)
|
| 432 |
+
if mode == "caption" and caption.strip():
|
| 433 |
+
cinfo = parse_caption(caption)
|
| 434 |
+
if cinfo.structured:
|
| 435 |
+
for field in ("title", "season", "episode", "quality", "year", "codec"):
|
| 436 |
+
if not getattr(cinfo, field) and getattr(info, field):
|
| 437 |
+
setattr(cinfo, field, getattr(info, field))
|
| 438 |
+
info = cinfo
|
| 439 |
values = build_values(info, stem, ext)
|
| 440 |
rendered = render_template(template or DEFAULT_TEMPLATE, values)
|
| 441 |
rendered = apply_remove_words(rendered, remove_words)
|
bot/pipeline.py
CHANGED
|
@@ -94,7 +94,7 @@ class Renamer:
|
|
| 94 |
|
| 95 |
# ------------------------------------------------------------------
|
| 96 |
async def process(self, job: Job) -> None:
|
| 97 |
-
s =
|
| 98 |
work = Path(self.cfg.temp_dir) / f"job{job.id}"
|
| 99 |
work.mkdir(parents=True, exist_ok=True)
|
| 100 |
reporter = ProgressReporter(self.client, job.status_msg, job.id, job.new_name, self.cfg.progress_interval)
|
|
@@ -178,16 +178,11 @@ class Renamer:
|
|
| 178 |
)
|
| 179 |
)
|
| 180 |
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
thumb = job.thumbnail or await self.db.get_thumb(job.user_id)
|
| 187 |
-
if thumb is None and ext.lower() in media.VIDEO_CONTAINERS and info is not None and info.of("video"):
|
| 188 |
-
thumb_path = work / "thumb.jpg"
|
| 189 |
-
if await media.make_video_thumbnail(current, thumb_path, info.duration, job.cancel):
|
| 190 |
-
thumb = thumb_path.read_bytes()
|
| 191 |
|
| 192 |
# ---- caption ---------------------------------------------------------------------
|
| 193 |
parsed = parse_info(split_ext(orig_name)[0])
|
|
|
|
| 94 |
|
| 95 |
# ------------------------------------------------------------------
|
| 96 |
async def process(self, job: Job) -> None:
|
| 97 |
+
s = await self.db.get_settings(job.user_id)
|
| 98 |
work = Path(self.cfg.temp_dir) / f"job{job.id}"
|
| 99 |
work.mkdir(parents=True, exist_ok=True)
|
| 100 |
reporter = ProgressReporter(self.client, job.status_msg, job.id, job.new_name, self.cfg.progress_interval)
|
|
|
|
| 178 |
)
|
| 179 |
)
|
| 180 |
|
| 181 |
+
thumb = await self.db.get_thumb(job.user_id)
|
| 182 |
+
if thumb is None and ext.lower() in media.VIDEO_CONTAINERS and info is not None and info.of("video"):
|
| 183 |
+
thumb_path = work / "thumb.jpg"
|
| 184 |
+
if await media.make_video_thumbnail(current, thumb_path, info.duration, job.cancel):
|
| 185 |
+
thumb = thumb_path.read_bytes()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
|
| 187 |
# ---- caption ---------------------------------------------------------------------
|
| 188 |
parsed = parse_info(split_ext(orig_name)[0])
|
bot/queue.py
CHANGED
|
@@ -20,6 +20,7 @@ class Job:
|
|
| 20 |
file_name: str
|
| 21 |
size: int
|
| 22 |
new_name: str
|
|
|
|
| 23 |
notes: List[str] = field(default_factory=list) # warnings shown with the queued / finished message
|
| 24 |
order: int = 0 # id of the message that triggered the job: files are processed oldest message first
|
| 25 |
status_msg: Any = None
|
|
@@ -27,12 +28,6 @@ class Job:
|
|
| 27 |
cancel: asyncio.Event = field(default_factory=asyncio.Event)
|
| 28 |
created: float = field(default_factory=time.time)
|
| 29 |
started: float = 0.0
|
| 30 |
-
# the settings the name was computed from, so a queued job cannot drift
|
| 31 |
-
settings_override: Optional[Dict[str, Any]] = None
|
| 32 |
-
# auto = the user's global thumbnail (a video frame may stand in),
|
| 33 |
-
# custom = this job's own thumbnail, none = send no thumbnail at all
|
| 34 |
-
thumbnail_mode: str = "auto"
|
| 35 |
-
thumbnail: Optional[bytes] = None
|
| 36 |
|
| 37 |
|
| 38 |
Runner = Callable[[Job], Awaitable[None]]
|
|
|
|
| 20 |
file_name: str
|
| 21 |
size: int
|
| 22 |
new_name: str
|
| 23 |
+
manual: bool = False
|
| 24 |
notes: List[str] = field(default_factory=list) # warnings shown with the queued / finished message
|
| 25 |
order: int = 0 # id of the message that triggered the job: files are processed oldest message first
|
| 26 |
status_msg: Any = None
|
|
|
|
| 28 |
cancel: asyncio.Event = field(default_factory=asyncio.Event)
|
| 29 |
created: float = field(default_factory=time.time)
|
| 30 |
started: float = 0.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
|
| 33 |
Runner = Callable[[Job], Awaitable[None]]
|
bot/sequence.py
DELETED
|
@@ -1,100 +0,0 @@
|
|
| 1 |
-
"""Collecting files and sorting them by episode.
|
| 2 |
-
|
| 3 |
-
The pure part lives here: which episode each file is, the order they go in,
|
| 4 |
-
and what is missing or repeated. The Telegram part lives in ``bot/app.py``.
|
| 5 |
-
"""
|
| 6 |
-
from __future__ import annotations
|
| 7 |
-
|
| 8 |
-
from dataclasses import dataclass, field
|
| 9 |
-
from typing import Any, Dict, List, Tuple
|
| 10 |
-
|
| 11 |
-
from .parser import effective_info, split_ext
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
@dataclass
|
| 15 |
-
class Duplicate:
|
| 16 |
-
season: str
|
| 17 |
-
episode: str
|
| 18 |
-
names: List[str] = field(default_factory=list)
|
| 19 |
-
|
| 20 |
-
@property
|
| 21 |
-
def label(self) -> str:
|
| 22 |
-
return f"S{self.season}E{self.episode}"
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
@dataclass
|
| 26 |
-
class SortResult:
|
| 27 |
-
ordered: List[Dict[str, Any]] = field(default_factory=list)
|
| 28 |
-
missing: List[str] = field(default_factory=list)
|
| 29 |
-
duplicates: List[Duplicate] = field(default_factory=list)
|
| 30 |
-
no_episode: List[str] = field(default_factory=list)
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
def detect_episodes(items: List[Dict[str, Any]], mode: str) -> List[Dict[str, Any]]:
|
| 34 |
-
"""Fill in each item's ``season`` and ``episode`` using the mode-aware merge.
|
| 35 |
-
|
| 36 |
-
An item is ``{"msg_id": int, "name": str, "caption": str}``; the result adds
|
| 37 |
-
``season`` and ``episode`` (empty strings when there are none).
|
| 38 |
-
"""
|
| 39 |
-
out: List[Dict[str, Any]] = []
|
| 40 |
-
for item in items:
|
| 41 |
-
stem, _ext = split_ext(item.get("name") or "")
|
| 42 |
-
info = effective_info(stem, item.get("caption") or "", mode)
|
| 43 |
-
entry = dict(item)
|
| 44 |
-
entry["season"] = info.season
|
| 45 |
-
entry["episode"] = info.episode
|
| 46 |
-
out.append(entry)
|
| 47 |
-
return out
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
def plan_sort(items: List[Dict[str, Any]]) -> SortResult:
|
| 51 |
-
"""Sort the items by season then episode, and find the gaps and the repeats.
|
| 52 |
-
|
| 53 |
-
Files with no episode number are kept — they sort last, in the order they
|
| 54 |
-
arrived, and are reported separately.
|
| 55 |
-
"""
|
| 56 |
-
numbered = [it for it in items if it.get("episode")]
|
| 57 |
-
unnumbered = [it for it in items if not it.get("episode")]
|
| 58 |
-
ordered = sorted(numbered, key=lambda it: (it.get("season") or "", it["episode"])) + unnumbered
|
| 59 |
-
|
| 60 |
-
by_season: Dict[str, set] = {}
|
| 61 |
-
for item in numbered:
|
| 62 |
-
by_season.setdefault(item.get("season") or "", set()).add(int(item["episode"]))
|
| 63 |
-
|
| 64 |
-
missing: List[str] = []
|
| 65 |
-
for season in sorted(by_season):
|
| 66 |
-
episodes = by_season[season]
|
| 67 |
-
for number in range(min(episodes), max(episodes) + 1):
|
| 68 |
-
if number not in episodes:
|
| 69 |
-
missing.append(f"S{season}E{number:02d}")
|
| 70 |
-
|
| 71 |
-
names: Dict[Tuple[str, str], List[str]] = {}
|
| 72 |
-
for item in numbered:
|
| 73 |
-
key = (item.get("season") or "", item["episode"])
|
| 74 |
-
names.setdefault(key, []).append(item.get("name") or "")
|
| 75 |
-
duplicates = [
|
| 76 |
-
Duplicate(season=season, episode=episode, names=files)
|
| 77 |
-
for (season, episode), files in names.items()
|
| 78 |
-
if len(files) > 1
|
| 79 |
-
]
|
| 80 |
-
|
| 81 |
-
return SortResult(
|
| 82 |
-
ordered=ordered,
|
| 83 |
-
missing=missing,
|
| 84 |
-
duplicates=duplicates,
|
| 85 |
-
no_episode=[item.get("name") or "" for item in unnumbered],
|
| 86 |
-
)
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
def format_report(result: SortResult, total: int) -> str:
|
| 90 |
-
"""The message shown once the files have been sent back."""
|
| 91 |
-
lines = [f"✅ Sorted {total} file{'s' if total != 1 else ''}."]
|
| 92 |
-
if result.missing:
|
| 93 |
-
lines += ["", "⚠️ Missing: " + ", ".join(result.missing)]
|
| 94 |
-
if result.duplicates:
|
| 95 |
-
lines += [""]
|
| 96 |
-
for duplicate in result.duplicates:
|
| 97 |
-
lines.append(f"⚠️ Duplicate {duplicate.label}: " + ", ".join(duplicate.names))
|
| 98 |
-
if result.no_episode:
|
| 99 |
-
lines += ["", "⚠️ No episode number: " + ", ".join(result.no_episode)]
|
| 100 |
-
return "\n".join(lines)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
bot/speedtest.py
DELETED
|
@@ -1,115 +0,0 @@
|
|
| 1 |
-
"""Internet speed test, using the official Ookla CLI.
|
| 2 |
-
|
| 3 |
-
The binary is installed in the Docker image (``ookla-speedtest``); see the
|
| 4 |
-
Dockerfile. Everything here is pure except :func:`run_speedtest`, so the
|
| 5 |
-
parsing and the message can be tested without a network.
|
| 6 |
-
"""
|
| 7 |
-
from __future__ import annotations
|
| 8 |
-
|
| 9 |
-
import asyncio
|
| 10 |
-
import json
|
| 11 |
-
from dataclasses import dataclass
|
| 12 |
-
|
| 13 |
-
from .utils import esc
|
| 14 |
-
|
| 15 |
-
OOKLA_BIN = "ookla-speedtest"
|
| 16 |
-
# --accept-license / --accept-gdpr stop the tool waiting for a prompt; the
|
| 17 |
-
# licence notice goes to stderr, so stdout stays pure JSON.
|
| 18 |
-
OOKLA_ARGS = ("--accept-license", "--accept-gdpr", "--format=json")
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
@dataclass
|
| 22 |
-
class SpeedResult:
|
| 23 |
-
download_mbps: float = 0.0
|
| 24 |
-
upload_mbps: float = 0.0
|
| 25 |
-
ping_ms: float = 0.0
|
| 26 |
-
server: str = ""
|
| 27 |
-
isp: str = ""
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
def parse_ookla_json(raw: str) -> SpeedResult:
|
| 31 |
-
"""Read the JSON that ``ookla-speedtest --format=json`` prints.
|
| 32 |
-
|
| 33 |
-
The CLI puts its licence notice on stderr, but be forgiving about anything
|
| 34 |
-
that ends up in front of the JSON.
|
| 35 |
-
"""
|
| 36 |
-
text = (raw or "").strip()
|
| 37 |
-
start = text.find("{")
|
| 38 |
-
if start > 0:
|
| 39 |
-
text = text[start:]
|
| 40 |
-
try:
|
| 41 |
-
data = json.loads(text)
|
| 42 |
-
except (TypeError, ValueError) as exc:
|
| 43 |
-
raise ValueError("the speedtest output was not valid JSON") from exc
|
| 44 |
-
if not isinstance(data, dict):
|
| 45 |
-
raise ValueError("the speedtest output was not a JSON object")
|
| 46 |
-
try:
|
| 47 |
-
download = float(data["download"]["bandwidth"])
|
| 48 |
-
upload = float(data["upload"]["bandwidth"])
|
| 49 |
-
except (KeyError, TypeError, ValueError) as exc:
|
| 50 |
-
raise ValueError("the speedtest output had no download/upload speeds") from exc
|
| 51 |
-
|
| 52 |
-
ping = data.get("ping") or {}
|
| 53 |
-
try:
|
| 54 |
-
# the CLI reports the round-trip time as "latency"; "iqm" is the
|
| 55 |
-
# inter-quartile mean it uses for the per-phase numbers
|
| 56 |
-
ping_ms = float(ping.get("latency") or ping.get("iqm") or 0)
|
| 57 |
-
except (TypeError, ValueError):
|
| 58 |
-
ping_ms = 0.0
|
| 59 |
-
|
| 60 |
-
server = data.get("server") or {}
|
| 61 |
-
name = str(server.get("name") or "").strip()
|
| 62 |
-
location = str(server.get("location") or "").strip()
|
| 63 |
-
country = str(server.get("country") or "").strip()
|
| 64 |
-
where = ", ".join(part for part in (location, country) if part)
|
| 65 |
-
label = " — ".join(part for part in (name, where) if part)
|
| 66 |
-
return SpeedResult(
|
| 67 |
-
download_mbps=download / 1_000_000,
|
| 68 |
-
upload_mbps=upload / 1_000_000,
|
| 69 |
-
ping_ms=ping_ms,
|
| 70 |
-
server=label,
|
| 71 |
-
isp=str(data.get("isp") or "").strip(),
|
| 72 |
-
)
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
def format_speed(result: SpeedResult) -> str:
|
| 76 |
-
"""The message shown in the chat."""
|
| 77 |
-
lines = [
|
| 78 |
-
"<b>📡 Internet speed</b>",
|
| 79 |
-
"",
|
| 80 |
-
f"⬇️ Download: <b>{result.download_mbps:.2f} Mbps</b>",
|
| 81 |
-
f"⬆️ Upload: <b>{result.upload_mbps:.2f} Mbps</b>",
|
| 82 |
-
f"⏱ Ping: <b>{result.ping_ms:.0f} ms</b>",
|
| 83 |
-
]
|
| 84 |
-
if result.server:
|
| 85 |
-
lines.append(f"📍 Server: {esc(result.server)}")
|
| 86 |
-
if result.isp:
|
| 87 |
-
lines.append(f"🏢 ISP: {esc(result.isp)}")
|
| 88 |
-
return "\n".join(lines)
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
async def run_speedtest(binary: str = OOKLA_BIN, timeout: float = 180.0) -> SpeedResult:
|
| 92 |
-
"""Run the Ookla CLI and return the result.
|
| 93 |
-
|
| 94 |
-
Raises ``FileNotFoundError`` when the binary is missing and
|
| 95 |
-
``asyncio.TimeoutError`` when it takes too long.
|
| 96 |
-
"""
|
| 97 |
-
try:
|
| 98 |
-
proc = await asyncio.create_subprocess_exec(
|
| 99 |
-
binary, *OOKLA_ARGS,
|
| 100 |
-
stdin=asyncio.subprocess.DEVNULL,
|
| 101 |
-
stdout=asyncio.subprocess.PIPE,
|
| 102 |
-
stderr=asyncio.subprocess.PIPE,
|
| 103 |
-
)
|
| 104 |
-
except FileNotFoundError as exc:
|
| 105 |
-
raise FileNotFoundError(f"{binary} is not installed") from exc
|
| 106 |
-
try:
|
| 107 |
-
out, err = await asyncio.wait_for(proc.communicate(), timeout)
|
| 108 |
-
except asyncio.TimeoutError:
|
| 109 |
-
proc.kill()
|
| 110 |
-
await proc.wait()
|
| 111 |
-
raise
|
| 112 |
-
if proc.returncode:
|
| 113 |
-
detail = (err or b"").decode("utf-8", "replace").strip()[-300:]
|
| 114 |
-
raise RuntimeError(detail or "the speedtest failed")
|
| 115 |
-
return parse_ookla_json((out or b"").decode("utf-8", "replace"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
bot/telegraph.py
DELETED
|
@@ -1,146 +0,0 @@
|
|
| 1 |
-
"""Publishing a media-info report to telegra.ph.
|
| 2 |
-
|
| 3 |
-
No extra dependency: the public Telegraph API is a couple of HTTP calls, so
|
| 4 |
-
``aiohttp`` (already required) is enough.
|
| 5 |
-
"""
|
| 6 |
-
from __future__ import annotations
|
| 7 |
-
|
| 8 |
-
import json
|
| 9 |
-
from typing import Any, Dict, List, Optional
|
| 10 |
-
|
| 11 |
-
import aiohttp
|
| 12 |
-
|
| 13 |
-
from .utils import esc
|
| 14 |
-
|
| 15 |
-
TELEGRAPH_API = "https://api.telegra.ph"
|
| 16 |
-
AUTHOR_NAME = "Auto Rename Bot"
|
| 17 |
-
SHORT_NAME = "autorename"
|
| 18 |
-
MAX_RAW = 100_000
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
def _tags(raw: Any) -> Dict[str, Any]:
|
| 22 |
-
tags = raw.get("tags") if isinstance(raw, dict) else None
|
| 23 |
-
return tags if isinstance(tags, dict) else {}
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
def summary_lines(probe: Dict[str, Any], name: str) -> List[str]:
|
| 27 |
-
"""The plain-text summary: one line per fact."""
|
| 28 |
-
fmt = probe.get("format") or {}
|
| 29 |
-
lines = [f"File: {name}"]
|
| 30 |
-
if fmt.get("format_name"):
|
| 31 |
-
lines.append(f"Container: {fmt['format_name']}")
|
| 32 |
-
if fmt.get("duration"):
|
| 33 |
-
lines.append(f"Duration: {fmt['duration']} s")
|
| 34 |
-
if fmt.get("size"):
|
| 35 |
-
lines.append(f"Size: {fmt['size']} bytes")
|
| 36 |
-
if fmt.get("bit_rate"):
|
| 37 |
-
lines.append(f"Bit rate: {fmt['bit_rate']}")
|
| 38 |
-
for key, value in _tags(fmt).items():
|
| 39 |
-
lines.append(f"{str(key).title()}: {value}")
|
| 40 |
-
return lines
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
def stream_lines(probe: Dict[str, Any]) -> List[str]:
|
| 44 |
-
lines: List[str] = []
|
| 45 |
-
for stream in probe.get("streams") or []:
|
| 46 |
-
bits = [f"#{stream.get('index')} {stream.get('codec_type') or ''}".strip()]
|
| 47 |
-
if stream.get("codec_name"):
|
| 48 |
-
bits.append(str(stream["codec_name"]))
|
| 49 |
-
if stream.get("width") and stream.get("height"):
|
| 50 |
-
bits.append(f"{stream['width']}x{stream['height']}")
|
| 51 |
-
if stream.get("channels"):
|
| 52 |
-
bits.append(f"{stream['channels']}ch")
|
| 53 |
-
if stream.get("sample_rate"):
|
| 54 |
-
bits.append(f"{stream['sample_rate']} Hz")
|
| 55 |
-
language = _tags(stream).get("language")
|
| 56 |
-
if language:
|
| 57 |
-
bits.append(str(language))
|
| 58 |
-
title = _tags(stream).get("title")
|
| 59 |
-
if title:
|
| 60 |
-
bits.append(f"“{title}”")
|
| 61 |
-
disposition = stream.get("disposition") or {}
|
| 62 |
-
flags = [name for name in ("default", "forced", "attached_pic") if disposition.get(name)]
|
| 63 |
-
if flags:
|
| 64 |
-
bits.append(", ".join(flags))
|
| 65 |
-
lines.append(" · ".join(bit for bit in bits if bit))
|
| 66 |
-
return lines
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
def chapter_lines(probe: Dict[str, Any]) -> List[str]:
|
| 70 |
-
lines: List[str] = []
|
| 71 |
-
for chapter in probe.get("chapters") or []:
|
| 72 |
-
title = _tags(chapter).get("title") or "chapter"
|
| 73 |
-
lines.append(f"{title} — {chapter.get('start_time')} → {chapter.get('end_time')}")
|
| 74 |
-
return lines
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
def build_summary(probe: Dict[str, Any], name: str) -> str:
|
| 78 |
-
"""A plain-text summary — used as the fallback when telegra.ph is unreachable."""
|
| 79 |
-
lines = summary_lines(probe, name)
|
| 80 |
-
streams = stream_lines(probe)
|
| 81 |
-
if streams:
|
| 82 |
-
lines += ["", "Streams:"] + streams
|
| 83 |
-
chapters = chapter_lines(probe)
|
| 84 |
-
if chapters:
|
| 85 |
-
lines += ["", "Chapters:"] + chapters
|
| 86 |
-
return "\n".join(lines)
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
def build_page(probe: Dict[str, Any], name: str) -> str:
|
| 90 |
-
"""The HTML body of the telegra.ph page."""
|
| 91 |
-
parts = ["<p>" + "<br>".join(esc(line) for line in summary_lines(probe, name)) + "</p>"]
|
| 92 |
-
streams = stream_lines(probe)
|
| 93 |
-
if streams:
|
| 94 |
-
parts.append("<p><b>Streams</b><br>" + "<br>".join(esc(line) for line in streams) + "</p>")
|
| 95 |
-
chapters = chapter_lines(probe)
|
| 96 |
-
if chapters:
|
| 97 |
-
parts.append("<p><b>Chapters</b><br>" + "<br>".join(esc(line) for line in chapters) + "</p>")
|
| 98 |
-
raw = json.dumps(probe, indent=2, ensure_ascii=False)
|
| 99 |
-
if len(raw) > MAX_RAW:
|
| 100 |
-
raw = raw[:MAX_RAW] + "\n… (truncated)"
|
| 101 |
-
parts.append("<p><b>Raw ffprobe output</b></p>")
|
| 102 |
-
parts.append(f"<pre><code>{esc(raw)}</code></pre>")
|
| 103 |
-
return "".join(parts)
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
class Telegraph:
|
| 107 |
-
"""A tiny client for the public telegra.ph API."""
|
| 108 |
-
|
| 109 |
-
def __init__(self, token: Optional[str] = None):
|
| 110 |
-
self.token = token
|
| 111 |
-
|
| 112 |
-
async def _post(self, session: aiohttp.ClientSession, method: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
| 113 |
-
async with session.post(f"{TELEGRAPH_API}/{method}", json=payload) as response:
|
| 114 |
-
data = await response.json(content_type=None)
|
| 115 |
-
if not isinstance(data, dict) or not data.get("ok"):
|
| 116 |
-
error = data.get("error") if isinstance(data, dict) else None
|
| 117 |
-
raise RuntimeError(error or f"telegra.ph {method} failed")
|
| 118 |
-
result = data.get("result")
|
| 119 |
-
return result if isinstance(result, dict) else {}
|
| 120 |
-
|
| 121 |
-
async def create_account(self) -> str:
|
| 122 |
-
"""Make a telegra.ph account and return its access token."""
|
| 123 |
-
async with aiohttp.ClientSession() as session:
|
| 124 |
-
result = await self._post(
|
| 125 |
-
session,
|
| 126 |
-
"createAccount",
|
| 127 |
-
{"short_name": SHORT_NAME, "author_name": AUTHOR_NAME},
|
| 128 |
-
)
|
| 129 |
-
return str(result.get("access_token") or "")
|
| 130 |
-
|
| 131 |
-
async def upload_page(self, title: str, content: List[str]) -> str:
|
| 132 |
-
"""Upload a page and return its URL."""
|
| 133 |
-
if not self.token:
|
| 134 |
-
raise RuntimeError("no telegra.ph access token")
|
| 135 |
-
async with aiohttp.ClientSession() as session:
|
| 136 |
-
result = await self._post(
|
| 137 |
-
session,
|
| 138 |
-
"uploadPage",
|
| 139 |
-
{
|
| 140 |
-
"access_token": self.token,
|
| 141 |
-
"title": title,
|
| 142 |
-
"content": content,
|
| 143 |
-
"author_name": AUTHOR_NAME,
|
| 144 |
-
},
|
| 145 |
-
)
|
| 146 |
-
return str(result.get("url") or "")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
bot/utils.py
CHANGED
|
@@ -47,22 +47,3 @@ def progress_bar(fraction: float, width: int = 14) -> str:
|
|
| 47 |
|
| 48 |
def clip(text: str, limit: int) -> str:
|
| 49 |
return text if len(text) <= limit else text[: max(0, limit - 1)] + "…"
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
SMALL_CAPS = {
|
| 53 |
-
"a": "\u1d43", "b": "\u0299", "c": "\u1d04", "d": "\u1d05", "e": "\u1d07",
|
| 54 |
-
"f": "\ua730", "g": "\u0262", "h": "\u029c", "i": "\u026a", "j": "\u1d0a",
|
| 55 |
-
"k": "\u1d0b", "l": "\u029f", "m": "\u1d0d", "n": "\u0274", "o": "\u1d0f",
|
| 56 |
-
"p": "\u1d18", "q": "\u01eb", "r": "\u0280", "s": "\u0073", "t": "\u1d1b",
|
| 57 |
-
"u": "\u1d1c", "v": "\u1d20", "w": "\u1d21", "x": "\u0078", "y": "\u02bf",
|
| 58 |
-
"z": "\u1d22",
|
| 59 |
-
}
|
| 60 |
-
SMALL_CAPS.update({ch.upper(): glyph for ch, glyph in list(SMALL_CAPS.items())})
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
def to_small_caps(text: str) -> str:
|
| 64 |
-
"""Map ``text`` to Unicode small capitals (``"Your" -> "\u02bf\u1d0f\u1d1c\u0280"``).
|
| 65 |
-
|
| 66 |
-
Both ASCII cases map to the same glyph; unmapped characters pass through.
|
| 67 |
-
"""
|
| 68 |
-
return "".join(SMALL_CAPS.get(ch, ch) for ch in (text or ""))
|
|
|
|
| 47 |
|
| 48 |
def clip(text: str, limit: int) -> str:
|
| 49 |
return text if len(text) <= limit else text[: max(0, limit - 1)] + "…"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
docs/superpowers/plans/2026-10-20-media-tools-and-formats.md
DELETED
|
@@ -1,1523 +0,0 @@
|
|
| 1 |
-
# Media Tools & Named Formats Implementation Plan
|
| 2 |
-
|
| 3 |
-
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
| 4 |
-
|
| 5 |
-
**Goal:** Add saved named rename formats, a collect-and-sort episode flow, a `/check` inspection command, thumbnail extraction and image resizing, an Ookla speed test, a telegra.ph media-info report, and an owner-only `/smedia` command that replaces the `START_IMAGE` environment variable.
|
| 6 |
-
|
| 7 |
-
**Architecture:** Eight independent subsystems bolted onto the existing Telethon bot. Pure logic (parsing, sorting, small caps, report building, CLI output parsing) lives in new focused modules and is unit-tested without network or Telegram. Telegram-facing behaviour is added to `bot/app.py` as new handlers and to `bot/panels.py` as pure `(text, buttons)` builders, matching the existing pattern. Persistence goes in three new SQLAlchemy tables created by the existing `Base.metadata.create_all` in `Database.init()`.
|
| 8 |
-
|
| 9 |
-
**Tech Stack:** Python 3.12, Telethon 1.45, SQLAlchemy 2.x (async), ffmpeg/ffprobe, Pillow, aiohttp, pytest + pytest-asyncio.
|
| 10 |
-
|
| 11 |
-
**Spec:** `docs/superpowers/specs/2026-10-20-media-tools-and-formats-design.md` — the plan argues from the spec, so the spec travels with it; executors read both.
|
| 12 |
-
|
| 13 |
-
## Global Constraints
|
| 14 |
-
|
| 15 |
-
- Python 3.12; the project runs on `python:3.12-slim`. No new pip dependency is needed: `aiohttp` (Telegraph), `Pillow` (resize) and `ffmpeg`/`ffprobe` are already present.
|
| 16 |
-
- The Ookla CLI is installed in the Dockerfile, not pip-installed. Version pinned to `ookla-speedtest-1.2.0-linux-x86_64.tgz`.
|
| 17 |
-
- `DATABASE_URL` may be Postgres or SQLite. **Every new primary key column must be `Integer`, not `BigInteger`** — SQLite only auto-increments a column declared exactly `INTEGER PRIMARY KEY`.
|
| 18 |
-
- The existing test suite (266 tests) must keep passing, except the three tests that cover behaviour this work deliberately removes (see Task 4 and Task 10).
|
| 19 |
-
- The bot only reacts in private chats. Unknown users are ignored and reported to the log channel.
|
| 20 |
-
- Every new user-facing string is plain text with Telegram-HTML escaping applied to user-supplied values.
|
| 21 |
-
- Inline-button callback payloads are limited to 64 bytes.
|
| 22 |
-
- `MAX_FORMATS` is 12, per user.
|
| 23 |
-
- A format field is tri-state: `None` = inherit the user's global setting, `""`/`[]` = explicitly empty, a real value = override.
|
| 24 |
-
- The user calls the formats "One Piece format", "Dragon Ball format" — never "Format 1".
|
| 25 |
-
|
| 26 |
-
## Review Focus
|
| 27 |
-
|
| 28 |
-
Five failure modes the spec implies that no task's tests would otherwise exercise. Each line gets a test in the task that owns the code.
|
| 29 |
-
|
| 30 |
-
1. **A format's template must reach the file name.** The name is computed in `App.enqueue`, not in `Renamer.process`; resolving the format only inside the pipeline silently renames every file with the global template. → Task 4.
|
| 31 |
-
2. **A format that explicitly sets "no thumbnail" must send no thumbnail at all** — not the global thumbnail, not an automatic video frame, and never an empty byte string handed to `send_file(thumb=...)`. → Task 4.
|
| 32 |
-
3. **A file with no episode number must survive a sort.** It is not dropped; it sorts last in arrival order and is listed separately in the report. → Task 5.
|
| 33 |
-
4. **An open sequence must suppress auto-rename.** Files sent while a sequence is open are collected, never renamed and re-uploaded. → Task 5.
|
| 34 |
-
5. **A format deleted between enqueue and the run must not break the queued job.** The job carries resolved settings, not a format id. → Task 4.
|
| 35 |
-
|
| 36 |
-
---
|
| 37 |
-
|
| 38 |
-
### Task 1: Mode-aware info, small caps, and the `/check` report
|
| 39 |
-
|
| 40 |
-
**Files:**
|
| 41 |
-
- Modify: `bot/parser.py` (add `effective_info`, refactor `compute_new_name` to use it)
|
| 42 |
-
- Modify: `bot/utils.py` (add `to_small_caps`)
|
| 43 |
-
- Modify: `bot/panels.py` (add `check_report`)
|
| 44 |
-
- Create: `tests/test_check.py`
|
| 45 |
-
|
| 46 |
-
**Interfaces:**
|
| 47 |
-
- Consumes: nothing new. `parser.parse_info`, `parser.parse_caption`, `parser.Info` already exist.
|
| 48 |
-
- Produces:
|
| 49 |
-
- `parser.effective_info(stem: str, caption: str, mode: str) -> Info` — the details a rename would use. For `mode != "caption"` it returns `parse_info(stem)`. For `mode == "caption"` it parses the caption; if that parse has no structured field (season/episode/quality/year) it returns `parse_info(stem)` unchanged, otherwise the caption's values with the filename filling in only the fields the caption left empty.
|
| 50 |
-
- `utils.to_small_caps(text: str) -> str` — maps both cases to Unicode small capitals, leaves unmapped characters alone.
|
| 51 |
-
- `panels.check_report(file_info: Info, caption_info: Info) -> str` — the `/check` message body (HTML-escaped values, no buttons).
|
| 52 |
-
|
| 53 |
-
- [ ] **Step 1: Write the failing tests**
|
| 54 |
-
|
| 55 |
-
In `tests/test_check.py`:
|
| 56 |
-
|
| 57 |
-
```python
|
| 58 |
-
from bot.parser import Info, effective_info
|
| 59 |
-
from bot.panels import check_report
|
| 60 |
-
from bot.utils import to_small_caps
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
def test_to_small_caps_maps_every_letter_and_leaves_the_rest():
|
| 64 |
-
assert to_small_caps("Your") == "\u02bf\u1d0f\u1d1c\u0280" # ʏ ᴏ ᴜ ʀ
|
| 65 |
-
assert to_small_caps("FileName") == to_small_caps("filename")
|
| 66 |
-
assert to_small_caps("123 /mode!") == "123 /mode!"
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
def test_effective_info_uses_the_caption_only_when_it_is_structured():
|
| 70 |
-
stem = "Dragon Ball Super Beerus S01E02 1080p"
|
| 71 |
-
assert effective_info(stem, "", "filename").title == "Dragon Ball Super Beerus"
|
| 72 |
-
# an unstructured caption is discarded entirely
|
| 73 |
-
assert effective_info(stem, "My Show", "caption").title == "Dragon Ball Super Beerus"
|
| 74 |
-
# a structured caption wins, and the filename fills in what it lacks
|
| 75 |
-
info = effective_info(stem, "Beerus 1080p S01E02", "caption")
|
| 76 |
-
assert info.season == "01" and info.episode == "02" and info.quality == "1080p"
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
def test_check_report_has_the_agreed_layout():
|
| 80 |
-
info = Info(title="Dragon Ball Super Beerus", season="01", episode="02", quality="1080p")
|
| 81 |
-
text = check_report(info, info)
|
| 82 |
-
assert "\u02bf\u1d0f\u1d1c\u0280" in text # ʏ ᴏ ᴜ ʀ — the small-capped header
|
| 83 |
-
assert "\ua730\u026a\u029f\u1d07\u0274\u1d43\u1d0d\u1d07:" in text # ꜰɪʟᴇɴᴀᴍᴇ
|
| 84 |
-
assert "\u27a1 Title \u2986 Dragon Ball Super Beerus" in text
|
| 85 |
-
assert "\u27a1 Season \u2986 01" in text
|
| 86 |
-
assert "\u27a1 Episode \u2986 02" in text
|
| 87 |
-
assert "\u27a1 Quality \u2986 1080p" in text
|
| 88 |
-
assert text.count("\u27a1") == 8 # four fields in each of the two sections
|
| 89 |
-
assert "Chapter" not in text
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
def test_check_report_shows_none_for_empty_values_and_extra_fields_only_when_present():
|
| 93 |
-
text = check_report(Info(title="X"), Info(title="X"))
|
| 94 |
-
assert "\u27a1 Season \u2986 None" in text
|
| 95 |
-
assert "Year" not in text and "Codec" not in text
|
| 96 |
-
text = check_report(Info(title="X", year="2023", codec="HEVC"), Info(title="X"))
|
| 97 |
-
assert "\u27a1 Year \u2986 2023" in text and "\u27a1 Codec \u2986 HEVC" in text
|
| 98 |
-
```
|
| 99 |
-
|
| 100 |
-
- [ ] **Step 2: Run the tests to verify they fail**
|
| 101 |
-
|
| 102 |
-
Run: `pytest tests/test_check.py -v`
|
| 103 |
-
Expected: FAIL — `ImportError: cannot import name 'effective_info' from 'bot.parser'`
|
| 104 |
-
|
| 105 |
-
- [ ] **Step 3: Implement `effective_info` in `bot/parser.py`**
|
| 106 |
-
|
| 107 |
-
Add after `parse_caption`:
|
| 108 |
-
|
| 109 |
-
```python
|
| 110 |
-
def effective_info(stem: str, caption: str, mode: str) -> Info:
|
| 111 |
-
"""The details a rename would use for this file.
|
| 112 |
-
|
| 113 |
-
Caption mode only takes the caption when it yields a structured field; an
|
| 114 |
-
unstructured caption is discarded and the filename is used unchanged.
|
| 115 |
-
"""
|
| 116 |
-
info = parse_info(stem)
|
| 117 |
-
if mode != "caption" or not (caption or "").strip():
|
| 118 |
-
return info
|
| 119 |
-
cinfo = parse_caption(caption)
|
| 120 |
-
if not cinfo.structured:
|
| 121 |
-
return info
|
| 122 |
-
for field in ("title", "season", "episode", "quality", "year", "codec"):
|
| 123 |
-
if not getattr(cinfo, field) and getattr(info, field):
|
| 124 |
-
setattr(cinfo, field, getattr(info, field))
|
| 125 |
-
return cinfo
|
| 126 |
-
```
|
| 127 |
-
|
| 128 |
-
Refactor `compute_new_name` to call it: replace its inline merge block with
|
| 129 |
-
`info = effective_info(stem, caption, mode)`. The returned name must not change.
|
| 130 |
-
|
| 131 |
-
- [ ] **Step 4: Implement `to_small_caps` in `bot/utils.py`**
|
| 132 |
-
|
| 133 |
-
A module-level dict mapping each ASCII letter to its small-capital codepoint, and:
|
| 134 |
-
|
| 135 |
-
```python
|
| 136 |
-
def to_small_caps(text: str) -> str:
|
| 137 |
-
"""'Your' -> 'Y\u0282\u1d0f\u1d1c'. Unmapped characters are left alone."""
|
| 138 |
-
return "".join(SMALL_CAPS.get(ch, ch) for ch in (text or ""))
|
| 139 |
-
```
|
| 140 |
-
|
| 141 |
-
The mapping table (both cases map to the same glyph):
|
| 142 |
-
|
| 143 |
-
```
|
| 144 |
-
a \u1d43 b \u0299 c \u1d04 d \u1d05 e \u1d07 f \ua730 g \u0262
|
| 145 |
-
h \u029c i \u026a j \u1d0a k \u1d0b l \u029f m \u1d0d n \u0274
|
| 146 |
-
o \u1d0f p \u1d18 q \u01eb r \u0280 s \u0073 t \u1d1b u \u1d1c
|
| 147 |
-
v \u1d20 w \u1d21 x \u0078 y \u02bf z \u1d22
|
| 148 |
-
```
|
| 149 |
-
|
| 150 |
-
- [ ] **Step 5: Implement `check_report` in `bot/panels.py`**
|
| 151 |
-
|
| 152 |
-
```python
|
| 153 |
-
def check_report(file_info: Info, caption_info: Info) -> str:
|
| 154 |
-
"""The /check body: what the bot extracted from the name and from the caption."""
|
| 155 |
-
```
|
| 156 |
-
|
| 157 |
-
Structure, exactly:
|
| 158 |
-
|
| 159 |
-
```
|
| 160 |
-
<smallcaps "Your Can Change Mode By"> /mode
|
| 161 |
-
|
| 162 |
-
<smallcaps "FileName">:
|
| 163 |
-
➦ Title ⭆ <value>
|
| 164 |
-
➦ Season ⭆ <value>
|
| 165 |
-
➦ Episode ⭆ <value>
|
| 166 |
-
➦ Quality ⭆ <value>
|
| 167 |
-
[➦ Year ⭆ <value>] only when the parser found one
|
| 168 |
-
[➦ Codec ⭆ <value>] only when the parser found one
|
| 169 |
-
|
| 170 |
-
<smallcaps "Caption Mode">:
|
| 171 |
-
➦ ... same fields ...
|
| 172 |
-
```
|
| 173 |
-
|
| 174 |
-
Field order is Title, Season, Episode, Quality, then Year and Codec when present.
|
| 175 |
-
An empty value renders as `None`. Values are HTML-escaped with `esc` from
|
| 176 |
-
`bot.utils`. The section headings are small-capped; the field names and values are not.
|
| 177 |
-
Import `Info` and `to_small_caps` in `bot/panels.py`.
|
| 178 |
-
|
| 179 |
-
- [ ] **Step 6: Run the tests to verify they pass**
|
| 180 |
-
|
| 181 |
-
Run: `pytest tests/test_check.py -v`
|
| 182 |
-
Expected: PASS (4 tests)
|
| 183 |
-
|
| 184 |
-
- [ ] **Step 7: Run the whole suite to confirm nothing regressed**
|
| 185 |
-
|
| 186 |
-
Run: `pytest -q`
|
| 187 |
-
Expected: all previously passing tests still pass (266 + 4)
|
| 188 |
-
|
| 189 |
-
- [ ] **Step 8: Commit**
|
| 190 |
-
|
| 191 |
-
```bash
|
| 192 |
-
git add bot/parser.py bot/utils.py bot/panels.py tests/test_check.py
|
| 193 |
-
git commit -m "feat: mode-aware info parsing, small caps and the /check report"
|
| 194 |
-
```
|
| 195 |
-
|
| 196 |
-
---
|
| 197 |
-
|
| 198 |
-
### Task 2: Database tables for formats, sequences and the start banner
|
| 199 |
-
|
| 200 |
-
**Files:**
|
| 201 |
-
- Modify: `bot/db.py`
|
| 202 |
-
- Modify: `tests/test_db.py`
|
| 203 |
-
|
| 204 |
-
**Interfaces:**
|
| 205 |
-
- Consumes: nothing new.
|
| 206 |
-
- Produces:
|
| 207 |
-
- `db.MAX_FORMATS: int` — 12.
|
| 208 |
-
- Model `Format` with columns `id` (Integer pk), `user_id` (BigInteger, indexed), `name` (String 64), `position` (Integer), `template` (Text, nullable), `thumb` (LargeBinary, nullable), `meta` (Text, JSON), `keep_audio` (Text, nullable), `keep_subtitle` (Text, nullable). Unique on `(user_id, name)`.
|
| 209 |
-
- Model `SequenceRow` with `user_id` (BigInteger pk), `items` (Text, JSON list), `copied` (Integer), `updated_at` (DateTime).
|
| 210 |
-
- Model `BotMedia` with `id` (Integer pk), `kind` (String 8), `data` (LargeBinary).
|
| 211 |
-
- `Database.list_formats(uid) -> List[Dict]`, `get_format(uid, fmt_id) -> Optional[Dict]`,
|
| 212 |
-
`create_format(uid, name) -> Optional[Dict]` (None when the name is taken or 12 exist),
|
| 213 |
-
`update_format(uid, fmt_id, **changes) -> bool`, `delete_format(uid, fmt_id) -> bool`,
|
| 214 |
-
`count_formats(uid) -> int`.
|
| 215 |
-
- `Database.get_sequence(uid) -> Optional[Dict]` (`{"items": [...], "copied": int}`),
|
| 216 |
-
`set_sequence(uid, items, copied=0) -> None`, `clear_sequence(uid) -> None`.
|
| 217 |
-
- `Database.get_start_media() -> Optional[Dict]` (`{"kind": "image"|"gif", "data": bytes}`),
|
| 218 |
-
`set_start_media(kind, data) -> None`, `clear_start_media() -> None`.
|
| 219 |
-
- The format dict shape is `{"id": int, "name": str, "template": Optional[str], "thumb": Optional[bytes], "meta": Dict[str, Optional[str]], "keep_audio": Optional[List[str]], "keep_subtitle": Optional[List[str]]}`. `meta` always has the six keys `title, author, artist, audio, subtitle, video`, each `None` (inherit) or a string.
|
| 220 |
-
|
| 221 |
-
- [ ] **Step 1: Write the failing tests**
|
| 222 |
-
|
| 223 |
-
Append to `tests/test_db.py`:
|
| 224 |
-
|
| 225 |
-
```python
|
| 226 |
-
async def test_formats_crud_and_the_twelve_slot_limit(db):
|
| 227 |
-
assert await db.count_formats(7) == 0
|
| 228 |
-
first = await db.create_format(7, "One Piece format")
|
| 229 |
-
assert first["id"] and first["name"] == "One Piece format"
|
| 230 |
-
assert first["template"] is None and first["keep_audio"] is None
|
| 231 |
-
assert first["meta"]["title"] is None
|
| 232 |
-
assert await db.create_format(7, "One Piece format") is None # the name is taken
|
| 233 |
-
for i in range(11):
|
| 234 |
-
assert await db.create_format(7, f"F{i}") is not None
|
| 235 |
-
assert await db.count_formats(7) == 12
|
| 236 |
-
assert await db.create_format(7, "Thirteenth") is None # the limit
|
| 237 |
-
assert await db.count_formats(8) == 0 # per user
|
| 238 |
-
assert [f["name"] for f in await db.list_formats(7)][0] == "One Piece format"
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
async def test_format_fields_round_trip_through_the_database(db):
|
| 242 |
-
fmt = await db.create_format(7, "Dragon Ball")
|
| 243 |
-
await db.update_format(7, fmt["id"], template="[S{season}E{episode}] {title}",
|
| 244 |
-
meta={"title": "", "author": "Kuririn"},
|
| 245 |
-
keep_audio=[], keep_subtitle=["english"])
|
| 246 |
-
got = await db.get_format(7, fmt["id"])
|
| 247 |
-
assert got["template"] == "[S{season}E{episode}] {title}"
|
| 248 |
-
assert got["meta"]["title"] == "" and got["meta"]["author"] == "Kuririn"
|
| 249 |
-
assert got["meta"]["artist"] is None
|
| 250 |
-
assert got["keep_audio"] == [] and got["keep_subtitle"] == ["english"]
|
| 251 |
-
assert await db.get_format(9, fmt["id"]) is None # another user
|
| 252 |
-
assert await db.delete_format(7, fmt["id"]) is True
|
| 253 |
-
assert await db.delete_format(7, fmt["id"]) is False
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
async def test_sequence_and_start_media(db):
|
| 257 |
-
assert await db.get_sequence(3) is None
|
| 258 |
-
await db.set_sequence(3, [{"msg_id": 1, "name": "a.mkv", "caption": ""}])
|
| 259 |
-
seq = await db.get_sequence(3)
|
| 260 |
-
assert seq["items"][0]["name"] == "a.mkv" and seq["copied"] == 0
|
| 261 |
-
await db.set_sequence(3, seq["items"], copied=2)
|
| 262 |
-
assert (await db.get_sequence(3))["copied"] == 2
|
| 263 |
-
await db.clear_sequence(3)
|
| 264 |
-
assert await db.get_sequence(3) is None
|
| 265 |
-
|
| 266 |
-
assert await db.get_start_media() is None
|
| 267 |
-
await db.set_start_media("gif", b"\x00gif")
|
| 268 |
-
assert (await db.get_start_media())["kind"] == "gif"
|
| 269 |
-
assert (await db.get_start_media())["data"] == b"\x00gif"
|
| 270 |
-
await db.set_start_media("image", b"\xff\xd8")
|
| 271 |
-
assert (await db.get_start_media())["kind"] == "image" # one row, replaced
|
| 272 |
-
await db.clear_start_media()
|
| 273 |
-
assert await db.get_start_media() is None
|
| 274 |
-
```
|
| 275 |
-
|
| 276 |
-
- [ ] **Step 2: Run the tests to verify they fail**
|
| 277 |
-
|
| 278 |
-
Run: `pytest tests/test_db.py -v`
|
| 279 |
-
Expected: FAIL — `AttributeError: 'Database' object has no attribute 'count_formats'`
|
| 280 |
-
|
| 281 |
-
- [ ] **Step 3: Add the models to `bot/db.py`**
|
| 282 |
-
|
| 283 |
-
Add `UniqueConstraint` to the sqlalchemy import. Add the three models after `class Admin`. Use
|
| 284 |
-
`mapped_column(Integer, primary_key=True, autoincrement=True)` for `Format.id` and `BotMedia.id`
|
| 285 |
-
— **not** `BigInteger`, because SQLite only auto-increments a column declared exactly
|
| 286 |
-
`INTEGER PRIMARY KEY`. `Format` gets `__table_args__ = (UniqueConstraint("user_id", "name"),)`.
|
| 287 |
-
|
| 288 |
-
`Format.meta` stores a JSON object; `keep_audio` / `keep_subtitle` store `NULL` for "inherit"
|
| 289 |
-
and a JSON array otherwise, so `NULL` and `[]` stay distinguishable.
|
| 290 |
-
|
| 291 |
-
- [ ] **Step 4: Add the `Database` methods to `bot/db.py`**
|
| 292 |
-
|
| 293 |
-
Add `MAX_FORMATS = 12` at module level.
|
| 294 |
-
|
| 295 |
-
`create_format` must: reject a blank name, reject a name already used by that user, reject the
|
| 296 |
-
call when `count_formats(uid) >= MAX_FORMATS`, and otherwise insert with `position` equal to the
|
| 297 |
-
current count. It returns the created format dict, or `None` on any rejection.
|
| 298 |
-
|
| 299 |
-
`update_format(uid, fmt_id, **changes)` accepts only the keys `name`, `template`, `thumb`,
|
| 300 |
-
`meta`, `keep_audio`, `keep_subtitle`. It must only touch rows belonging to `uid` and returns
|
| 301 |
-
whether a row was changed. `meta` is merged key by key over the stored dict; `keep_audio` and
|
| 302 |
-
`keep_subtitle` are replaced wholesale and `None` is stored as SQL `NULL`.
|
| 303 |
-
|
| 304 |
-
Every format read decodes `meta` from JSON (a missing key reads back as `None`) and decodes
|
| 305 |
-
`keep_audio` / `keep_subtitle` from JSON, leaving `None` as `None`.
|
| 306 |
-
|
| 307 |
-
`get_sequence` returns `None` when there is no row, otherwise `{"items": json.loads(row.items), "copied": row.copied}`.
|
| 308 |
-
`set_sequence` upserts the row for that user. `clear_sequence` deletes the row.
|
| 309 |
-
|
| 310 |
-
`get_start_media` returns the single row (`id == 1`) as `{"kind": ..., "data": bytes(row.data)}` or `None`.
|
| 311 |
-
`set_start_media` replaces the row (`id == 1`). `clear_start_media` deletes it.
|
| 312 |
-
|
| 313 |
-
- [ ] **Step 5: Run the tests to verify they pass**
|
| 314 |
-
|
| 315 |
-
Run: `pytest tests/test_db.py -v`
|
| 316 |
-
Expected: PASS (3 new tests plus the existing ones)
|
| 317 |
-
|
| 318 |
-
- [ ] **Step 6: Run the whole suite**
|
| 319 |
-
|
| 320 |
-
Run: `pytest -q`
|
| 321 |
-
Expected: everything passes
|
| 322 |
-
|
| 323 |
-
- [ ] **Step 7: Commit**
|
| 324 |
-
|
| 325 |
-
```bash
|
| 326 |
-
git add bot/db.py tests/test_db.py
|
| 327 |
-
git commit -m "feat: database tables for formats, sequences and the start banner"
|
| 328 |
-
```
|
| 329 |
-
|
| 330 |
-
---
|
| 331 |
-
|
| 332 |
-
### Task 3: Resolving a format into effective settings
|
| 333 |
-
|
| 334 |
-
**Files:**
|
| 335 |
-
- Create: `bot/formats.py`
|
| 336 |
-
- Create: `tests/test_formats.py`
|
| 337 |
-
|
| 338 |
-
**Interfaces:**
|
| 339 |
-
- Consumes: `db.get_format` and the format dict shape from Task 2; `db.DEFAULT_SETTINGS` from `bot.db`.
|
| 340 |
-
- Produces:
|
| 341 |
-
- `formats.MAX_FORMATS: int` — re-exported from `bot.db`.
|
| 342 |
-
- `formats.META_FIELDS: Tuple[str, ...]` — `("title", "author", "artist", "audio", "subtitle", "video")`.
|
| 343 |
-
- `formats.resolve_format(fmt: Dict[str, Any], settings: Dict[str, Any]) -> Dict[str, Any]` — a deep copy of `settings` with the format's non-`None` fields overlaid. Overlays `template`, each non-`None` key of `meta`, and `keep_audio` / `keep_subtitle` when they are not `None`.
|
| 344 |
-
- `formats.resolve_thumbnail(fmt: Dict[str, Any], global_thumb: Optional[bytes]) -> Tuple[Optional[bytes], bool]` — the thumbnail bytes to send and whether an automatic video frame may still be used as a fallback.
|
| 345 |
-
- `formats.apply_format_field(db, uid, fmt_id, kind, text) -> Tuple[bool, str]` — validates and stores one format field, returning `(ok, html_message)`. Kinds: `fmt_name`, `fmt_template`, `fmt_title`, `fmt_author`, `fmt_artist`, `fmt_audio`, `fmt_subtitle`, `fmt_video`, `fmt_keep_audio`, `fmt_keep_subtitle`.
|
| 346 |
-
|
| 347 |
-
- [ ] **Step 1: Write the failing tests**
|
| 348 |
-
|
| 349 |
-
Create `tests/test_formats.py`:
|
| 350 |
-
|
| 351 |
-
```python
|
| 352 |
-
from bot.db import DEFAULT_SETTINGS
|
| 353 |
-
from bot.formats import apply_format_field, resolve_format, resolve_thumbnail
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
def test_a_format_that_inherits_everything_changes_nothing():
|
| 357 |
-
fmt = {"template": None, "meta": {f: None for f in ("title", "author", "artist", "audio", "subtitle", "video")},
|
| 358 |
-
"keep_audio": None, "keep_subtitle": None}
|
| 359 |
-
settings = dict(DEFAULT_SETTINGS, template="GLOBAL", keep_audio=["english"])
|
| 360 |
-
assert resolve_format(fmt, settings) == settings
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
def test_a_format_overlays_only_the_fields_it_sets():
|
| 364 |
-
fmt = {"template": "[S{season}E{episode}] {title}",
|
| 365 |
-
"meta": {"title": "One Piece", "author": None, "artist": None, "audio": None, "subtitle": None, "video": None},
|
| 366 |
-
"keep_audio": [], "keep_subtitle": None}
|
| 367 |
-
settings = dict(DEFAULT_SETTINGS, template="GLOBAL", keep_audio=["english"], keep_subtitle=["japanese"])
|
| 368 |
-
out = resolve_format(fmt, settings)
|
| 369 |
-
assert out["template"] == "[S{season}E{episode}] {title}"
|
| 370 |
-
assert out["meta"]["title"] == "One Piece" and out["meta"]["author"] == ""
|
| 371 |
-
assert out["keep_audio"] == [] and out["keep_subtitle"] == ["japanese"] # inherit
|
| 372 |
-
assert settings["template"] == "GLOBAL" # the input is not mutated
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
def test_resolve_thumbnail_states():
|
| 376 |
-
inherit = {"thumb": None}
|
| 377 |
-
custom = {"thumb": b"\xff\xd8jpeg"}
|
| 378 |
-
none = {"thumb": b""}
|
| 379 |
-
assert resolve_thumbnail(inherit, b"GLOBAL") == (b"GLOBAL", False)
|
| 380 |
-
assert resolve_thumbnail(inherit, None) == (None, True) # a video frame may still be used
|
| 381 |
-
assert resolve_thumbnail(custom, b"GLOBAL") == (b"\xff\xd8jpeg", False)
|
| 382 |
-
assert resolve_thumbnail(none, b"GLOBAL") == (None, False) # explicitly none: never a frame
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
async def test_apply_format_field_clearing_and_inheriting(db):
|
| 386 |
-
fmt = await db.create_format(1, "One Piece")
|
| 387 |
-
ok, msg = await apply_format_field(db, 1, fmt["id"], "fmt_title", "One Piece")
|
| 388 |
-
assert ok and "One Piece" in msg
|
| 389 |
-
ok, _ = await apply_format_field(db, 1, fmt["id"], "fmt_title", "off")
|
| 390 |
-
assert ok
|
| 391 |
-
assert (await db.get_format(1, fmt["id"]))["meta"]["title"] == "" # explicitly empty
|
| 392 |
-
ok, _ = await apply_format_field(db, 1, fmt["id"], "fmt_keep_audio", "off")
|
| 393 |
-
assert ok
|
| 394 |
-
assert (await db.get_format(1, fmt["id"]))["keep_audio"] == [] # keep every track
|
| 395 |
-
ok, msg = await apply_format_field(db, 1, fmt["id"], "fmt_name", "off")
|
| 396 |
-
assert not ok # a format must have a name
|
| 397 |
-
```
|
| 398 |
-
|
| 399 |
-
- [ ] **Step 2: Run the tests to verify they fail**
|
| 400 |
-
|
| 401 |
-
Run: `pytest tests/test_formats.py -v`
|
| 402 |
-
Expected: FAIL — `ModuleNotFoundError: No module named 'bot.formats'`
|
| 403 |
-
|
| 404 |
-
- [ ] **Step 3: Implement `bot/formats.py`**
|
| 405 |
-
|
| 406 |
-
`resolve_format` deep-copies `settings` (use `copy.deepcopy`), then applies the overlays. The
|
| 407 |
-
`meta` overlay merges field by field over the copied `settings["meta"]`, keeping the six
|
| 408 |
-
`META_FIELDS` keys.
|
| 409 |
-
|
| 410 |
-
`resolve_thumbnail` implements the three states exactly as the test pins them:
|
| 411 |
-
a `None` thumb inherits the global thumbnail (and only allows a video frame when there is no
|
| 412 |
-
global thumbnail at all), a non-empty thumb is the custom one, and an empty thumb means
|
| 413 |
-
"no thumbnail at all" — never a video frame.
|
| 414 |
-
|
| 415 |
-
`apply_format_field` maps each kind onto a storage key:
|
| 416 |
-
|
| 417 |
-
| kind | stored as | `off` means |
|
| 418 |
-
|---|---|---|
|
| 419 |
-
| `fmt_name` | `name` | rejected — a format must have a name |
|
| 420 |
-
| `fmt_template` | `template` | inherit (`None`) — an empty template is not a valid state |
|
| 421 |
-
| `fmt_title` … `fmt_video` | `meta[<field>]` | explicitly empty (`""`) |
|
| 422 |
-
| `fmt_keep_audio`, `fmt_keep_subtitle` | `keep_audio` / `keep_subtitle` | explicitly empty (`[]`) — keep every track |
|
| 423 |
-
|
| 424 |
-
Reuse `parse_track_tokens` and `validate_track_tokens` from `bot.media` for the keep lists, and
|
| 425 |
-
`CLEAR_WORDS` from `bot.settings_ops` for the `off` check. The template is validated with the
|
| 426 |
-
same rules the global template uses (non-empty, at most 200 characters).
|
| 427 |
-
|
| 428 |
-
- [ ] **Step 4: Run the tests to verify they pass**
|
| 429 |
-
|
| 430 |
-
Run: `pytest tests/test_formats.py -v`
|
| 431 |
-
Expected: PASS (4 tests)
|
| 432 |
-
|
| 433 |
-
- [ ] **Step 5: Run the whole suite**
|
| 434 |
-
|
| 435 |
-
Run: `pytest -q`
|
| 436 |
-
Expected: everything passes
|
| 437 |
-
|
| 438 |
-
- [ ] **Step 6: Commit**
|
| 439 |
-
|
| 440 |
-
```bash
|
| 441 |
-
git add bot/formats.py tests/test_formats.py
|
| 442 |
-
git commit -m "feat: resolve a saved format into effective settings"
|
| 443 |
-
```
|
| 444 |
-
|
| 445 |
-
---
|
| 446 |
-
|
| 447 |
-
### Task 4: `/rename` as a format picker, and the format editor
|
| 448 |
-
|
| 449 |
-
**Files:**
|
| 450 |
-
- Modify: `bot/queue.py` (`Job` gains the override fields)
|
| 451 |
-
- Modify: `bot/pipeline.py` (apply the override; honour the thumbnail state)
|
| 452 |
-
- Modify: `bot/panels.py` (the format list and the format editor panels)
|
| 453 |
-
- Modify: `bot/app.py` (the `/rename` and `/formats` commands, the callbacks, the enqueue path)
|
| 454 |
-
- Modify: `tests/test_app.py` (rewrite the two removed-behaviour tests, add the new ones)
|
| 455 |
-
|
| 456 |
-
**Interfaces:**
|
| 457 |
-
- Consumes: `db.list_formats` / `get_format` / `create_format` / `update_format` / `delete_format` from Task 2; `formats.resolve_format`, `formats.resolve_thumbnail`, `formats.apply_format_field` from Task 3.
|
| 458 |
-
- Produces:
|
| 459 |
-
- `queue.Job.thumbnail_mode: str` — `"auto"` (use the global thumbnail), `"custom"` (use `Job.thumbnail`), `"none"` (send no thumbnail at all). Default `"auto"`.
|
| 460 |
-
- `queue.Job.thumbnail: Optional[bytes]` — the custom thumbnail bytes, used when `thumbnail_mode == "custom"`.
|
| 461 |
-
- `queue.Job.settings_override: Optional[Dict[str, Any]]` — the already-resolved effective settings.
|
| 462 |
-
- `panels.formats_list(formats: Sequence[Dict], *, for_rename: bool) -> Panel` — the picker.
|
| 463 |
-
- `panels.format_editor(fmt: Dict, settings: Dict) -> Panel` — the editor.
|
| 464 |
-
- `app.App.format_panel(uid: int, fmt_id: int) -> Panel` — loads the format and the global settings and returns the editor panel.
|
| 465 |
-
|
| 466 |
-
**Callback data** (all under 64 bytes):
|
| 467 |
-
|
| 468 |
-
| data | meaning |
|
| 469 |
-
|---|---|
|
| 470 |
-
| `fm:<id>` | use this format for the next file |
|
| 471 |
-
| `fl:<id>` | open this format in the editor |
|
| 472 |
-
| `fnew` | create a format (asks for a name) |
|
| 473 |
-
| `fset:<field>:<id>` | ask for a text value for a field |
|
| 474 |
-
| `fdef:<field>:<id>` | set a field back to "inherit my default" |
|
| 475 |
-
| `fthumb:<id>` | ask for a photo (the thumbnail field) |
|
| 476 |
-
| `fthumbdef:<id>` | thumbnail → inherit the global thumbnail |
|
| 477 |
-
| `fthumbnone:<id>` | thumbnail → explicitly no thumbnail |
|
| 478 |
-
| `fdel:<id>` | ask to confirm the delete |
|
| 479 |
-
| `fdelok:<id>` | delete it |
|
| 480 |
-
| `frename:<id>` | ask for a new name |
|
| 481 |
-
|
| 482 |
-
- [ ] **Step 1: Write the failing tests**
|
| 483 |
-
|
| 484 |
-
In `tests/test_app.py`, **replace** `test_rename_command_uses_the_given_name_and_keeps_the_extension`
|
| 485 |
-
and `test_rename_usage_messages` with:
|
| 486 |
-
|
| 487 |
-
```python
|
| 488 |
-
async def test_rename_picks_a_format_then_renames_the_next_file(ctx):
|
| 489 |
-
ev = await say(ctx, OWNER, "/rename")
|
| 490 |
-
assert "fmt" not in ev.out[0][0] # no formats yet
|
| 491 |
-
assert "fnew" in data_of(ev.out[0][1]) # only "create a format" is offered
|
| 492 |
-
|
| 493 |
-
fmt = await ctx.db.create_format(OWNER, "One Piece format")
|
| 494 |
-
ev = await say(ctx, OWNER, "/rename")
|
| 495 |
-
assert "One Piece format" in ev.out[0][0]
|
| 496 |
-
assert f"fm:{fmt['id']}" in data_of(ev.out[0][1])
|
| 497 |
-
|
| 498 |
-
await click(ctx, OWNER, f"fm:{fmt['id']}")
|
| 499 |
-
assert ctx.app.pending[OWNER].kind == "rename_format"
|
| 500 |
-
await send_doc(ctx, OWNER, "[SubsPlease] Tokyo Revengers - 05 (1080p) [ABCD1234].mkv")
|
| 501 |
-
(job,) = ctx.app.queue.jobs.values()
|
| 502 |
-
assert job.new_name == EPISODE_NEW # the format's template was applied
|
| 503 |
-
assert OWNER not in ctx.app.pending # one file per /rename
|
| 504 |
-
```
|
| 505 |
-
|
| 506 |
-
Add a test that the format's template really reaches the name (Review Focus 1):
|
| 507 |
-
|
| 508 |
-
```python
|
| 509 |
-
async def test_a_formats_template_reaches_the_file_name(ctx):
|
| 510 |
-
fmt = await ctx.db.create_format(OWNER, "One Piece")
|
| 511 |
-
await ctx.db.update_format(OWNER, fmt["id"], template="TR - {title} - E{episode}")
|
| 512 |
-
await click(ctx, OWNER, f"fm:{fmt['id']}")
|
| 513 |
-
await send_doc(ctx, OWNER, "Tokyo Revengers - 07 (1080p).mkv")
|
| 514 |
-
(job,) = ctx.app.queue.jobs.values()
|
| 515 |
-
assert job.new_name == "TR - Tokyo Revengers - E07.mkv"
|
| 516 |
-
assert job.settings_override["template"] == "TR - {title} - E{episode}"
|
| 517 |
-
```
|
| 518 |
-
|
| 519 |
-
Add a test for the thumbnail states (Review Focus 2):
|
| 520 |
-
|
| 521 |
-
```python
|
| 522 |
-
async def test_a_format_can_suppress_the_thumbnail_entirely(ctx):
|
| 523 |
-
fmt = await ctx.db.create_format(OWNER, "No Thumb")
|
| 524 |
-
await ctx.db.update_format(OWNER, fmt["id"], thumb=b"")
|
| 525 |
-
await click(ctx, OWNER, f"fm:{fmt['id']}")
|
| 526 |
-
await send_doc(ctx, OWNER, "Movie.mkv")
|
| 527 |
-
(job,) = ctx.app.queue.jobs.values()
|
| 528 |
-
assert job.thumbnail_mode == "none" and job.thumbnail is None
|
| 529 |
-
```
|
| 530 |
-
|
| 531 |
-
Add a test for a deleted format (Review Focus 5):
|
| 532 |
-
|
| 533 |
-
```python
|
| 534 |
-
async def test_deleting_a_format_does_not_break_a_queued_job(ctx):
|
| 535 |
-
fmt = await ctx.db.create_format(OWNER, "Temp")
|
| 536 |
-
await click(ctx, OWNER, f"fm:{fmt['id']}")
|
| 537 |
-
await send_doc(ctx, OWNER, "Movie.mkv")
|
| 538 |
-
(job,) = ctx.app.queue.jobs.values()
|
| 539 |
-
await ctx.db.delete_format(OWNER, fmt["id"])
|
| 540 |
-
assert job.settings_override is not None # the job carries resolved settings, not an id
|
| 541 |
-
```
|
| 542 |
-
|
| 543 |
-
And the format-editor tests:
|
| 544 |
-
|
| 545 |
-
```python
|
| 546 |
-
async def test_the_format_editor_saves_every_field(ctx):
|
| 547 |
-
fmt = await ctx.db.create_format(OWNER, "One Piece")
|
| 548 |
-
ev = await click(ctx, OWNER, f"fl:{fmt['id']}")
|
| 549 |
-
assert "One Piece" in ev.edits[0][0]
|
| 550 |
-
await click(ctx, OWNER, f"fset:fmt_title:{fmt['id']}")
|
| 551 |
-
assert ctx.app.pending[OWNER].kind == "fmt_title"
|
| 552 |
-
await say(ctx, OWNER, "One Piece")
|
| 553 |
-
got = await ctx.db.get_format(OWNER, fmt["id"])
|
| 554 |
-
assert got["meta"]["title"] == "One Piece"
|
| 555 |
-
|
| 556 |
-
await click(ctx, OWNER, f"fdef:fmt_title:{fmt['id']}")
|
| 557 |
-
assert (await ctx.db.get_format(OWNER, fmt["id"]))["meta"]["title"] is None # inherit
|
| 558 |
-
|
| 559 |
-
await click(ctx, OWNER, f"fthumbnone:{fmt['id']}")
|
| 560 |
-
assert (await ctx.db.get_format(OWNER, fmt["id"]))["thumb"] == b""
|
| 561 |
-
```
|
| 562 |
-
|
| 563 |
-
- [ ] **Step 2: Run the tests to verify they fail**
|
| 564 |
-
|
| 565 |
-
Run: `pytest tests/test_app.py -v -k "format or rename"`
|
| 566 |
-
Expected: FAIL — the `/rename` command still expects a name, and `Job` has no `thumbnail_mode`.
|
| 567 |
-
|
| 568 |
-
- [ ] **Step 3: Add the override fields to `Job` in `bot/queue.py`**
|
| 569 |
-
|
| 570 |
-
```python
|
| 571 |
-
thumbnail_mode: str = "auto" # auto | custom | none
|
| 572 |
-
thumbnail: Optional[bytes] = None
|
| 573 |
-
settings_override: Optional[Dict[str, Any]] = None
|
| 574 |
-
```
|
| 575 |
-
|
| 576 |
-
Remove the `manual: bool = False` field at the same time (see Step 7).
|
| 577 |
-
|
| 578 |
-
- [ ] **Step 4: Apply the override in `bot/pipeline.py`**
|
| 579 |
-
|
| 580 |
-
In `Renamer.process`, replace `s = await self.db.get_settings(job.user_id)` with a merge of
|
| 581 |
-
the global settings and `job.settings_override` (the override is already the full effective
|
| 582 |
-
dict, so the merge is a plain replacement when it is not `None`).
|
| 583 |
-
|
| 584 |
-
In `Renamer._run`, replace the thumbnail block with:
|
| 585 |
-
|
| 586 |
-
```python
|
| 587 |
-
if job.thumbnail_mode == "none":
|
| 588 |
-
thumb = None
|
| 589 |
-
elif job.thumbnail_mode == "custom":
|
| 590 |
-
thumb = job.thumbnail
|
| 591 |
-
else:
|
| 592 |
-
thumb = await self.db.get_thumb(job.user_id)
|
| 593 |
-
if thumb is None and ext.lower() in media.VIDEO_CONTAINERS and info is not None and info.of("video"):
|
| 594 |
-
...the existing video-frame fallback...
|
| 595 |
-
```
|
| 596 |
-
|
| 597 |
-
Never pass an empty byte string to `send_file(thumb=...)`.
|
| 598 |
-
|
| 599 |
-
- [ ] **Step 5: Add the two panels to `bot/panels.py`**
|
| 600 |
-
|
| 601 |
-
`formats_list(formats, *, for_rename)` — the text names the command (`/rename` or `/formats`)
|
| 602 |
-
and lists one button per format showing its name, with a marker when the format overrides the
|
| 603 |
-
global template. Add a "➕ New format" button (`fnew`) only when `len(formats) < MAX_FORMATS`.
|
| 604 |
-
Add `nav("home")`.
|
| 605 |
-
|
| 606 |
-
`format_editor(fmt, settings)` — one row per field with buttons `fset:<kind>:<id>` for the
|
| 607 |
-
template, the six metadata fields and the two keep lists; a thumbnail row with `fthumb:<id>`,
|
| 608 |
-
`fthumbdef:<id>` and `fthumbnone:<id>`; a name row (`frename:<id>`) and a delete row
|
| 609 |
-
(`fdel:<id>`). Every field shows its current value, and marks an inherited field as
|
| 610 |
-
"my default" so the tri-state is visible. Back goes to `m:formats`.
|
| 611 |
-
|
| 612 |
-
- [ ] **Step 6: Add `"formats"` to `PANEL_NAMES` in `bot/app.py`**
|
| 613 |
-
|
| 614 |
-
`PANEL_NAMES = {"home", "rename", "cap", "meta", "tool", "ftype", "help", "admin", "formats"}`.
|
| 615 |
-
|
| 616 |
-
- [ ] **Step 7: Add the `/rename` and `/formats` commands to `bot/app.py`**
|
| 617 |
-
|
| 618 |
-
`cmd_rename(event)`:
|
| 619 |
-
- If the command has an argument, reply
|
| 620 |
-
"❌ /rename no longer takes a name. Pick a format, then send me the file."
|
| 621 |
-
- Otherwise reply with `panels.formats_list(await self.db.list_formats(uid), for_rename=True)`.
|
| 622 |
-
|
| 623 |
-
`cmd_formats(event)`: reply with `panels.formats_list(await self.db.list_formats(uid), for_rename=False)`.
|
| 624 |
-
|
| 625 |
-
`cmd_rename` must be reachable as `/rename` with no argument. The old `/rename <name>` path
|
| 626 |
-
goes away, and with it:
|
| 627 |
-
|
| 628 |
-
- The `manual` parameter of `App.enqueue` and the `manual=` argument on the `Job` it builds.
|
| 629 |
-
- The `manual` field on `queue.Job`. Update the assertion in `tests/test_app.py` that reads
|
| 630 |
-
`not job.manual` (the "queued" test) to drop that part — it becomes
|
| 631 |
-
`assert job.file_name == EPISODE and job.size == 50 * 1024 * 1024 and job.state == "queued"`.
|
| 632 |
-
- The `build_manual_name` import in `bot/app.py`. Keep `parser.build_manual_name` and its
|
| 633 |
-
tests in `tests/test_parser.py` — it is a small tested public helper, the bot simply
|
| 634 |
-
stops calling it.
|
| 635 |
-
|
| 636 |
-
- [ ] **Step 8: Wire the pending state and the callbacks in `bot/app.py`**
|
| 637 |
-
|
| 638 |
-
`Pending` gains `format_id: Optional[int] = None`. `Pending.kind` gains the value
|
| 639 |
-
`"rename_format"` and the ten `fmt_*` kinds from Task 3. Every format pending uses
|
| 640 |
-
`back="formats"` so the existing `Pending` construction stays valid.
|
| 641 |
-
|
| 642 |
-
`on_text` branches: when `p.format_id is not None`, call
|
| 643 |
-
`apply_format_field(self.db, uid, p.format_id, p.kind, event.raw_text)` and re-render the
|
| 644 |
-
editor with `await self.format_panel(uid, p.format_id)`; otherwise keep the existing
|
| 645 |
-
`apply_setting` path unchanged.
|
| 646 |
-
|
| 647 |
-
`on_photo` branches: when the pending kind is `fmt_thumb`, store the prepared photo in the
|
| 648 |
-
format (`update_format(..., thumb=data)`) and re-render the editor; otherwise keep the existing
|
| 649 |
-
global-thumbnail behaviour.
|
| 650 |
-
|
| 651 |
-
`on_media` branches: when the pending kind is `rename_format`, enqueue the file with that
|
| 652 |
-
format and clear the pending; otherwise enqueue normally.
|
| 653 |
-
|
| 654 |
-
`on_callback` handles the new callback data. `fm:<id>` sets
|
| 655 |
-
`Pending(kind="rename_format", format_id=id, ...)` and replies
|
| 656 |
-
"Send me the file to rename with **<name>**." `fnew` sets
|
| 657 |
-
`Pending(kind="fmt_name", ...)` and asks for a name. `fset:<field>:<id>` sets the matching
|
| 658 |
-
pending kind. `fdef:<field>:<id>` writes `None` for that field. `fthumbdef:<id>` writes
|
| 659 |
-
`thumb=None`, `fthumbnone:<id>` writes `thumb=b""`. `fdel:<id>` asks for confirmation and
|
| 660 |
-
`fdelok:<id>` deletes and re-renders the list. `frename:<id>` asks for a new name.
|
| 661 |
-
|
| 662 |
-
Every callback must check that the format belongs to the sender's user id before changing it.
|
| 663 |
-
|
| 664 |
-
- [ ] **Step 9: Resolve the format at enqueue time in `bot/app.py`**
|
| 665 |
-
|
| 666 |
-
`App.enqueue` gains a `fmt: Optional[Dict]` parameter. When it is not `None`:
|
| 667 |
-
|
| 668 |
-
```python
|
| 669 |
-
s = resolve_format(fmt, s)
|
| 670 |
-
thumb_mode, thumb = resolve_thumbnail(fmt, await self.db.get_thumb(uid))
|
| 671 |
-
```
|
| 672 |
-
|
| 673 |
-
and `compute_new_name` is called with the **resolved** `s["template"]` and `s["mode"]`, and the
|
| 674 |
-
job is created with `settings_override=s`, `thumbnail_mode=thumb_mode` and `thumbnail=thumb`.
|
| 675 |
-
|
| 676 |
-
This is the step that makes a format's template reach the file name — the name is computed
|
| 677 |
-
here, not in the pipeline.
|
| 678 |
-
|
| 679 |
-
- [ ] **Step 10: Run the tests to verify they pass**
|
| 680 |
-
|
| 681 |
-
Run: `pytest tests/test_app.py -v`
|
| 682 |
-
Expected: PASS
|
| 683 |
-
|
| 684 |
-
- [ ] **Step 11: Run the whole suite**
|
| 685 |
-
|
| 686 |
-
Run: `pytest -q`
|
| 687 |
-
Expected: everything passes
|
| 688 |
-
|
| 689 |
-
- [ ] **Step 12: Commit**
|
| 690 |
-
|
| 691 |
-
```bash
|
| 692 |
-
git add bot/queue.py bot/pipeline.py bot/panels.py bot/app.py tests/test_app.py
|
| 693 |
-
git commit -m "feat: named rename formats, the /rename picker and the format editor"
|
| 694 |
-
```
|
| 695 |
-
|
| 696 |
-
---
|
| 697 |
-
|
| 698 |
-
### Task 5: `/sequence` and `/sort`
|
| 699 |
-
|
| 700 |
-
**Files:**
|
| 701 |
-
- Create: `bot/sequence.py`
|
| 702 |
-
- Create: `tests/test_sequence.py`
|
| 703 |
-
- Modify: `bot/app.py` (the commands, the collection branch, the copy loop)
|
| 704 |
-
- Modify: `tests/test_app.py` (the handler tests)
|
| 705 |
-
|
| 706 |
-
**Interfaces:**
|
| 707 |
-
- Consumes: `parser.effective_info` from Task 1; `db.get_sequence` / `set_sequence` / `clear_sequence` from Task 2.
|
| 708 |
-
- Produces:
|
| 709 |
-
- `sequence.detect_episodes(items: List[Dict[str, Any]], mode: str) -> List[Dict[str, Any]]` — returns the items with `season` and `episode` filled in. Each item is `{"msg_id": int, "name": str, "caption": str, "season": str, "episode": str}`.
|
| 710 |
-
- `sequence.plan_sort(items: List[Dict[str, Any]]) -> sequence.SortResult` with
|
| 711 |
-
- `SortResult.ordered: List[Dict[str, Any]]` — the items in sorted order.
|
| 712 |
-
- `SortResult.missing: List[str]` — e.g. `["S01E03", "S01E07"]`.
|
| 713 |
-
- `SortResult.duplicates: List[sequence.Duplicate]` — `Duplicate(season, episode, names)`.
|
| 714 |
-
- `SortResult.no_episode: List[str]` — the file names that carry no episode number.
|
| 715 |
-
- `sequence.format_report(result: SortResult, total: int) -> str` — the message shown after the sort.
|
| 716 |
-
|
| 717 |
-
- [ ] **Step 1: Write the failing tests**
|
| 718 |
-
|
| 719 |
-
Create `tests/test_sequence.py`:
|
| 720 |
-
|
| 721 |
-
```python
|
| 722 |
-
from bot.sequence import detect_episodes, format_report, plan_sort
|
| 723 |
-
|
| 724 |
-
|
| 725 |
-
def items(*names):
|
| 726 |
-
return [{"msg_id": i, "name": n, "caption": ""} for i, n in enumerate(names)]
|
| 727 |
-
|
| 728 |
-
|
| 729 |
-
def test_episodes_are_detected_from_the_file_name():
|
| 730 |
-
out = detect_episodes(items("Show S01E02.mkv", "Show S01E10.mkv"), "filename")
|
| 731 |
-
assert [(i["season"], i["episode"]) for i in out] == [("01", "02"), ("01", "10")]
|
| 732 |
-
|
| 733 |
-
|
| 734 |
-
def test_sorting_is_by_season_then_episode_ascending():
|
| 735 |
-
out = plan_sort(detect_episodes(
|
| 736 |
-
items("S01E10.mkv", "S02E01.mkv", "S01E02.mkv", "S01E03.mkv"), "filename"))
|
| 737 |
-
assert [i["name"] for i in out.ordered] == ["S01E02.mkv", "S01E03.mkv", "S01E10.mkv", "S02E01.mkv"]
|
| 738 |
-
|
| 739 |
-
|
| 740 |
-
def test_missing_episodes_are_reported_per_season():
|
| 741 |
-
out = plan_sort(detect_episodes(items("S01E01.mkv", "S01E04.mkv", "S02E01.mkv"), "filename"))
|
| 742 |
-
assert out.missing == ["S01E02", "S01E03"]
|
| 743 |
-
|
| 744 |
-
|
| 745 |
-
def test_duplicate_episodes_are_reported_with_their_file_names():
|
| 746 |
-
out = plan_sort(detect_episodes(items("S01E02.mkv", "S01E02 (copy).mkv", "S01E03.mkv"), "filename"))
|
| 747 |
-
assert len(out.duplicates) == 1
|
| 748 |
-
assert out.duplicates[0].episode == "02"
|
| 749 |
-
assert out.duplicates[0].names == ["S01E02.mkv", "S01E02 (copy).mkv"]
|
| 750 |
-
|
| 751 |
-
|
| 752 |
-
def test_a_file_without_an_episode_is_kept_and_reported():
|
| 753 |
-
out = plan_sort(detect_episodes(items("S01E02.mkv", "bonus.mkv", "extra.mkv"), "filename"))
|
| 754 |
-
assert [i["name"] for i in out.ordered] == ["S01E02.mkv", "bonus.mkv", "extra.mkv"]
|
| 755 |
-
assert out.no_episode == ["bonus.mkv", "extra.mkv"]
|
| 756 |
-
|
| 757 |
-
|
| 758 |
-
def test_the_report_lists_only_what_is_wrong():
|
| 759 |
-
out = plan_sort(detect_episodes(items("S01E01.mkv", "S01E03.mkv"), "filename"))
|
| 760 |
-
text = format_report(out, 2)
|
| 761 |
-
assert "S01E02" in text and "Duplicates" not in text and "No episode" not in text
|
| 762 |
-
```
|
| 763 |
-
|
| 764 |
-
- [ ] **Step 2: Run the tests to verify they fail**
|
| 765 |
-
|
| 766 |
-
Run: `pytest tests/test_sequence.py -v`
|
| 767 |
-
Expected: FAIL — `ModuleNotFoundError: No module named 'bot.sequence'`
|
| 768 |
-
|
| 769 |
-
- [ ] **Step 3: Implement `bot/sequence.py`**
|
| 770 |
-
|
| 771 |
-
`detect_episodes` calls `effective_info(stem, item["caption"], mode)` for each item, where
|
| 772 |
-
`stem` is the file name without its extension, and fills `season` and `episode` with the
|
| 773 |
-
parsed values (empty strings when there are none).
|
| 774 |
-
|
| 775 |
-
`plan_sort` sorts with a **stable** sort whose key is `(0, season, episode)` for an item with
|
| 776 |
-
an episode and `(1, "", "")` for one without, so the numbered files come first in season and
|
| 777 |
-
episode order and the unnumbered ones keep their arrival order at the end.
|
| 778 |
-
|
| 779 |
-
Missing episodes: group the items by season; for each season take the episodes from the
|
| 780 |
-
lowest to the highest present and subtract the ones present. The result is sorted and
|
| 781 |
-
formatted as `S{season}E{episode}`.
|
| 782 |
-
|
| 783 |
-
Duplicates: count the `(season, episode)` pairs; every pair seen more than once becomes a
|
| 784 |
-
`Duplicate` holding the season, the episode and the file names in arrival order.
|
| 785 |
-
|
| 786 |
-
`format_report` returns a message with the sorted count first, then the missing list, the
|
| 787 |
-
duplicate list and the no-episode list, omitting any section that is empty.
|
| 788 |
-
|
| 789 |
-
- [ ] **Step 4: Run the tests to verify they pass**
|
| 790 |
-
|
| 791 |
-
Run: `pytest tests/test_sequence.py -v`
|
| 792 |
-
Expected: PASS (6 tests)
|
| 793 |
-
|
| 794 |
-
- [ ] **Step 5: Write the handler tests for the sequence**
|
| 795 |
-
|
| 796 |
-
In `tests/test_app.py`:
|
| 797 |
-
|
| 798 |
-
```python
|
| 799 |
-
async def test_an_open_sequence_collects_files_instead_of_renaming_them(ctx):
|
| 800 |
-
ev = await say(ctx, OWNER, "/sequence")
|
| 801 |
-
assert "Send me the files" in ev.texts[0]
|
| 802 |
-
await send_doc(ctx, OWNER, "Show S01E02.mkv")
|
| 803 |
-
await send_doc(ctx, OWNER, "Show S01E01.mkv")
|
| 804 |
-
assert not ctx.app.queue.jobs # nothing was renamed
|
| 805 |
-
seq = await ctx.db.get_sequence(OWNER)
|
| 806 |
-
assert [i["name"] for i in seq["items"]] == ["Show S01E02.mkv", "Show S01E01.mkv"]
|
| 807 |
-
|
| 808 |
-
|
| 809 |
-
async def test_sort_copies_the_files_back_in_order(ctx):
|
| 810 |
-
await say(ctx, OWNER, "/sequence")
|
| 811 |
-
await send_doc(ctx, OWNER, "Show S01E02.mkv", mid=11)
|
| 812 |
-
await send_doc(ctx, OWNER, "Show S01E01.mkv", mid=12)
|
| 813 |
-
await say(ctx, OWNER, "/sort")
|
| 814 |
-
# the copies are made one at a time, in episode order
|
| 815 |
-
assert [ids for _to, _from, ids in ctx.client.copies] == [[12], [11]]
|
| 816 |
-
assert await ctx.db.get_sequence(OWNER) is None # the sequence closed
|
| 817 |
-
```
|
| 818 |
-
|
| 819 |
-
`FakeClient` needs a `copies` list and a `forward_messages` method — add to `tests/tgfakes.py`
|
| 820 |
-
(Telethon has no `copy_messages`; the sort uses `forward_messages` with `drop_author=True`,
|
| 821 |
-
which is what drops the "forwarded from" header):
|
| 822 |
-
|
| 823 |
-
```python
|
| 824 |
-
self.copies: List[tuple] = [] # forward_messages(to, messages, from_peer, **kw)
|
| 825 |
-
|
| 826 |
-
async def forward_messages(self, to, messages, from_peer=None, **kw):
|
| 827 |
-
self.copies.append((to, from_peer, list(messages), kw))
|
| 828 |
-
return [SimpleNamespace(id=4000 + len(self.copies))]
|
| 829 |
-
```
|
| 830 |
-
|
| 831 |
-
- [ ] **Step 6: Run the handler tests to verify they fail**
|
| 832 |
-
|
| 833 |
-
Run: `pytest tests/test_app.py -v -k sequence`
|
| 834 |
-
Expected: FAIL — `/sequence` is not a command.
|
| 835 |
-
|
| 836 |
-
- [ ] **Step 7: Add the commands to `bot/app.py`**
|
| 837 |
-
|
| 838 |
-
`cmd_sequence(event)`: if a sequence is already open, reply with the count and the
|
| 839 |
-
`/sort` hint; otherwise create an empty sequence and reply "Send me the files to sort.
|
| 840 |
-
When you're done, send /sort." with a "✖ Cancel" button (`sq:cancel`).
|
| 841 |
-
|
| 842 |
-
`cmd_sort(event)`: if no sequence is open, reply "You have no files to sort." Otherwise:
|
| 843 |
-
|
| 844 |
-
1. `items = detect_episodes(seq["items"], mode)` where `mode` is the user's current mode.
|
| 845 |
-
2. `result = plan_sort(items)`.
|
| 846 |
-
3. Send the files back **one at a time**, awaiting each one, with a short sleep between
|
| 847 |
-
them (0.4 s), starting from `seq["copied"]` so a retry resumes. Each one is
|
| 848 |
-
`await self.client.forward_messages(chat_id, [msg_id], chat_id, drop_author=True)` —
|
| 849 |
-
Telethon has no true copy, and `drop_author=True` is what removes the "forwarded
|
| 850 |
-
from" header. `copied` is an index into the **ordered** list, so the ordering must be
|
| 851 |
-
identical on a retry; it is, as long as the user's mode has not changed since the
|
| 852 |
-
sort started.
|
| 853 |
-
4. Save the new `copied` count after every copy.
|
| 854 |
-
5. Edit the status message with `format_report(result, len(result.ordered))`.
|
| 855 |
-
6. Clear the sequence.
|
| 856 |
-
|
| 857 |
-
The original messages are **kept** — never delete them. A copy that raises `FloodWaitError`
|
| 858 |
-
must wait and retry, and a copy that fails for any other reason must stop the loop, report
|
| 859 |
-
how many were copied, and leave the sequence open.
|
| 860 |
-
|
| 861 |
-
`on_media`: before enqueueing, check `await self.db.get_sequence(uid)`. If a sequence is open,
|
| 862 |
-
collect the file instead of renaming it (Review Focus 4) — append
|
| 863 |
-
`{"msg_id": msg.id, "name": original_name_of(msg), "caption": msg.raw_text or ""}` to the
|
| 864 |
-
sequence's items, save it, and acknowledge with the new count.
|
| 865 |
-
|
| 866 |
-
The `sq:cancel` callback clears the sequence.
|
| 867 |
-
|
| 868 |
-
- [ ] **Step 8: Run the handler tests to verify they pass**
|
| 869 |
-
|
| 870 |
-
Run: `pytest tests/test_app.py -v -k "sequence or sort"`
|
| 871 |
-
Expected: PASS
|
| 872 |
-
|
| 873 |
-
- [ ] **Step 9: Run the whole suite**
|
| 874 |
-
|
| 875 |
-
Run: `pytest -q`
|
| 876 |
-
Expected: everything passes
|
| 877 |
-
|
| 878 |
-
- [ ] **Step 10: Commit**
|
| 879 |
-
|
| 880 |
-
```bash
|
| 881 |
-
git add bot/sequence.py tests/test_sequence.py tests/tgfakes.py bot/app.py tests/test_app.py
|
| 882 |
-
git commit -m "feat: collect files with /sequence and sort them with /sort"
|
| 883 |
-
```
|
| 884 |
-
|
| 885 |
-
---
|
| 886 |
-
|
| 887 |
-
### Task 6: The `/check` command
|
| 888 |
-
|
| 889 |
-
**Files:**
|
| 890 |
-
- Modify: `bot/app.py`
|
| 891 |
-
- Modify: `tests/test_app.py`
|
| 892 |
-
|
| 893 |
-
**Interfaces:**
|
| 894 |
-
- Consumes: `panels.check_report` and `parser.effective_info` from Task 1; `pipeline.original_name_of` and `pipeline.source_ext`.
|
| 895 |
-
- Produces: the `/check` command handler.
|
| 896 |
-
|
| 897 |
-
- [ ] **Step 1: Write the failing test**
|
| 898 |
-
|
| 899 |
-
In `tests/test_app.py`:
|
| 900 |
-
|
| 901 |
-
```python
|
| 902 |
-
async def test_check_reports_what_the_bot_extracted(ctx):
|
| 903 |
-
reply = document_message("Dragon Ball Super Beerus S01E02 1080p.mkv", mid=40, caption="")
|
| 904 |
-
ev = await say(ctx, OWNER, "/check", reply=reply)
|
| 905 |
-
text = ev.texts[0]
|
| 906 |
-
assert "Title \u2986 Dragon Ball Super Beerus" in text
|
| 907 |
-
assert "Season \u2986 01" in text and "Episode \u2986 02" in text
|
| 908 |
-
assert "Quality \u2986 1080p" in text
|
| 909 |
-
assert text.count("\u27a1") == 8 # the name and the caption sections
|
| 910 |
-
|
| 911 |
-
|
| 912 |
-
async def test_check_needs_a_file(ctx):
|
| 913 |
-
ev = await say(ctx, OWNER, "/check")
|
| 914 |
-
assert "Reply to a file" in ev.texts[0]
|
| 915 |
-
```
|
| 916 |
-
|
| 917 |
-
- [ ] **Step 2: Run the test to verify it fails**
|
| 918 |
-
|
| 919 |
-
Run: `pytest tests/test_app.py -v -k check`
|
| 920 |
-
Expected: FAIL — `/check` is not a command.
|
| 921 |
-
|
| 922 |
-
- [ ] **Step 3: Implement `cmd_check` in `bot/app.py`**
|
| 923 |
-
|
| 924 |
-
Reply to a file with `/check`:
|
| 925 |
-
|
| 926 |
-
1. `reply = await event.get_reply_message()`; if it is not a renameable file, reply
|
| 927 |
-
"Reply to a file with /check to see what I can read from it."
|
| 928 |
-
2. `name = original_name_of(reply)`, `stem, _ext = split_ext(name)`.
|
| 929 |
-
3. `file_info = parse_info(stem)` — the FileName section.
|
| 930 |
-
4. `caption_info = effective_info(stem, reply.raw_text or "", "caption")` — the Caption Mode
|
| 931 |
-
section, exactly what a rename in caption mode would use.
|
| 932 |
-
5. Reply with `panels.check_report(file_info, caption_info)`.
|
| 933 |
-
|
| 934 |
-
Import `parse_info`, `split_ext` from `bot.parser` and `check_report` from `bot.panels`.
|
| 935 |
-
|
| 936 |
-
- [ ] **Step 4: Run the test to verify it passes**
|
| 937 |
-
|
| 938 |
-
Run: `pytest tests/test_app.py -v -k check`
|
| 939 |
-
Expected: PASS
|
| 940 |
-
|
| 941 |
-
- [ ] **Step 5: Run the whole suite**
|
| 942 |
-
|
| 943 |
-
Run: `pytest -q`
|
| 944 |
-
Expected: everything passes
|
| 945 |
-
|
| 946 |
-
- [ ] **Step 6: Commit**
|
| 947 |
-
|
| 948 |
-
```bash
|
| 949 |
-
git add bot/app.py tests/test_app.py
|
| 950 |
-
git commit -m "feat: the /check command"
|
| 951 |
-
```
|
| 952 |
-
|
| 953 |
-
---
|
| 954 |
-
|
| 955 |
-
### Task 7: `/ethumb` and `/resize`
|
| 956 |
-
|
| 957 |
-
**Files:**
|
| 958 |
-
- Modify: `bot/media.py` (add `extract_frame`, `resize_image`, `parse_size`)
|
| 959 |
-
- Modify: `bot/app.py` (the two commands and their callbacks)
|
| 960 |
-
- Modify: `tests/test_media.py` (the media helpers)
|
| 961 |
-
- Modify: `tests/test_app.py` (the handlers)
|
| 962 |
-
- Modify: `tests/tgfakes.py` (the fake client needs to serve a video file)
|
| 963 |
-
|
| 964 |
-
**Interfaces:**
|
| 965 |
-
- Consumes: `media.probe` for the duration; `media.prepare_thumb` for the Telegram thumbnail limits.
|
| 966 |
-
- Produces:
|
| 967 |
-
- `media.extract_frame(src: Path, dst: Path, duration: float, cancel=None) -> bool` — grabs one frame at ~10% of the duration at the **source width** (the existing `make_video_thumbnail` scales to 320px; this one does not). Failure is non-fatal and returns `False`.
|
| 968 |
-
- `media.resize_image(data: bytes, width: Optional[int] = None, height: Optional[int] = None) -> bytes` — resizes an image. With one dimension the aspect ratio is kept; with both the exact size is forced. The result is a JPEG.
|
| 969 |
-
- `media.parse_size(text: str) -> Tuple[Optional[int], Optional[int], Optional[str]]` — `"800"` gives `(800, None, None)`, `"800x450"` gives `(800, 450, None)`, anything invalid gives `(None, None, "an error message")`.
|
| 970 |
-
|
| 971 |
-
- [ ] **Step 1: Write the failing tests**
|
| 972 |
-
|
| 973 |
-
In `tests/test_media.py`:
|
| 974 |
-
|
| 975 |
-
```python
|
| 976 |
-
def test_parse_size_accepts_a_width_or_an_exact_size():
|
| 977 |
-
assert media.parse_size("800") == (800, None, None)
|
| 978 |
-
assert media.parse_size("800x450") == (800, 450, None)
|
| 979 |
-
assert media.parse_size(" 640 ") == (640, None, None)
|
| 980 |
-
w, h, err = media.parse_size("huge")
|
| 981 |
-
assert (w, h) == (None, None) and "number" in err
|
| 982 |
-
|
| 983 |
-
|
| 984 |
-
def test_resize_image_keeps_the_aspect_ratio_or_forces_the_exact_size():
|
| 985 |
-
buf = io.BytesIO()
|
| 986 |
-
Image.new("RGB", (1000, 500), (10, 20, 30)).save(buf, "PNG")
|
| 987 |
-
data = buf.getvalue()
|
| 988 |
-
out = Image.open(io.BytesIO(media.resize_image(data, width=200)))
|
| 989 |
-
assert out.size == (200, 100)
|
| 990 |
-
out = Image.open(io.BytesIO(media.resize_image(data, width=200, height=200)))
|
| 991 |
-
assert out.size == (200, 200)
|
| 992 |
-
```
|
| 993 |
-
|
| 994 |
-
In `tests/test_app.py`:
|
| 995 |
-
|
| 996 |
-
```python
|
| 997 |
-
async def test_resize_answers_with_size_buttons(ctx):
|
| 998 |
-
reply = FakeMessage(mid=60, photo=object())
|
| 999 |
-
ev = await say(ctx, OWNER, "/resize", reply=reply)
|
| 1000 |
-
assert "rz:320" in data_of(ev.out[0][1]) and "rz:orig" in data_of(ev.out[0][1])
|
| 1001 |
-
|
| 1002 |
-
|
| 1003 |
-
async def test_resize_needs_an_image(ctx):
|
| 1004 |
-
ev = await say(ctx, OWNER, "/resize")
|
| 1005 |
-
assert "Reply to an image" in ev.texts[0]
|
| 1006 |
-
```
|
| 1007 |
-
|
| 1008 |
-
- [ ] **Step 2: Run the tests to verify they fail**
|
| 1009 |
-
|
| 1010 |
-
Run: `pytest tests/test_media.py tests/test_app.py -v -k "resize or parse_size"`
|
| 1011 |
-
Expected: FAIL — `media.parse_size` does not exist.
|
| 1012 |
-
|
| 1013 |
-
- [ ] **Step 3: Implement the three helpers in `bot/media.py`**
|
| 1014 |
-
|
| 1015 |
-
`parse_size` accepts a bare number or `WIDTHxHEIGHT` (also `WIDTH X HEIGHT` with spaces).
|
| 1016 |
-
Anything else returns an error message mentioning that a number was expected.
|
| 1017 |
-
|
| 1018 |
-
`resize_image` opens the image with Pillow, converts to RGB, resizes with
|
| 1019 |
-
`Image.LANCZOS` (or `Image.Resampling.LANCZOS` on newer Pillow) and saves as a JPEG.
|
| 1020 |
-
|
| 1021 |
-
`extract_frame` runs:
|
| 1022 |
-
|
| 1023 |
-
```
|
| 1024 |
-
ffmpeg -hide_banner -nostdin -y -loglevel error -ss {seek} -i {src} -frames:v 1 -q:v 2 {dst}
|
| 1025 |
-
```
|
| 1026 |
-
|
| 1027 |
-
where `seek` is `1.0` when the duration is unknown, otherwise
|
| 1028 |
-
`min(max(duration * 0.1, 0.0), 60.0)` — the same rule as `make_video_thumbnail`, but without
|
| 1029 |
-
the `scale='min(320,iw)':-2` filter, so the frame keeps the source width.
|
| 1030 |
-
|
| 1031 |
-
- [ ] **Step 4: Implement `cmd_ethumb` and `cmd_resize` in `bot/app.py`**
|
| 1032 |
-
|
| 1033 |
-
`cmd_ethumb(event)`: reply to a file with `/ethumb`. Download it to a temp dir, probe it for
|
| 1034 |
-
the duration, call `extract_frame`, and send the JPEG back as a photo with the buttons
|
| 1035 |
-
`[("Set as my thumbnail", "eth:set"), ("✖ Close", "x:close")]`. Keep the bytes in
|
| 1036 |
-
`self._ethumbs[uid]` so the `eth:set` callback can store them with `db.set_thumb`. If the
|
| 1037 |
-
file has no video stream, reply "That file has no video — I can't take a thumbnail from it."
|
| 1038 |
-
|
| 1039 |
-
`cmd_resize(event)`: reply to an image with `/resize`. Reply with the buttons
|
| 1040 |
-
`[("320", "rz:320"), ("640", "rz:640"), ("1280", "rz:1280"), ("Original", "rz:orig"),
|
| 1041 |
-
("Custom", "rz:custom"), ("✖ Close", "x:close")]`.
|
| 1042 |
-
|
| 1043 |
-
The `rz:<size>` callback resizes the replied-to image and sends the result back as a photo
|
| 1044 |
-
with a "Set as my thumbnail" button (`eth:set`). `rz:orig` sends the image unchanged.
|
| 1045 |
-
`rz:custom` asks for a size with a text pending (`kind="resize_size"`) and the next text
|
| 1046 |
-
message is parsed with `media.parse_size`; an invalid size is reported and the pending stays.
|
| 1047 |
-
|
| 1048 |
-
- [ ] **Step 5: Run the tests to verify they pass**
|
| 1049 |
-
|
| 1050 |
-
Run: `pytest tests/test_media.py tests/test_app.py -v -k "resize or ethumb or parse_size"`
|
| 1051 |
-
Expected: PASS
|
| 1052 |
-
|
| 1053 |
-
- [ ] **Step 6: Run the whole suite**
|
| 1054 |
-
|
| 1055 |
-
Run: `pytest -q`
|
| 1056 |
-
Expected: everything passes
|
| 1057 |
-
|
| 1058 |
-
- [ ] **Step 7: Commit**
|
| 1059 |
-
|
| 1060 |
-
```bash
|
| 1061 |
-
git add bot/media.py bot/app.py tests/test_media.py tests/test_app.py tests/tgfakes.py
|
| 1062 |
-
git commit -m "feat: /ethumb thumbnail extraction and /resize"
|
| 1063 |
-
```
|
| 1064 |
-
|
| 1065 |
-
---
|
| 1066 |
-
|
| 1067 |
-
### Task 8: `/speed` with the Ookla CLI
|
| 1068 |
-
|
| 1069 |
-
**Files:**
|
| 1070 |
-
- Create: `bot/speedtest.py`
|
| 1071 |
-
- Create: `tests/test_speedtest.py`
|
| 1072 |
-
- Modify: `bot/app.py` (the command)
|
| 1073 |
-
- Modify: `Dockerfile` (install the Ookla CLI)
|
| 1074 |
-
|
| 1075 |
-
**Interfaces:**
|
| 1076 |
-
- Consumes: nothing new.
|
| 1077 |
-
- Produces:
|
| 1078 |
-
- `speedtest.SpeedResult` — a dataclass with `download_mbps: float`, `upload_mbps: float`, `ping_ms: float`, `server: str`, `isp: str`.
|
| 1079 |
-
- `speedtest.parse_ookla_json(raw: str) -> SpeedResult` — parses the JSON from `ookla-speedtest --format=json`. Raises `ValueError` on anything it cannot read.
|
| 1080 |
-
- `speedtest.format_speed(result: SpeedResult) -> str` — the message shown in the chat.
|
| 1081 |
-
- `speedtest.run_speedtest(binary: str = "ookla-speedtest", timeout: float = 120.0) -> SpeedResult` — runs the CLI with `--accept-license --accept-gdpr --simple --format=json`. Raises `FileNotFoundError` when the binary is missing and `asyncio.TimeoutError` when it times out.
|
| 1082 |
-
|
| 1083 |
-
- [ ] **Step 1: Write the failing tests**
|
| 1084 |
-
|
| 1085 |
-
Create `tests/test_speedtest.py`:
|
| 1086 |
-
|
| 1087 |
-
```python
|
| 1088 |
-
import pytest
|
| 1089 |
-
from bot.speedtest import format_speed, parse_ookla_json
|
| 1090 |
-
|
| 1091 |
-
RAW = """{"timestamp":"2024-01-01T00:00:00.000Z",
|
| 1092 |
-
"download":{"bandwidth":91234567,"bytes":1000,"elapsed":10000},
|
| 1093 |
-
"upload":{"bandwidth":4567890,"bytes":1000,"elapsed":10000},
|
| 1094 |
-
"ping":{"jitter":2,"iqm":12,"low":10,"high":15},
|
| 1095 |
-
"server":{"id":"1","host":"x","name":"Mumbai","country":"IN","sponsor":"ACME","d":0,"latency":12},
|
| 1096 |
-
"isp":"Example ISP","result":{"id":"1","url":"http://x","ok":true}}"""
|
| 1097 |
-
|
| 1098 |
-
|
| 1099 |
-
def test_parse_ookla_json_reads_the_speeds():
|
| 1100 |
-
r = parse_ookla_json(RAW)
|
| 1101 |
-
assert r.download_mbps == pytest.approx(91.234567, rel=1e-4)
|
| 1102 |
-
assert r.upload_mbps == pytest.approx(4.56789, rel=1e-4)
|
| 1103 |
-
assert r.ping_ms == 12 and r.server == "Mumbai" and r.isp == "Example ISP"
|
| 1104 |
-
|
| 1105 |
-
|
| 1106 |
-
def test_parse_ookla_json_rejects_rubbish():
|
| 1107 |
-
with pytest.raises(ValueError):
|
| 1108 |
-
parse_ookla_json("not json")
|
| 1109 |
-
with pytest.raises(ValueError):
|
| 1110 |
-
parse_ookla_json("{}")
|
| 1111 |
-
|
| 1112 |
-
|
| 1113 |
-
def test_format_speed_shows_the_numbers():
|
| 1114 |
-
text = format_speed(parse_ookla_json(RAW))
|
| 1115 |
-
assert "91.23" in text and "4.57" in text and "12" in text
|
| 1116 |
-
assert "Mumbai" in text and "Example ISP" in text
|
| 1117 |
-
```
|
| 1118 |
-
|
| 1119 |
-
- [ ] **Step 2: Run the tests to verify they fail**
|
| 1120 |
-
|
| 1121 |
-
Run: `pytest tests/test_speedtest.py -v`
|
| 1122 |
-
Expected: FAIL — `ModuleNotFoundError: No module named 'bot.speedtest'`
|
| 1123 |
-
|
| 1124 |
-
- [ ] **Step 3: Implement `bot/speedtest.py`**
|
| 1125 |
-
|
| 1126 |
-
`parse_ookla_json` loads the JSON, reads `download.bandwidth` and `upload.bandwidth`
|
| 1127 |
-
(bits per second) and divides by 1_000_000 for Mbps, reads `ping.iqm` for the latency in
|
| 1128 |
-
milliseconds, and joins `server.name` with `server.country` for the server label. It raises
|
| 1129 |
-
`ValueError` when the JSON is unparsable or when the `download` or `upload` keys are missing.
|
| 1130 |
-
|
| 1131 |
-
`run_speedtest` uses `asyncio.create_subprocess_exec` with the argument list
|
| 1132 |
-
`[binary, "--accept-license", "--accept-gdpr", "--simple", "--format=json"]`, captures the
|
| 1133 |
-
stdout, waits with `asyncio.wait_for(..., timeout)` and parses the result. A non-zero exit
|
| 1134 |
-
code raises `RuntimeError` with the tail of stderr.
|
| 1135 |
-
|
| 1136 |
-
`format_speed` returns a message with the download speed, the upload speed, the ping, the
|
| 1137 |
-
server and the ISP — two decimals on the speeds, the ping as an integer.
|
| 1138 |
-
|
| 1139 |
-
- [ ] **Step 4: Run the tests to verify they pass**
|
| 1140 |
-
|
| 1141 |
-
Run: `pytest tests/test_speedtest.py -v`
|
| 1142 |
-
Expected: PASS (3 tests)
|
| 1143 |
-
|
| 1144 |
-
- [ ] **Step 5: Add the command to `bot/app.py`**
|
| 1145 |
-
|
| 1146 |
-
`cmd_speed(event)`: reply "Testing your connection…", run `run_speedtest` in a thread
|
| 1147 |
-
(`asyncio.to_thread` is not enough because it is async — use `await run_speedtest(...)`),
|
| 1148 |
-
then edit the message with `format_speed(result)`. Catch `FileNotFoundError` and reply
|
| 1149 |
-
"The speedtest tool is not installed in this container." and `asyncio.TimeoutError` and reply
|
| 1150 |
-
"The speed test timed out."
|
| 1151 |
-
|
| 1152 |
-
Add `("speed", "Check the bot's internet speed")` to the `COMMANDS` list.
|
| 1153 |
-
|
| 1154 |
-
- [ ] **Step 6: Install the Ookla CLI in the `Dockerfile`**
|
| 1155 |
-
|
| 1156 |
-
Add `curl` to the existing `apt-get install` line, then add a new `RUN` layer:
|
| 1157 |
-
|
| 1158 |
-
```dockerfile
|
| 1159 |
-
RUN curl -sSL https://s3.amazonaws.com/ookla.speedtest.net/tools/cli/ookla-speedtest-1.2.0-linux-x86_64.tgz -o /tmp/ookla.tgz \
|
| 1160 |
-
&& tar -xzf /tmp/ookla.tgz -C /tmp \
|
| 1161 |
-
&& install -m 0755 /tmp/ookla-speedtest /usr/local/bin/ookla-speedtest \
|
| 1162 |
-
&& rm -rf /tmp/ookla.tgz /tmp/ookla-speedtest
|
| 1163 |
-
```
|
| 1164 |
-
|
| 1165 |
-
- [ ] **Step 7: Run the whole suite**
|
| 1166 |
-
|
| 1167 |
-
Run: `pytest -q`
|
| 1168 |
-
Expected: everything passes
|
| 1169 |
-
|
| 1170 |
-
- [ ] **Step 8: Commit**
|
| 1171 |
-
|
| 1172 |
-
```bash
|
| 1173 |
-
git add bot/speedtest.py bot/app.py tests/test_speedtest.py Dockerfile
|
| 1174 |
-
git commit -m "feat: /speed using the Ookla CLI"
|
| 1175 |
-
```
|
| 1176 |
-
|
| 1177 |
-
---
|
| 1178 |
-
|
| 1179 |
-
### Task 9: `/mediainfo` with a telegra.ph page
|
| 1180 |
-
|
| 1181 |
-
**Files:**
|
| 1182 |
-
- Create: `bot/telegraph.py`
|
| 1183 |
-
- Create: `tests/test_telegraph.py`
|
| 1184 |
-
- Modify: `bot/media.py` (add `probe_full`)
|
| 1185 |
-
- Modify: `bot/app.py` (the command)
|
| 1186 |
-
- Modify: `tests/test_app.py` (the handler)
|
| 1187 |
-
|
| 1188 |
-
**Interfaces:**
|
| 1189 |
-
- Consumes: `db.kv_get` / `db.kv_set` from Task 2 for the cached access token; `aiohttp` for the HTTP calls.
|
| 1190 |
-
- Produces:
|
| 1191 |
-
- `telegraph.TELEGRAPH_API` — `"https://api.telegra.ph"`.
|
| 1192 |
-
- `telegraph.build_page(probe: Dict[str, Any], name: str) -> str` — the HTML body of the page.
|
| 1193 |
-
- `telegraph.Telegraph` — a class with `async def create_account(self) -> str` and `async def upload_page(self, title: str, content: List[str]) -> str` (returns the page URL).
|
| 1194 |
-
- `media.probe_full(path: Path | str, cancel=None) -> Dict[str, Any]` — the raw ffprobe JSON with format, streams, chapters and programs.
|
| 1195 |
-
|
| 1196 |
-
- [ ] **Step 1: Write the failing tests**
|
| 1197 |
-
|
| 1198 |
-
Create `tests/test_telegraph.py`:
|
| 1199 |
-
|
| 1200 |
-
```python
|
| 1201 |
-
from bot.telegraph import build_page
|
| 1202 |
-
|
| 1203 |
-
PROBE = {
|
| 1204 |
-
"format": {"filename": "movie.mkv", "format_name": "matroska,webm", "duration": "120.0",
|
| 1205 |
-
"size": "1048576", "bit_rate": "8000000",
|
| 1206 |
-
"tags": {"title": "Movie Title"}},
|
| 1207 |
-
"streams": [
|
| 1208 |
-
{"index": 0, "codec_type": "video", "codec_name": "h264", "width": 1920, "height": 1080,
|
| 1209 |
-
"r_frame_rate": "24/1", "bit_rate": "7000000", "disposition": {"default": 1}},
|
| 1210 |
-
{"index": 1, "codec_type": "audio", "codec_name": "aac", "channels": 2, "sample_rate": "48000",
|
| 1211 |
-
"tags": {"language": "eng", "title": "English"}},
|
| 1212 |
-
{"index": 2, "codec_type": "subtitle", "codec_name": "subrip",
|
| 1213 |
-
"tags": {"language": "eng"}},
|
| 1214 |
-
],
|
| 1215 |
-
"chapters": [{"id": 0, "start_time": "0.0", "end_time": "60.0", "tags": {"title": "Intro"}}],
|
| 1216 |
-
}
|
| 1217 |
-
|
| 1218 |
-
|
| 1219 |
-
def test_build_page_lists_the_format_the_streams_and_the_chapters():
|
| 1220 |
-
html = build_page(PROBE, "movie.mkv")
|
| 1221 |
-
assert "movie.mkv" in html and "matroska" in html
|
| 1222 |
-
assert "h264" in html and "1920x1080" in html
|
| 1223 |
-
assert "aac" in html and "English" in html
|
| 1224 |
-
assert "subrip" in html
|
| 1225 |
-
assert "Intro" in html
|
| 1226 |
-
assert "Movie Title" in html
|
| 1227 |
-
assert "<pre" in html and "raw" not in html.lower()[:0] # the raw JSON is included
|
| 1228 |
-
|
| 1229 |
-
|
| 1230 |
-
def test_build_page_survives_an_empty_probe():
|
| 1231 |
-
html = build_page({}, "nothing.mkv")
|
| 1232 |
-
assert "nothing.mkv" in html
|
| 1233 |
-
```
|
| 1234 |
-
|
| 1235 |
-
- [ ] **Step 2: Run the tests to verify they fail**
|
| 1236 |
-
|
| 1237 |
-
Run: `pytest tests/test_telegraph.py -v`
|
| 1238 |
-
Expected: FAIL — `ModuleNotFoundError: No module named 'bot.telegraph'`
|
| 1239 |
-
|
| 1240 |
-
- [ ] **Step 3: Implement `bot/telegraph.py`**
|
| 1241 |
-
|
| 1242 |
-
`build_page` returns an HTML fragment (Telegraph accepts an array of HTML strings) with:
|
| 1243 |
-
|
| 1244 |
-
1. A summary paragraph: the file name, the container, the duration, the size, the bit rate and
|
| 1245 |
-
the format tags.
|
| 1246 |
-
2. One paragraph per stream: the index, the type, the codec, the language, the resolution or
|
| 1247 |
-
the channel count, the sample rate, the bit rate, the title and the disposition flags.
|
| 1248 |
-
3. A paragraph per chapter.
|
| 1249 |
-
4. A `<pre><code>` block with the raw ffprobe JSON, HTML-escaped, truncated to 100 000
|
| 1250 |
-
characters.
|
| 1251 |
-
|
| 1252 |
-
Every value that comes from the file must be HTML-escaped.
|
| 1253 |
-
|
| 1254 |
-
`Telegraph` uses `aiohttp.ClientSession` to POST to `{TELEGRAPH_API}/createAccount` with
|
| 1255 |
-
`{"short_name": "autorename", "author_name": "Auto Rename Bot"}` and then to
|
| 1256 |
-
`{TELEGRAPH_API}/uploadPage` with `{"access_token": ..., "title": ..., "content": [...],
|
| 1257 |
-
"author_name": ...}`. Both raise `RuntimeError` with the API's `error` field when the response
|
| 1258 |
-
is not `ok`.
|
| 1259 |
-
|
| 1260 |
-
- [ ] **Step 4: Run the tests to verify they pass**
|
| 1261 |
-
|
| 1262 |
-
Run: `pytest tests/test_telegraph.py -v`
|
| 1263 |
-
Expected: PASS (2 tests)
|
| 1264 |
-
|
| 1265 |
-
- [ ] **Step 5: Add `probe_full` to `bot/media.py`**
|
| 1266 |
-
|
| 1267 |
-
```python
|
| 1268 |
-
async def probe_full(path: Path | str, cancel: Optional[asyncio.Event] = None) -> Dict[str, Any]:
|
| 1269 |
-
"""The raw ffprobe JSON: format, streams, chapters and programs."""
|
| 1270 |
-
```
|
| 1271 |
-
|
| 1272 |
-
It runs `ffprobe -v quiet -print_format json -show_format -show_streams -show_chapters
|
| 1273 |
-
-show_programs` and returns the parsed JSON, or `{}` when it cannot be parsed.
|
| 1274 |
-
|
| 1275 |
-
- [ ] **Step 6: Add the command to `bot/app.py`**
|
| 1276 |
-
|
| 1277 |
-
`cmd_mediainfo(event)`: reply to a file with `/mediainfo`. Download the whole file to a temp
|
| 1278 |
-
dir, run `media.probe_full`, build the page, and upload it:
|
| 1279 |
-
|
| 1280 |
-
1. Read the access token from `db.kv_get("telegraph_token")`.
|
| 1281 |
-
2. If there is no token, call `Telegraph.create_account()`, store it with
|
| 1282 |
-
`db.kv_set("telegraph_token", token)` and use it.
|
| 1283 |
-
3. `url = await Telegraph(token).upload_page(title, [build_page(probe, name)])`.
|
| 1284 |
-
4. Reply with the link: "📋 <b>Media info:</b> <a href=\"<url>\">open it</a>".
|
| 1285 |
-
|
| 1286 |
-
If the upload fails, reply with the readable summary in the chat instead of the link, so the
|
| 1287 |
-
command is never a dead end. Clean up the temp dir in a `finally` block.
|
| 1288 |
-
|
| 1289 |
-
- [ ] **Step 7: Run the whole suite**
|
| 1290 |
-
|
| 1291 |
-
Run: `pytest -q`
|
| 1292 |
-
Expected: everything passes
|
| 1293 |
-
|
| 1294 |
-
- [ ] **Step 8: Commit**
|
| 1295 |
-
|
| 1296 |
-
```bash
|
| 1297 |
-
git add bot/telegraph.py bot/media.py bot/app.py tests/test_telegraph.py tests/test_app.py
|
| 1298 |
-
git commit -m "feat: /mediainfo published to telegra.ph"
|
| 1299 |
-
```
|
| 1300 |
-
|
| 1301 |
-
---
|
| 1302 |
-
|
| 1303 |
-
### Task 10: `/smedia` and removing `START_IMAGE`
|
| 1304 |
-
|
| 1305 |
-
**Files:**
|
| 1306 |
-
- Modify: `bot/config.py` (drop the `start_image` field)
|
| 1307 |
-
- Modify: `bot/app.py` (the command, the animation handler, `/start`)
|
| 1308 |
-
- Modify: `bot/panels.py` (the `/smedia` panel)
|
| 1309 |
-
- Modify: `.env.example` (drop `START_IMAGE`)
|
| 1310 |
-
- Modify: `tests/test_app.py` (rewrite the start-image test, add the `/smedia` tests)
|
| 1311 |
-
- Modify: `tests/test_config.py` if it exists (drop the `START_IMAGE` assertions)
|
| 1312 |
-
|
| 1313 |
-
**Interfaces:**
|
| 1314 |
-
- Consumes: `db.get_start_media` / `set_start_media` / `clear_start_media` from Task 2.
|
| 1315 |
-
- Produces:
|
| 1316 |
-
- `panels.smedia_panel(kind: Optional[str], has_media: bool) -> Panel` — the owner panel.
|
| 1317 |
-
- The `smedia` owner command.
|
| 1318 |
-
|
| 1319 |
-
- [ ] **Step 1: Write the failing tests**
|
| 1320 |
-
|
| 1321 |
-
In `tests/test_app.py`, **replace** `test_start_image_is_sent_as_a_photo_with_the_panel_as_caption`
|
| 1322 |
-
with:
|
| 1323 |
-
|
| 1324 |
-
```python
|
| 1325 |
-
async def test_start_sends_the_stored_banner(ctx):
|
| 1326 |
-
await ctx.db.set_start_media("image", jpeg())
|
| 1327 |
-
ev = await say(ctx, OWNER, "/start")
|
| 1328 |
-
assert ev.out == []
|
| 1329 |
-
(chat, file, kw), = ctx.client.files
|
| 1330 |
-
assert chat == OWNER and "Auto Rename Bot" in kw["caption"]
|
| 1331 |
-
assert kw["buttons"] and kw["parse_mode"] == "html"
|
| 1332 |
-
|
| 1333 |
-
|
| 1334 |
-
async def test_start_falls_back_to_text_when_there_is_no_banner(ctx):
|
| 1335 |
-
ev = await say(ctx, OWNER, "/start")
|
| 1336 |
-
assert "Auto Rename Bot" in ev.texts[0] and ctx.client.files == []
|
| 1337 |
-
|
| 1338 |
-
|
| 1339 |
-
async def test_smedia_is_owner_only(ctx):
|
| 1340 |
-
ev = await say(ctx, ADMIN_CFG, "/smedia")
|
| 1341 |
-
assert "owner" in ev.texts[0].lower()
|
| 1342 |
-
|
| 1343 |
-
|
| 1344 |
-
async def test_smedia_stores_an_image_and_sends_it_on_start(ctx):
|
| 1345 |
-
ev = await say(ctx, OWNER, "/smedia")
|
| 1346 |
-
assert "smedia:image" in data_of(ev.out[0][1])
|
| 1347 |
-
await click(ctx, OWNER, "smedia:image")
|
| 1348 |
-
assert ctx.app.pending[OWNER].kind == "smedia_image"
|
| 1349 |
-
photo = FakeEvent(OWNER, message=FakeMessage(mid=70, photo=object()), photo_bytes=jpeg())
|
| 1350 |
-
await dispatch(ctx.client, photo)
|
| 1351 |
-
media = await ctx.db.get_start_media()
|
| 1352 |
-
assert media["kind"] == "image" and media["data"]
|
| 1353 |
-
```
|
| 1354 |
-
|
| 1355 |
-
- [ ] **Step 2: Run the tests to verify they fail**
|
| 1356 |
-
|
| 1357 |
-
Run: `pytest tests/test_app.py -v -k "smedia or start"`
|
| 1358 |
-
Expected: FAIL — `/smedia` is not a command and `Config` still has `start_image`.
|
| 1359 |
-
|
| 1360 |
-
- [ ] **Step 3: Remove `start_image` from `bot/config.py`**
|
| 1361 |
-
|
| 1362 |
-
Delete the `start_image: Optional[str]` field from the `Config` dataclass and the
|
| 1363 |
-
`start_image=env.get("START_IMAGE", "").strip() or None` argument from `load_config`.
|
| 1364 |
-
Delete the `START_IMAGE` lines from `.env.example`.
|
| 1365 |
-
|
| 1366 |
-
- [ ] **Step 4: Add the panel and the command to `bot/app.py`**
|
| 1367 |
-
|
| 1368 |
-
`panels.smedia_panel(kind, has_media)` — the text explains that the banner is shown on
|
| 1369 |
-
`/start` and the rows are `[("🖼 Image", "smedia:image"), ("🎞 GIF", "smedia:gif")]` plus
|
| 1370 |
-
`[("👁 View", "smedia:view"), ("🗑 Delete", "smedia:del")]` when a banner is stored.
|
| 1371 |
-
|
| 1372 |
-
`cmd_smedia(event)` is **owner-only**: add `"smedia"` to `OWNER_COMMANDS` in `bot/app.py` so
|
| 1373 |
-
the existing `_guard` enforces it.
|
| 1374 |
-
|
| 1375 |
-
The `smedia:view` callback sends the stored banner back to the owner as a photo (or an
|
| 1376 |
-
animation) with a "🗑 Delete" button. The `smedia:del` callback deletes it and re-renders the
|
| 1377 |
-
panel. Both must be no-ops (with a toast) when no banner is stored.
|
| 1378 |
-
|
| 1379 |
-
- [ ] **Step 5: Implement the `/smedia` flow in `bot/app.py`**
|
| 1380 |
-
|
| 1381 |
-
The `smedia:image` callback sets `Pending(kind="smedia_image")`; `smedia:gif` sets
|
| 1382 |
-
`Pending(kind="smedia_gif")`. Both ask the owner to send the file.
|
| 1383 |
-
|
| 1384 |
-
`on_photo`: when the pending kind is `smedia_image`, store the re-encoded JPEG with
|
| 1385 |
-
`db.set_start_media("image", data)` instead of the global thumbnail, and reply "✅ Start
|
| 1386 |
-
image saved."
|
| 1387 |
-
|
| 1388 |
-
A new `on_animation` handler (registered in `_register`) catches
|
| 1389 |
-
`events.NewMessage(incoming=True, func=lambda e: e.is_private and e.message.gif is not None)`.
|
| 1390 |
-
When the pending kind is `smedia_gif`, it stores the raw bytes with
|
| 1391 |
-
`db.set_start_media("gif", data)`. **A GIF must never be re-encoded** — the bytes are stored
|
| 1392 |
-
exactly as they arrived so it still animates.
|
| 1393 |
-
|
| 1394 |
-
When the pending kind is `smedia_gif` but the file is not an animation, reply "That's not a
|
| 1395 |
-
GIF — send me an animated one." and keep the pending.
|
| 1396 |
-
|
| 1397 |
-
`cmd_start`: read `db.get_start_media()`. When there is a banner, send it with
|
| 1398 |
-
`self.client.send_file(event.chat_id, io.BytesIO(data), caption=text, buttons=..., parse_mode="html", attributes=attrs)`
|
| 1399 |
-
where `attrs` is `[types.DocumentAttributeFilename("start.jpg")]` for an image and
|
| 1400 |
-
`[types.DocumentAttributeFilename("start.gif"), types.DocumentAttributeAnimated()]` for a
|
| 1401 |
-
GIF. When there is no banner, send the text panel as today.
|
| 1402 |
-
|
| 1403 |
-
- [ ] **Step 6: Run the tests to verify they pass**
|
| 1404 |
-
|
| 1405 |
-
Run: `pytest tests/test_app.py -v -k "smedia or start"`
|
| 1406 |
-
Expected: PASS
|
| 1407 |
-
|
| 1408 |
-
- [ ] **Step 7: Run the whole suite**
|
| 1409 |
-
|
| 1410 |
-
Run: `pytest -q`
|
| 1411 |
-
Expected: everything passes, except any test that still references `cfg.start_image` — search
|
| 1412 |
-
for `start_image` and remove those references.
|
| 1413 |
-
|
| 1414 |
-
- [ ] **Step 8: Commit**
|
| 1415 |
-
|
| 1416 |
-
```bash
|
| 1417 |
-
git add bot/config.py bot/app.py bot/panels.py .env.example tests/test_app.py
|
| 1418 |
-
git commit -m "feat: /smedia replaces the START_IMAGE environment variable"
|
| 1419 |
-
```
|
| 1420 |
-
|
| 1421 |
-
---
|
| 1422 |
-
|
| 1423 |
-
### Task 11: Command menu, help panel and README
|
| 1424 |
-
|
| 1425 |
-
**Files:**
|
| 1426 |
-
- Modify: `bot/app.py` (the `COMMANDS` list)
|
| 1427 |
-
- Modify: `bot/panels.py` (the help panel)
|
| 1428 |
-
- Modify: `README.md`
|
| 1429 |
-
|
| 1430 |
-
**Interfaces:**
|
| 1431 |
-
- Consumes: everything from Tasks 1–10.
|
| 1432 |
-
- Produces: the final command menu and documentation.
|
| 1433 |
-
|
| 1434 |
-
- [ ] **Step 1: Update the `COMMANDS` list in `bot/app.py`**
|
| 1435 |
-
|
| 1436 |
-
Add these entries to the `COMMANDS` list, keeping the existing ones:
|
| 1437 |
-
|
| 1438 |
-
```python
|
| 1439 |
-
("rename", "Pick a saved format and rename a file"),
|
| 1440 |
-
("formats", "Manage your saved rename formats"),
|
| 1441 |
-
("sequence", "Collect files to sort by episode"),
|
| 1442 |
-
("sort", "Sort the collected files and send them back"),
|
| 1443 |
-
("check", "See what the bot reads from a file"),
|
| 1444 |
-
("ethumb", "Extract a thumbnail from a video"),
|
| 1445 |
-
("resize", "Resize an image (reply to it)"),
|
| 1446 |
-
("speed", "Check the bot's internet speed"),
|
| 1447 |
-
("mediainfo", "Full media info of a file, published to telegra.ph"),
|
| 1448 |
-
("smedia", "Set the start image or GIF (owner only)"),
|
| 1449 |
-
```
|
| 1450 |
-
|
| 1451 |
-
Telegram allows at most 100 commands; the list stays well under that.
|
| 1452 |
-
|
| 1453 |
-
- [ ] **Step 2: Update the help panel in `bot/panels.py`**
|
| 1454 |
-
|
| 1455 |
-
Rewrite `help_panel()` to describe the new flow:
|
| 1456 |
-
|
| 1457 |
-
1. Send a file — it is renamed with your template and sent back.
|
| 1458 |
-
2. `/rename` — pick one of your saved formats, then send the file.
|
| 1459 |
-
3. `/formats` — create, edit and delete your saved formats.
|
| 1460 |
-
4. `/template` and `/caption` — the default naming and caption templates.
|
| 1461 |
-
5. `/mode` — read the details from the file name or the caption.
|
| 1462 |
-
6. `/check` — reply to a file to see what the bot reads from it.
|
| 1463 |
-
7. `/sequence` then `/sort` — collect files, sort them by episode, get them back in order.
|
| 1464 |
-
8. `/metadata`, `/keepaudio`, `/keepsubtitle` — tags and track clean-up.
|
| 1465 |
-
9. `/filetype` — send as document, video or audio.
|
| 1466 |
-
10. `/ethumb` and `/resize` — thumbnails and images.
|
| 1467 |
-
11. `/speed` and `/mediainfo` — the bot's line speed and a file's media info.
|
| 1468 |
-
12. `/queue` and `/cancel` — manage running jobs.
|
| 1469 |
-
|
| 1470 |
-
Keep the template-variables line at the bottom.
|
| 1471 |
-
|
| 1472 |
-
- [ ] **Step 3: Update the `README.md`**
|
| 1473 |
-
|
| 1474 |
-
- The features table: add rows for the saved formats, the sequence sorting, `/check`,
|
| 1475 |
-
`/ethumb`, `/resize`, `/speed`, `/mediainfo` and `/smedia`.
|
| 1476 |
-
- The commands table: add the new commands and change the `/rename` row to
|
| 1477 |
-
"Pick a saved format, then send the file".
|
| 1478 |
-
- The settings paragraph: remove `START_IMAGE` from the list of environment variables and say
|
| 1479 |
-
the start banner is set with `/smedia`.
|
| 1480 |
-
- The test count line: update it to the new number of tests.
|
| 1481 |
-
|
| 1482 |
-
- [ ] **Step 4: Run the whole suite**
|
| 1483 |
-
|
| 1484 |
-
Run: `pytest -q`
|
| 1485 |
-
Expected: everything passes
|
| 1486 |
-
|
| 1487 |
-
- [ ] **Step 5: Commit**
|
| 1488 |
-
|
| 1489 |
-
```bash
|
| 1490 |
-
git add bot/app.py bot/panels.py README.md
|
| 1491 |
-
git commit -m "docs: document the new commands and the format workflow"
|
| 1492 |
-
```
|
| 1493 |
-
|
| 1494 |
-
---
|
| 1495 |
-
|
| 1496 |
-
## Self-review
|
| 1497 |
-
|
| 1498 |
-
**Spec coverage.** Every section of the spec maps to a task: the format storage and the
|
| 1499 |
-
tri-state → Task 2 and Task 3; the picker, the editor and the enqueue-time overlay → Task 4;
|
| 1500 |
-
`/sequence` and `/sort` → Task 5; `/check` and the small caps → Task 1 and Task 6;
|
| 1501 |
-
`/ethumb` and `/resize` → Task 7; `/speed` and the Ookla install → Task 8; `/mediainfo` and
|
| 1502 |
-
Telegraph → Task 9; `/smedia` and the removal of `START_IMAGE` → Task 10; the command menu
|
| 1503 |
-
and the documentation → Task 11.
|
| 1504 |
-
|
| 1505 |
-
**Step scan.** Every step names a file, a signature or a test, and an exact expectation.
|
| 1506 |
-
The code blocks that remain are the ones the tests do not fully determine: the small-caps
|
| 1507 |
-
table, the sort key, the Ookla argument list, the callback data scheme and the Dockerfile
|
| 1508 |
-
layer.
|
| 1509 |
-
|
| 1510 |
-
**Type consistency.** `effective_info` is defined in Task 1 and consumed in Tasks 5 and 6.
|
| 1511 |
-
The format dict shape is defined in Task 2 and used by Tasks 3, 4 and 8. `resolve_format`
|
| 1512 |
-
and `resolve_thumbnail` are defined in Task 3 and used by Task 4. `plan_sort` and
|
| 1513 |
-
`detect_episodes` are defined in Task 5 and used by Task 5's handler. `Job.thumbnail_mode`,
|
| 1514 |
-
`Job.thumbnail` and `Job.settings_override` are defined in Task 4 and read in Task 4's
|
| 1515 |
-
pipeline change.
|
| 1516 |
-
|
| 1517 |
-
**Review Focus.** All five lines are pinned by a named test: the format template reaching
|
| 1518 |
-
the name (Task 4, `test_a_formats_template_reaches_the_file_name`), the explicitly-empty
|
| 1519 |
-
thumbnail (Task 4, `test_a_format_can_suppress_the_thumbnail_entirely`), the file with no
|
| 1520 |
-
episode surviving a sort (Task 5, `test_a_file_without_an_episode_is_kept_and_reported`),
|
| 1521 |
-
the open sequence suppressing auto-rename (Task 5,
|
| 1522 |
-
`test_an_open_sequence_collects_files_instead_of_renaming_them`), and the deleted format not
|
| 1523 |
-
breaking a queued job (Task 4, `test_deleting_a_format_does_not_break_a_queued_job`).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
docs/superpowers/specs/2026-10-20-media-tools-and-formats-design.md
DELETED
|
@@ -1,522 +0,0 @@
|
|
| 1 |
-
# Media Tools & Named Formats — Design
|
| 2 |
-
|
| 3 |
-
Date: 2026-10-20
|
| 4 |
-
Project: `autorename-bot` (Telegram auto-rename bot, Python + Telethon + ffmpeg)
|
| 5 |
-
|
| 6 |
-
## Intent and the why
|
| 7 |
-
|
| 8 |
-
The bot has grown into a personal toolbelt for a channel admin who renames, tags and
|
| 9 |
-
repackages a lot of media. Three things are in the way:
|
| 10 |
-
|
| 11 |
-
1. **One global template.** Every file is renamed with a single template, so a One Piece
|
| 12 |
-
batch and a Dragon Ball batch cannot be named differently without editing the setting
|
| 13 |
-
mid-run and losing the previous one.
|
| 14 |
-
2. **No way to reorder a batch.** When a season arrives as 25 separate files, there is no
|
| 15 |
-
way to see that episode 7 is missing, that episode 12 arrived twice, or to get the
|
| 16 |
-
files back in episode order.
|
| 17 |
-
3. **The command set is thin.** There is no way to ask what the bot actually extracted
|
| 18 |
-
from a file, no way to pull a thumbnail out of a video, no way to resize an image, no
|
| 19 |
-
way to see the bot's line speed, and no way to inspect a file's real media properties.
|
| 20 |
-
The start banner is baked into an environment variable, which means changing it means
|
| 21 |
-
redeploying.
|
| 22 |
-
|
| 23 |
-
This design adds a saved-format system, a collect-and-sort flow, an inspection command,
|
| 24 |
-
and four small media tools, and moves the start banner into the database behind an
|
| 25 |
-
owner-only command.
|
| 26 |
-
|
| 27 |
-
## Goals
|
| 28 |
-
|
| 29 |
-
- Save up to 12 **named** formats per user, each carrying its own template, thumbnail,
|
| 30 |
-
title, author, artist, audio, subtitle, video, keep-audio and keep-subtitle.
|
| 31 |
-
- `/rename` asks which format to use, then asks for the file, then renames it.
|
| 32 |
-
- Collect files, sort them by episode ascending, and report missing and duplicate
|
| 33 |
-
episodes.
|
| 34 |
-
- Show exactly what the bot can extract from a file's name and from its caption.
|
| 35 |
-
- Extract a thumbnail from a video, and resize an image.
|
| 36 |
-
- Report the bot's internet speed with the Ookla CLI.
|
| 37 |
-
- Publish a full media-info report to telegra.ph and return the link.
|
| 38 |
-
- Replace the `START_IMAGE` environment variable with an owner-only `/smedia` command
|
| 39 |
-
that accepts an image **or** a GIF.
|
| 40 |
-
|
| 41 |
-
## Non-goals
|
| 42 |
-
|
| 43 |
-
- Batch renaming many files in one command. `/rename` handles exactly one file per
|
| 44 |
-
invocation.
|
| 45 |
-
- Reordering the saved formats in the list. They keep creation order.
|
| 46 |
-
- Re-encoding video. Every existing rename path stays stream-copy.
|
| 47 |
-
- Any kind of "forwarded from" attribution on sorted output — copies, not forwards.
|
| 48 |
-
- Multi-user or shared formats. Each allowed user has their own 12.
|
| 49 |
-
|
| 50 |
-
## Anti-goals
|
| 51 |
-
|
| 52 |
-
The change must not slow down or destabilise the existing rename path. Anything that
|
| 53 |
-
touches the hot path (download → probe → ffmpeg → upload) is additive: the format
|
| 54 |
-
system plugs into it as an override, it does not replace it.
|
| 55 |
-
|
| 56 |
-
## Decisions already made (by the bot owner)
|
| 57 |
-
|
| 58 |
-
| Area | Decision |
|
| 59 |
-
|---|---|
|
| 60 |
-
| `/check` fields | Title, Season, Episode, Quality. **No chapter field.** |
|
| 61 |
-
| `/rename` | One file per invocation. After it renames, you start over with `/rename`. |
|
| 62 |
-
| `/rename <text>` | **Removed.** `/rename` is now only the format picker. |
|
| 63 |
-
| `/speed` | The **Ookla** CLI (`ookla-speedtest`), not the old `speedtest-cli` package. |
|
| 64 |
-
| `/sort` output | The **actual files** are re-sent in sorted order. |
|
| 65 |
-
| `/sort` mechanism | **Sent back with no "forwarded from" header**, one at a time so the order is exact. |
|
| 66 |
-
| `/sort` originals | The original messages are **kept** in the chat. |
|
| 67 |
-
| Format manager | **Button-driven**, not command-driven. |
|
| 68 |
-
| Format scope | **Per-user**, 12 slots each. |
|
| 69 |
-
| `/resize` input | **Reply to** an image. |
|
| 70 |
-
| Formats are named | e.g. "One Piece format", "Dragon Ball format" — not "Format 1". |
|
| 71 |
-
| `/rename` with no formats | Only "➕ New format" is offered. There is no global-template shortcut. |
|
| 72 |
-
| `/check` caption section | Shows exactly what caption mode would produce, not a field-wise merge. |
|
| 73 |
-
|
| 74 |
-
## How it is made
|
| 75 |
-
|
| 76 |
-
### 1. Saved formats
|
| 77 |
-
|
| 78 |
-
**Storage.** A new `formats` table — not the `users.settings` JSON blob, because each
|
| 79 |
-
format carries a thumbnail (binary) and a thumbnail does not belong in a JSON column.
|
| 80 |
-
|
| 81 |
-
```
|
| 82 |
-
formats
|
| 83 |
-
id integer primary key autoincrement
|
| 84 |
-
user_id bigint index, not null
|
| 85 |
-
name varchar(64) not null
|
| 86 |
-
position integer not null -- creation order, 0..11
|
| 87 |
-
template text null -- null = inherit the user's global template
|
| 88 |
-
thumb bytea null -- null = inherit, empty = no thumbnail, bytes = custom
|
| 89 |
-
meta text not null -- JSON: {"title": null|"", "author": null|"", ...}
|
| 90 |
-
keep_audio text null -- null = inherit, [] = keep all, ["english"] = only that
|
| 91 |
-
keep_subtitle text null -- same tri-state
|
| 92 |
-
unique (user_id, name)
|
| 93 |
-
```
|
| 94 |
-
|
| 95 |
-
`id` is `INTEGER PRIMARY KEY`, not `BIGINT`: the default database and the whole test
|
| 96 |
-
suite run on SQLite, and SQLite only auto-increments a column declared exactly
|
| 97 |
-
`INTEGER PRIMARY KEY`. SQLAlchemy renders `BigInteger` as `BIGINT`, so an insert
|
| 98 |
-
without an explicit id would fail on SQLite. Use `Integer` for the primary key, or
|
| 99 |
-
`BigInteger().with_variant(Integer, "sqlite")`.
|
| 100 |
-
|
| 101 |
-
**The tri-state.** Every format field has three possible states, and the distinction
|
| 102 |
-
matters:
|
| 103 |
-
|
| 104 |
-
- `null` — **inherit**: use the user's global setting for that field.
|
| 105 |
-
- `""` / `[]` — **explicitly empty**: override the global setting with "nothing".
|
| 106 |
-
This is how a format says "keep every track" (`keep_audio = []`) even when the
|
| 107 |
-
user's global keep-audio list is `["english"]`.
|
| 108 |
-
- a real value — **override**.
|
| 109 |
-
|
| 110 |
-
Without the tri-state there is no way to say "this format keeps all tracks" when the
|
| 111 |
-
global list is non-empty, and no way to say "this format has no title" when the global
|
| 112 |
-
title is set.
|
| 113 |
-
|
| 114 |
-
**Two fields are not tri-state, for good reason:**
|
| 115 |
-
|
| 116 |
-
- The **template** is inherit-or-value only. An empty template is not a useful state:
|
| 117 |
-
the global template rejects an empty value, and an empty template would render to
|
| 118 |
-
nothing, leaving the file with its original name. The editor offers "use my default
|
| 119 |
-
template" and a real template — never "no template".
|
| 120 |
-
- The **thumbnail** is a genuine tri-state, because the thumbnail is not a settings
|
| 121 |
-
field at all (it lives in `users.thumb`, not in the settings JSON). See below.
|
| 122 |
-
|
| 123 |
-
**Applying a format.** The effective settings for a job are the user's global settings
|
| 124 |
-
with the format's non-`null` fields overlaid on top.
|
| 125 |
-
|
| 126 |
-
This overlay has to happen in **two** places, not one, and the file name is the reason
|
| 127 |
-
the design originally got this wrong:
|
| 128 |
-
|
| 129 |
-
- `App.enqueue` computes the new file name with `compute_new_name(...)` from the
|
| 130 |
-
**global** settings, and puts the result in `Job.new_name`.
|
| 131 |
-
- `Renamer.process` reads the settings again, but only for the metadata / track / file
|
| 132 |
-
type / caption work — it never recomputes the name. It uploads `job.new_name` as it
|
| 133 |
-
stands.
|
| 134 |
-
|
| 135 |
-
So a format's template would never reach the file name if the overlay only happened
|
| 136 |
-
inside `process()`. The overlay must be resolved **at enqueue time**, where the name is
|
| 137 |
-
computed, and the resolved result travels on the job:
|
| 138 |
-
|
| 139 |
-
- `Job` gains `settings_override: Optional[Dict[str, Any]]` — the **already-resolved**
|
| 140 |
-
effective settings, not a `format_id`.
|
| 141 |
-
- `App.enqueue` resolves the format into an effective settings dict, computes the new
|
| 142 |
-
name from **that** dict, and stores both on the job.
|
| 143 |
-
- `Renamer.process` becomes `s = merge_settings(global_settings, job.settings_override)`
|
| 144 |
-
before it does anything else.
|
| 145 |
-
|
| 146 |
-
Resolving at enqueue time also means a format deleted between enqueue and run cannot
|
| 147 |
-
break a job already in the queue.
|
| 148 |
-
|
| 149 |
-
**The thumbnail is resolved separately.** The global thumbnail is a blob in
|
| 150 |
-
`users.thumb`, not a settings key, so `merge_settings` cannot carry it. The effective
|
| 151 |
-
thumbnail is resolved on its own and passed to the pipeline alongside the settings
|
| 152 |
-
override. Its three states:
|
| 153 |
-
|
| 154 |
-
| State | Meaning | Sent with the file |
|
| 155 |
-
|---|---|---|
|
| 156 |
-
| inherit | use the user's global thumbnail | the global thumbnail; if there is none, a frame of the video |
|
| 157 |
-
| custom | this format has its own poster | the format's image |
|
| 158 |
-
| explicitly empty | no thumbnail at all | **nothing** — not even an automatic video frame |
|
| 159 |
-
|
| 160 |
-
"Explicitly empty" therefore has to suppress the pipeline's existing video-frame
|
| 161 |
-
fallback, and an empty byte string must never be handed to `send_file(thumb=...)`.
|
| 162 |
-
This is a real change to the thumbnail block in `pipeline._run`, and getting it wrong
|
| 163 |
-
silently changes the preview image on every renamed file.
|
| 164 |
-
|
| 165 |
-
Fields a format does **not** carry fall back to the global settings: mode
|
| 166 |
-
(filename/caption), file type, caption template, remove-words, target channel, and the
|
| 167 |
-
metadata on/off switch.
|
| 168 |
-
|
| 169 |
-
**Flow.**
|
| 170 |
-
|
| 171 |
-
- `/formats` → a panel listing the user's formats as buttons (name, with a marker if the
|
| 172 |
-
format overrides the global template), plus "➕ New format" when there is room, plus
|
| 173 |
-
Back/Home.
|
| 174 |
-
- `/rename` → the same list of format buttons. Picking one sets a pending state
|
| 175 |
-
(`kind="rename_format"`, `format_id=…`) and the bot says "Send me the file to rename
|
| 176 |
-
with **<name>**." The next document the user sends is renamed with that format.
|
| 177 |
-
The pending state is cleared as soon as that file is queued, so a second file needs a
|
| 178 |
-
fresh `/rename`.
|
| 179 |
-
- **With no saved formats, `/rename` shows only "➕ New format".** There is no
|
| 180 |
-
"global template" shortcut and no fallback: renaming goes through a saved format,
|
| 181 |
-
full stop. The global template is not dead, though — it is the *default a new format
|
| 182 |
-
starts from*, because a new format's template field begins in the "inherit" state
|
| 183 |
-
and therefore resolves to the user's global template until they change it.
|
| 184 |
-
- Creating a format: "➕ New format" → the bot asks for a name → the format editor
|
| 185 |
-
panel opens.
|
| 186 |
-
- The format editor shows one button per field (Template, Thumbnail, Title, Author,
|
| 187 |
-
Artist, Audio, Subtitle, Video, Keep Audio, Keep Subtitle) plus Rename, Delete and
|
| 188 |
-
Back. Tapping a field asks for its value as text (or a photo for the thumbnail) and
|
| 189 |
-
returns to the editor. Every field offers a "clear / use default" option that sets
|
| 190 |
-
the field to its explicit-empty or inherit state.
|
| 191 |
-
- Deleting asks for confirmation, then removes the row.
|
| 192 |
-
- The 12-slot limit is enforced: the "➕ New format" button is not shown at 12.
|
| 193 |
-
|
| 194 |
-
**Thumbnail states.** A format's thumbnail is `null` (inherit the user's global
|
| 195 |
-
thumbnail), empty bytes (send no thumbnail) or the stored JPEG. Sending a photo while
|
| 196 |
-
the editor is open on the Thumbnail field sets it. A "use my default" button sets it
|
| 197 |
-
back to `null`, and a "no thumbnail" button sets it to empty.
|
| 198 |
-
|
| 199 |
-
### 2. `/sequence` and `/sort`
|
| 200 |
-
|
| 201 |
-
**Flow.**
|
| 202 |
-
|
| 203 |
-
1. `/sequence` → the bot replies "Send me the files to sort. When you're done, send
|
| 204 |
-
`/sort`." with a "✖ Cancel" button.
|
| 205 |
-
2. Every file the user sends while a sequence is open is **collected, not renamed**.
|
| 206 |
-
An open sequence suspends auto-rename for that user. The bot acknowledges each file
|
| 207 |
-
with a running count.
|
| 208 |
-
3. `/sort` → the bot sorts the collected files, copies them back into the chat in
|
| 209 |
-
episode order, and reports missing and duplicate episodes.
|
| 210 |
-
4. After a sort the sequence closes. The originals stay in the chat.
|
| 211 |
-
|
| 212 |
-
**Suspending auto-rename.** Today every incoming document is renamed and re-uploaded
|
| 213 |
-
(`App.on_media` → `App.enqueue`). While a sequence is open for that user, the same
|
| 214 |
-
handler collects the file instead. This is a check at the top of the media handler
|
| 215 |
-
against the open-sequence table — it must not fall through to the rename path.
|
| 216 |
-
|
| 217 |
-
**Storage.** A `sequences` table so a sequence survives a bot restart (the bot's whole
|
| 218 |
-
premise is that it runs on a free Space where the disk is wiped):
|
| 219 |
-
|
| 220 |
-
```
|
| 221 |
-
sequences
|
| 222 |
-
user_id bigint primary key
|
| 223 |
-
items text not null -- JSON: [{"msg_id": 12, "name": "ep01.mkv", "caption": "..."}]
|
| 224 |
-
copied integer not null -- how many files were already copied out
|
| 225 |
-
updated_at timestamptz
|
| 226 |
-
```
|
| 227 |
-
|
| 228 |
-
**Episode detection happens at sort time, not at collection time.** The caption is
|
| 229 |
-
needed to merge with the filename in caption mode, so the caption is stored alongside
|
| 230 |
-
the name. The user's mode is read once, when `/sort` runs. This means a mode change
|
| 231 |
-
part-way through a batch applies to the whole batch — which is the sane behaviour —
|
| 232 |
-
rather than producing a half-and-half batch.
|
| 233 |
-
|
| 234 |
-
**Sorting.** Sort key is `(season, episode)` ascending, both zero-padded strings, so
|
| 235 |
-
`S01E02` sorts before `S01E10`. Files with no episode keep their arrival order and sit
|
| 236 |
-
after every numbered file.
|
| 237 |
-
|
| 238 |
-
A file whose episode number cannot be found is **not** dropped. It is sorted to the
|
| 239 |
-
end, in the order it was received, and listed separately in the report as "no episode
|
| 240 |
-
number".
|
| 241 |
-
|
| 242 |
-
**Missing and duplicates.**
|
| 243 |
-
|
| 244 |
-
- *Missing* — for each season present, the episodes from the lowest to the highest
|
| 245 |
-
number present, minus the ones actually there. Reported as `S01E03, S01E07`.
|
| 246 |
-
- *Duplicates* — the same `(season, episode)` appearing more than once. Reported with
|
| 247 |
-
the episode and the file names involved.
|
| 248 |
-
|
| 249 |
-
Both are reported after the sort finishes, in one message, in this order: how many
|
| 250 |
-
files were sorted, the missing list, the duplicate list, and the files that had no
|
| 251 |
-
episode number. Any of those sections is omitted when empty.
|
| 252 |
-
|
| 253 |
-
**Copying back.** The files are sent back without a "forwarded from" header. They go
|
| 254 |
-
out **one at a time**, awaiting each one, with a short delay between them, because
|
| 255 |
-
Telegram does not guarantee the order of a batch send. Each one is a `forward_messages`
|
| 256 |
-
call with `drop_author=True` from the chat to itself — Telethon has no true copy, and
|
| 257 |
-
dropping the author is what removes the header. **Telethon has no true copy**
|
| 258 |
-
(that is Bot-API only), so the implementation uses `forward_messages` with
|
| 259 |
-
`drop_author=True`, which drops the attribution and leaves no "forwarded from" header.
|
| 260 |
-
|
| 261 |
-
The original messages are kept. If a copy fails part-way, the bot says how many were
|
| 262 |
-
copied and leaves the sequence open so the user can retry — and the retry **resumes**
|
| 263 |
-
from where it stopped rather than re-copying the files that already went out. The
|
| 264 |
-
`copied` counter on the sequence row is what makes that possible.
|
| 265 |
-
|
| 266 |
-
**The report.** Something like:
|
| 267 |
-
|
| 268 |
-
```
|
| 269 |
-
✅ Sorted 25 files.
|
| 270 |
-
|
| 271 |
-
⚠️ Missing: S01E03, S01E07
|
| 272 |
-
⚠️ Duplicates: S01E12 (episode 12.mkv, episode 12 (copy).mkv)
|
| 273 |
-
⚠️ No episode number: bonus.mkv, extras.mp4
|
| 274 |
-
```
|
| 275 |
-
|
| 276 |
-
### 3. `/check`
|
| 277 |
-
|
| 278 |
-
Reply to a file with `/check` and the bot shows what it can extract, using the
|
| 279 |
-
small-caps style the owner picked:
|
| 280 |
-
|
| 281 |
-
```
|
| 282 |
-
Yᴏᴜ Cᴀɴ Cʰᴀɴɢᴇ Mᴏᴅᴇ Bʏ /mode
|
| 283 |
-
|
| 284 |
-
FɪʟᴇNᴀᴍᴇ:
|
| 285 |
-
➦ Title ⭆ Dragon Ball Super Beerus
|
| 286 |
-
➦ Season ⭆ 01
|
| 287 |
-
➦ Episode ⭆ 02
|
| 288 |
-
➦ Quality ⭆ 1080p
|
| 289 |
-
|
| 290 |
-
Cᴀᴘᴛɪᴏɴ Mᴏᴅᴇ:
|
| 291 |
-
➦ Title ⭆ Dragon Ball Super Beerus
|
| 292 |
-
➦ Season ⭆ 01
|
| 293 |
-
➦ Episode ⭆ 02
|
| 294 |
-
➦ Quality ⭆ 1080p
|
| 295 |
-
```
|
| 296 |
-
|
| 297 |
-
- The **FileName** section is the parse of the file's name.
|
| 298 |
-
- The **Caption Mode** section is **exactly what a rename in caption mode would use**
|
| 299 |
-
for this file. That is the rule `compute_new_name` already applies, and it is not the
|
| 300 |
-
same as a plain field-wise merge:
|
| 301 |
-
- The caption is parsed. If that parse produced any structured field (season,
|
| 302 |
-
episode, quality or year), the caption's values win and the filename fills in only
|
| 303 |
-
the fields the caption did not supply.
|
| 304 |
-
- If the caption produced **no** structured field, the caption is discarded entirely
|
| 305 |
-
and the filename parse is used unchanged — including its title.
|
| 306 |
-
So for a file named `ep01.mkv` with the caption "My Show", the Caption Mode section
|
| 307 |
-
shows the title from the *filename*, not "My Show". That is the point of the command:
|
| 308 |
-
it shows the two modes' real output, so the user can tell whether switching modes
|
| 309 |
-
would change anything.
|
| 310 |
-
- Section headings are small-capped; the field names (Title, Season, …) and the values
|
| 311 |
-
are normal.
|
| 312 |
-
- **Year** and **Codec** lines appear only when the parser found them, so a file
|
| 313 |
-
without them produces exactly the output above.
|
| 314 |
-
|
| 315 |
-
The mode-aware merge must be one shared helper, used by `/check` and by `/sort`'s
|
| 316 |
-
episode detection, so the two can never disagree with what a rename would do.
|
| 317 |
-
|
| 318 |
-
**Small caps.** A new helper maps a string to the Unicode small-capital letters and
|
| 319 |
-
leaves anything unmapped alone:
|
| 320 |
-
|
| 321 |
-
```
|
| 322 |
-
a→ᵃ b→ʙ c→ᴄ d→ᴅ e→ᴇ f→ꜰ g→ɢ h→ʜ i→ɪ j→ᴊ k→ᴋ l→ʟ m→ᴍ
|
| 323 |
-
n→ɴ o→ᴏ p→ᴘ q→ǫ r→ʀ s→s t→ᴛ u→ᴜ v→ᴠ w→ᴡ x→x y→ʿ z→ᴢ
|
| 324 |
-
```
|
| 325 |
-
|
| 326 |
-
Uppercase letters map to the same glyphs as lowercase, which is what makes
|
| 327 |
-
"Yᴏᴜ" out of "Your". Digits, spaces and punctuation are untouched. The helper is
|
| 328 |
-
pure and unit-tested.
|
| 329 |
-
|
| 330 |
-
### 4. `/ethumb`
|
| 331 |
-
|
| 332 |
-
Reply to a video or media file with `/ethumb`:
|
| 333 |
-
|
| 334 |
-
1. The bot downloads the file to a temp dir.
|
| 335 |
-
2. `ffmpeg` grabs a frame at ~10% of the duration (the same seek point the automatic
|
| 336 |
-
video thumbnail uses) at the source width, written as a JPEG.
|
| 337 |
-
3. The bot sends the image back as a photo, with a "Set as my thumbnail" button and a
|
| 338 |
-
"Close" button.
|
| 339 |
-
|
| 340 |
-
"Set as my thumbnail" stores the image as the user's global thumbnail, resized down to
|
| 341 |
-
Telegram's limits by the existing `media.prepare_thumb` helper. A format's thumbnail
|
| 342 |
-
is set by sending a photo while the format editor is open — `/ethumb` does not edit
|
| 343 |
-
formats.
|
| 344 |
-
|
| 345 |
-
If the file has no video stream (an audio file, an image), the bot says so rather than
|
| 346 |
-
failing silently.
|
| 347 |
-
|
| 348 |
-
### 5. `/resize`
|
| 349 |
-
|
| 350 |
-
Reply to an image with `/resize`:
|
| 351 |
-
|
| 352 |
-
1. The bot replies with size buttons: **320**, **640**, **1280**, **Original**,
|
| 353 |
-
**Custom**, plus Close.
|
| 354 |
-
2. Picking a preset resizes immediately. **Custom** asks for a size: a single number is
|
| 355 |
-
a maximum width and keeps the aspect ratio; `WIDTHxHEIGHT` forces that exact size.
|
| 356 |
-
(This replaces the "force exact size" toggle discussed earlier — the shape of what
|
| 357 |
-
you type decides.)
|
| 358 |
-
3. The resized image is sent back as a photo with a "Set as my thumbnail" button.
|
| 359 |
-
|
| 360 |
-
Resizing re-encodes the image; that is inherent to resizing and is the one place in
|
| 361 |
-
this design where bytes are rewritten. Pillow is already a dependency.
|
| 362 |
-
|
| 363 |
-
Both `/resize` and `/ethumb` accept an image sent as a photo **or** as a document.
|
| 364 |
-
|
| 365 |
-
### 6. `/speed`
|
| 366 |
-
|
| 367 |
-
The Ookla CLI is installed in the Dockerfile and run as a subprocess:
|
| 368 |
-
|
| 369 |
-
```
|
| 370 |
-
ookla-speedtest --accept-license --accept-gdpr --simple --format=json
|
| 371 |
-
```
|
| 372 |
-
|
| 373 |
-
- `--accept-license` and `--accept-gdpr` are passed so the tool never blocks on a
|
| 374 |
-
prompt.
|
| 375 |
-
- `--simple` skips the server scan and latency test, so a run takes ~15–30 seconds
|
| 376 |
-
instead of a minute.
|
| 377 |
-
- `--format=json` makes the output machine-readable. A pure function parses that JSON
|
| 378 |
-
into the values shown in the chat, so the parsing is unit-tested without a network.
|
| 379 |
-
|
| 380 |
-
The bot replies with a "Testing your connection…" message first, then reports download
|
| 381 |
-
speed, upload speed, ping, the test server and the ISP. A timeout of 120 seconds
|
| 382 |
-
applies. If the binary is missing (a local run without the Docker image), the bot says
|
| 383 |
-
so clearly instead of showing a stack trace.
|
| 384 |
-
|
| 385 |
-
**Dockerfile change.** `python:3.12-slim` has no `curl`, so it is added to the apt
|
| 386 |
-
install line, and the Ookla tarball is unpacked to `/usr/local/bin/ookla-speedtest`:
|
| 387 |
-
|
| 388 |
-
```dockerfile
|
| 389 |
-
RUN apt-get update \
|
| 390 |
-
&& apt-get install -y --no-install-recommends ffmpeg ca-certificates curl \
|
| 391 |
-
&& rm -rf /var/lib/apt/lists/*
|
| 392 |
-
|
| 393 |
-
RUN curl -sSL https://s3.amazonaws.com/ookla.speedtest.net/tools/cli/ookla-speedtest-1.2.0-linux-x86_64.tgz -o /tmp/ookla.tgz \
|
| 394 |
-
&& tar -xzf /tmp/ookla.tgz -C /tmp \
|
| 395 |
-
&& install -m 0755 /tmp/ookla-speedtest /usr/local/bin/ookla-speedtest \
|
| 396 |
-
&& rm -rf /tmp/ookla.tgz /tmp/ookla-speedtest
|
| 397 |
-
```
|
| 398 |
-
|
| 399 |
-
The tarball is x86-64 only, which matches the Space's architecture. The version is
|
| 400 |
-
pinned so a rebuild does not silently change the tool.
|
| 401 |
-
|
| 402 |
-
### 7. `/mediainfo`
|
| 403 |
-
|
| 404 |
-
Reply to a file with `/mediainfo`:
|
| 405 |
-
|
| 406 |
-
1. The bot downloads the file (whole file — the point of this command is complete
|
| 407 |
-
information) and runs `ffprobe` with `-show_format -show_streams -show_chapters
|
| 408 |
-
-show_programs`.
|
| 409 |
-
2. The raw JSON and a readable summary are turned into an HTML page.
|
| 410 |
-
3. The page is uploaded to telegra.ph and the bot replies with the link.
|
| 411 |
-
|
| 412 |
-
**Telegraph.** No new dependency: `aiohttp` is already installed, so a small
|
| 413 |
-
`bot/telegraph.py` posts directly to the public API — `createAccount` to get an access
|
| 414 |
-
token, then `uploadPage`. The access token is cached in the `kv` table so a new
|
| 415 |
-
account is not created for every request. If telegra.ph is unreachable from the host,
|
| 416 |
-
the bot says so and shows the readable summary in the chat instead, so the command is
|
| 417 |
-
never a dead end.
|
| 418 |
-
|
| 419 |
-
**Page content.** A summary section (container, duration, size, bitrate, tags), a
|
| 420 |
-
stream table (index, type, codec, language, resolution, channels, sample rate, bitrate,
|
| 421 |
-
title, disposition flags), the chapters, and finally the raw ffprobe JSON in a `<pre>`
|
| 422 |
-
block. Raw JSON is truncated if it would exceed roughly 100 KB.
|
| 423 |
-
|
| 424 |
-
### 8. `/smedia` and removing `START_IMAGE`
|
| 425 |
-
|
| 426 |
-
`/smedia` is **owner-only** (it joins `addadmin`, `deladmin`, `admins` in
|
| 427 |
-
`OWNER_COMMANDS`).
|
| 428 |
-
|
| 429 |
-
1. `/smedia` → the bot replies with "🖼 Image" and "🎞 GIF" buttons, plus "👁 View" and
|
| 430 |
-
"🗑 Delete" for the current banner.
|
| 431 |
-
2. Picking Image or GIF asks the owner to send the file. The next photo, animation or
|
| 432 |
-
document that arrives is stored.
|
| 433 |
-
3. The banner is stored in a new `bot_media` table (one row):
|
| 434 |
-
|
| 435 |
-
```
|
| 436 |
-
bot_media
|
| 437 |
-
id integer primary key -- always 1
|
| 438 |
-
kind varchar(8) -- 'image' | 'gif'
|
| 439 |
-
data bytea not null
|
| 440 |
-
```
|
| 441 |
-
|
| 442 |
-
4. The received file is **validated against the kind that was chosen**: picking GIF
|
| 443 |
-
and sending a JPEG is rejected with a clear message ("that's a photo — send me an
|
| 444 |
-
animated GIF"), and the stored `kind` always matches the bytes that were stored. A
|
| 445 |
-
JPEG stored as `kind='gif'` would be sent back as an animation with a still first
|
| 446 |
-
frame, so the mismatch is caught rather than accepted.
|
| 447 |
-
5. A photo is re-encoded to JPEG for storage. A GIF's bytes are stored exactly as
|
| 448 |
-
they were sent, so it still animates.
|
| 449 |
-
6. `/start` sends the stored banner instead of the environment variable. A GIF is sent
|
| 450 |
-
with `DocumentAttributeAnimated` so Telegram renders it as an animation; an image is
|
| 451 |
-
sent as a photo.
|
| 452 |
-
|
| 453 |
-
**Removal.** `START_IMAGE` is deleted from `bot/config.py` (the `Config` dataclass
|
| 454 |
-
field and the `load_config` call), from `.env.example` and from the README. The
|
| 455 |
-
banner now survives a restart because it lives in the database.
|
| 456 |
-
|
| 457 |
-
## What the builder has to decide, and what is already decided
|
| 458 |
-
|
| 459 |
-
**Already decided (do not re-litigate):** the flow of every command above, the tri-state
|
| 460 |
-
inheritance model, copy-not-forward for `/sort`, originals kept, per-user formats, the
|
| 461 |
-
12-slot limit, Ookla for `/speed`, no chapter field in `/check`, `/rename <text>`
|
| 462 |
-
removed, `/resize` replies to an image.
|
| 463 |
-
|
| 464 |
-
**Left to the builder (any sensible choice is fine):**
|
| 465 |
-
|
| 466 |
-
- The exact layout of the new panels — button order, wording, how the format editor
|
| 467 |
-
presents a field that is currently inheriting.
|
| 468 |
-
- Where the new code lives: the design names `bot/formats.py`, `bot/sequence.py`,
|
| 469 |
-
`bot/speedtest.py` and `bot/telegraph.py` as suggestions, not requirements. Keep new
|
| 470 |
-
pure logic out of `app.py`, which is already 774 lines.
|
| 471 |
-
- Whether the small-caps helper lives in `utils.py` or its own module.
|
| 472 |
-
- The exact wording of every user-facing string, beyond the `/check` layout that is
|
| 473 |
-
given above.
|
| 474 |
-
- How the `ffmpeg` frame-extraction call is factored — `media.py` already has
|
| 475 |
-
`make_video_thumbnail`, which grabs a 320px-wide frame; the `/ethumb` version needs
|
| 476 |
-
the source width, so it is a new function rather than a changed one.
|
| 477 |
-
|
| 478 |
-
## Constraints
|
| 479 |
-
|
| 480 |
-
- Telegram: bot uploads cap at 2000 MB (`MAX_FILE_MB`); a chat message is 4096
|
| 481 |
-
characters; an inline-button callback payload is 64 bytes; inline keyboards carry at
|
| 482 |
-
most 100 buttons.
|
| 483 |
-
- The bot only reacts in private chats. Unknown users are ignored.
|
| 484 |
-
- The Space is free-tier CPU with 50 GB of scratch disk. `/mediainfo` and `/ethumb`
|
| 485 |
-
download whole files, so they respect the same size limit as a rename.
|
| 486 |
-
- The existing test suite (266 tests) must keep passing and must not need the network.
|
| 487 |
-
|
| 488 |
-
## Risks
|
| 489 |
-
|
| 490 |
-
- **Copying 25 files in order** needs 25 API calls. Telegram may rate-limit; the copy
|
| 491 |
-
loop must handle `FloodWaitError` by waiting, and must not reorder the output.
|
| 492 |
-
- **telegra.ph** may be blocked or slow from some hosts; the fallback to a chat
|
| 493 |
-
summary covers it.
|
| 494 |
-
- **Ookla** needs outbound access to the speedtest servers and moves a few tens of
|
| 495 |
-
megabytes; it is a manual command, never automatic.
|
| 496 |
-
- **A format's thumbnail** is stored per format; 12 formats × a JPEG each is small, but
|
| 497 |
-
the table is binary and must not be loaded into the settings cache.
|
| 498 |
-
|
| 499 |
-
## Testing
|
| 500 |
-
|
| 501 |
-
New tests, no network:
|
| 502 |
-
|
| 503 |
-
- Format CRUD, the 12-slot limit, name uniqueness, and the tri-state overlay
|
| 504 |
-
producing the expected effective settings.
|
| 505 |
-
- Sorting: ordering across seasons, zero-padded episode order, missing-episode
|
| 506 |
-
detection, duplicate detection, and files with no episode number.
|
| 507 |
-
- The small-caps mapping, including the exact `/check` layout.
|
| 508 |
-
- The size parser for `/resize` (`800`, `800x450`, invalid input).
|
| 509 |
-
- The Ookla JSON parser.
|
| 510 |
-
- The Telegraph page builder.
|
| 511 |
-
|
| 512 |
-
The existing 266 tests must still pass, **except** the ones that cover behaviour this
|
| 513 |
-
design deliberately removes. Those are rewritten to cover the new behaviour:
|
| 514 |
-
|
| 515 |
-
- `test_rename_command_uses_the_given_name_and_keeps_the_extension` and
|
| 516 |
-
`test_rename_usage_messages` assert the removed `/rename <text>` flow → rewritten
|
| 517 |
-
for the format picker.
|
| 518 |
-
- `test_start_image_is_sent_as_a_photo_with_the_panel_as_caption` builds its config
|
| 519 |
-
with `dataclasses.replace(ctx.cfg, start_image=...)`, which raises `TypeError` once
|
| 520 |
-
the `Config` field is gone → rewritten for the database-stored banner.
|
| 521 |
-
|
| 522 |
-
"Still passing" means no regressions outside the deliberately removed features.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/conftest.py
CHANGED
|
@@ -41,17 +41,6 @@ def ffprobe_json(path, *extra):
|
|
| 41 |
return json.loads(out)
|
| 42 |
|
| 43 |
|
| 44 |
-
@pytest.fixture
|
| 45 |
-
async def db(tmp_path):
|
| 46 |
-
"""A fresh SQLite database for the tests that need one."""
|
| 47 |
-
from bot.db import Database
|
| 48 |
-
|
| 49 |
-
database = Database(f"sqlite+aiosqlite:///{tmp_path}/t.db")
|
| 50 |
-
await database.init()
|
| 51 |
-
yield database
|
| 52 |
-
await database.close()
|
| 53 |
-
|
| 54 |
-
|
| 55 |
@pytest.fixture(scope="session")
|
| 56 |
def media_dir(tmp_path_factory):
|
| 57 |
d = tmp_path_factory.mktemp("media")
|
|
|
|
| 41 |
return json.loads(out)
|
| 42 |
|
| 43 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
@pytest.fixture(scope="session")
|
| 45 |
def media_dir(tmp_path_factory):
|
| 46 |
d = tmp_path_factory.mktemp("media")
|
tests/test_app.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
"""Handler-level tests: real Telethon event builders, fake events and client."""
|
| 2 |
import asyncio
|
|
|
|
| 3 |
import io
|
| 4 |
import shutil
|
| 5 |
import time
|
|
@@ -185,119 +186,16 @@ async def test_start_variants(ctx, text):
|
|
| 185 |
assert (await say(ctx, OWNER, text)).out
|
| 186 |
|
| 187 |
|
| 188 |
-
async def
|
| 189 |
-
|
| 190 |
ev = await say(ctx, OWNER, "/start")
|
| 191 |
assert ev.out == []
|
| 192 |
(chat, file, kw), = ctx.client.files
|
| 193 |
-
assert chat == OWNER
|
| 194 |
-
assert kw["buttons"] and kw["parse_mode"] == "html"
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
async def test_start_falls_back_to_text_when_there_is_no_banner(ctx):
|
| 198 |
ev = await say(ctx, OWNER, "/start")
|
| 199 |
-
assert "Auto Rename Bot" in ev.texts[0]
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
async def test_a_stored_gif_is_sent_as_an_animation(ctx):
|
| 203 |
-
await ctx.db.set_start_media("gif", b"\x00gif")
|
| 204 |
-
await say(ctx, OWNER, "/start")
|
| 205 |
-
(chat, file, kw), = ctx.client.files
|
| 206 |
-
assert chat == OWNER
|
| 207 |
-
assert kw.get("force_document") is True
|
| 208 |
-
names = [getattr(a, "file_name", None) for a in kw.get("attributes") or []]
|
| 209 |
-
assert "start.gif" in names
|
| 210 |
-
assert any(type(a).__name__ == "DocumentAttributeAnimated" for a in kw.get("attributes") or [])
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
async def test_a_stored_image_is_sent_as_a_photo(ctx):
|
| 214 |
-
await ctx.db.set_start_media("image", jpeg())
|
| 215 |
-
await say(ctx, OWNER, "/start")
|
| 216 |
-
(chat, file, kw), = ctx.client.files
|
| 217 |
-
assert kw.get("force_document") is not True # a photo, not a document
|
| 218 |
-
assert not kw.get("attributes")
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
async def test_smedia_is_owner_only(ctx):
|
| 222 |
-
ev = await say(ctx, ADMIN_CFG, "/smedia")
|
| 223 |
-
assert "owner" in ev.texts[0].lower()
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
async def test_smedia_stores_an_image_and_sends_it_on_start(ctx):
|
| 227 |
-
ev = await say(ctx, OWNER, "/smedia")
|
| 228 |
-
assert "smedia:image" in data_of(ev.out[0][1])
|
| 229 |
-
await click(ctx, OWNER, "smedia:image")
|
| 230 |
-
assert ctx.app.pending[OWNER].kind == "smedia_image"
|
| 231 |
-
photo = FakeEvent(OWNER, message=FakeMessage(mid=70, photo=object()), photo_bytes=jpeg())
|
| 232 |
-
await dispatch(ctx.client, photo)
|
| 233 |
-
media_row = await ctx.db.get_start_media()
|
| 234 |
-
assert media_row["kind"] == "image" and media_row["data"]
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
async def test_smedia_stores_a_gif(ctx):
|
| 238 |
-
await click(ctx, OWNER, "smedia:gif")
|
| 239 |
-
assert ctx.app.pending[OWNER].kind == "smedia_gif"
|
| 240 |
-
gif = FakeEvent(OWNER, message=FakeMessage(mid=71, gif=object()), photo_bytes=b"\x00gifdata")
|
| 241 |
-
await dispatch(ctx.client, gif)
|
| 242 |
-
media_row = await ctx.db.get_start_media()
|
| 243 |
-
assert media_row["kind"] == "gif" and media_row["data"] == b"\x00gifdata"
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
async def test_the_start_screen_can_be_viewed_and_deleted(ctx):
|
| 247 |
-
await ctx.db.set_start_media("image", jpeg())
|
| 248 |
-
await click(ctx, OWNER, "smedia:view")
|
| 249 |
-
assert ctx.client.files
|
| 250 |
-
await click(ctx, OWNER, "smedia:del")
|
| 251 |
-
assert await ctx.db.get_start_media() is None
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
async def test_a_format_thumbnail_is_set_by_sending_a_photo(ctx):
|
| 255 |
-
fmt = await ctx.db.create_format(OWNER, "Poster")
|
| 256 |
-
await click(ctx, OWNER, f"fthumb:{fmt['id']}")
|
| 257 |
-
assert ctx.app.pending[OWNER].kind == "fmt_thumb"
|
| 258 |
-
photo = FakeEvent(OWNER, message=FakeMessage(mid=72, photo=object()), photo_bytes=jpeg())
|
| 259 |
-
await dispatch(ctx.client, photo)
|
| 260 |
-
stored = await ctx.db.get_format(OWNER, fmt["id"])
|
| 261 |
-
assert stored["thumb"] # a JPEG was stored
|
| 262 |
-
assert OWNER not in ctx.app.pending
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
async def test_renaming_a_format(ctx):
|
| 266 |
-
fmt = await ctx.db.create_format(OWNER, "Old Name")
|
| 267 |
-
await click(ctx, OWNER, f"frename:{fmt['id']}")
|
| 268 |
-
assert ctx.app.pending[OWNER].kind == "fmt_name"
|
| 269 |
-
await say(ctx, OWNER, "New Name")
|
| 270 |
-
assert (await ctx.db.get_format(OWNER, fmt["id"]))["name"] == "New Name"
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
async def test_the_delete_asks_first(ctx):
|
| 274 |
-
fmt = await ctx.db.create_format(OWNER, "Doomed")
|
| 275 |
-
cb = await click(ctx, OWNER, f"fdel:{fmt['id']}")
|
| 276 |
-
assert "Delete" in cb.edits[0][0]
|
| 277 |
-
assert await ctx.db.get_format(OWNER, fmt["id"]) is not None # not gone yet
|
| 278 |
-
await click(ctx, OWNER, f"fdelok:{fmt['id']}")
|
| 279 |
-
assert await ctx.db.get_format(OWNER, fmt["id"]) is None
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
async def test_speed_reports_a_missing_tool(ctx, monkeypatch):
|
| 283 |
-
async def missing(*_a, **_kw):
|
| 284 |
-
raise FileNotFoundError("ookla-speedtest is not installed")
|
| 285 |
-
|
| 286 |
-
monkeypatch.setattr(appmod, "run_speedtest", missing)
|
| 287 |
-
await say(ctx, OWNER, "/speed")
|
| 288 |
-
assert "installed" in str(ctx.client.edited)
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
async def test_mediainfo_needs_a_file(ctx):
|
| 292 |
-
ev = await say(ctx, OWNER, "/mediainfo")
|
| 293 |
-
assert "Reply to a file" in ev.texts[0]
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
async def test_every_new_command_is_registered(ctx):
|
| 297 |
-
for command in ("rename", "formats", "sequence", "sort", "check", "ethumb", "resize", "speed",
|
| 298 |
-
"mediainfo", "smedia"):
|
| 299 |
-
assert command in [name for name, _d in COMMANDS], command
|
| 300 |
-
assert (await say(ctx, OWNER, f"/{command}")).out or command == "smedia"
|
| 301 |
|
| 302 |
|
| 303 |
async def test_start_and_help(ctx):
|
|
@@ -472,7 +370,7 @@ async def test_a_file_is_queued_with_the_template_name_and_a_cancel_button(ctx):
|
|
| 472 |
ev = await send_doc(ctx, OWNER, mid=10)
|
| 473 |
(job,) = ctx.app.queue.jobs.values()
|
| 474 |
assert job.new_name == EPISODE_NEW and (job.user_id, job.chat_id, job.msg_id) == (OWNER, OWNER, 10)
|
| 475 |
-
assert job.file_name == EPISODE and job.size == 50 * 1024 * 1024 and job.state == "queued"
|
| 476 |
text, buttons, kw = ev.out[0]
|
| 477 |
assert "Queued" in text and "starting soon" in text and EPISODE_NEW in text
|
| 478 |
assert flat_buttons(buttons) == [("✖ Cancel", f"job:x:{job.id}")]
|
|
@@ -517,9 +415,12 @@ async def test_a_template_variable_that_cannot_be_filled_is_flagged(ctx):
|
|
| 517 |
assert "in the caption" in ev.texts[0]
|
| 518 |
|
| 519 |
|
| 520 |
-
async def
|
| 521 |
ev = await send_doc(ctx, OWNER, "Plain Movie.mkv")
|
| 522 |
assert "⚠️" not in ev.texts[0] # bracket groups vanish on purpose
|
|
|
|
|
|
|
|
|
|
| 523 |
|
| 524 |
|
| 525 |
class SlowEvent(FakeEvent):
|
|
@@ -537,9 +438,7 @@ class SlowEvent(FakeEvent):
|
|
| 537 |
async def test_jobs_are_ordered_by_the_message_that_triggered_them(ctx):
|
| 538 |
await send_doc(ctx, OWNER, "B.mkv", mid=31)
|
| 539 |
await send_doc(ctx, OWNER, "A.mkv", mid=30)
|
| 540 |
-
|
| 541 |
-
await click(ctx, OWNER, f"fm:{fmt['id']}")
|
| 542 |
-
await send_doc(ctx, OWNER, "old.mkv", mid=34)
|
| 543 |
assert sorted(j.order for j in ctx.app.queue.jobs.values()) == [30, 31, 34]
|
| 544 |
assert [j.file_name for j in ctx.app.queue.for_user(OWNER)] == ["A.mkv", "B.mkv", "old.mkv"]
|
| 545 |
|
|
@@ -568,206 +467,28 @@ async def test_a_forwarded_batch_comes_out_in_the_order_it_was_sent(ctx):
|
|
| 568 |
assert ran == [names[1145], names[1146], names[1147]]
|
| 569 |
|
| 570 |
|
| 571 |
-
async def
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
assert "fnew" in data # with no formats yet, only "new format" is offered
|
| 575 |
-
assert not any(d.startswith("fm:") for d in data)
|
| 576 |
-
|
| 577 |
-
fmt = await ctx.db.create_format(OWNER, "One Piece format")
|
| 578 |
-
ev = await say(ctx, OWNER, "/rename")
|
| 579 |
-
assert "One Piece format" in ev.out[0][0]
|
| 580 |
-
assert f"fm:{fmt['id']}" in data_of(ev.out[0][1])
|
| 581 |
-
|
| 582 |
-
await click(ctx, OWNER, f"fm:{fmt['id']}")
|
| 583 |
-
assert ctx.app.pending[OWNER].kind == "rename_format"
|
| 584 |
-
await send_doc(ctx, OWNER, "[SubsPlease] Tokyo Revengers - 05 (1080p) [ABCD1234].mkv")
|
| 585 |
-
|
| 586 |
-
(job,) = ctx.app.queue.jobs.values()
|
| 587 |
-
assert job.new_name == EPISODE_NEW # the format's template was applied
|
| 588 |
-
assert OWNER not in ctx.app.pending # one file per /rename
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
async def test_rename_no_longer_takes_a_name(ctx):
|
| 592 |
-
ev = await say(ctx, OWNER, "/rename My Custom Name")
|
| 593 |
-
assert "doesn't take a name" in ev.texts[0]
|
| 594 |
-
assert not ctx.app.queue.jobs
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
async def test_a_formats_template_reaches_the_file_name(ctx):
|
| 598 |
-
fmt = await ctx.db.create_format(OWNER, "One Piece")
|
| 599 |
-
await ctx.db.update_format(OWNER, fmt["id"], template="TR - {title} - E{episode}")
|
| 600 |
-
await click(ctx, OWNER, f"fm:{fmt['id']}")
|
| 601 |
-
await send_doc(ctx, OWNER, "Tokyo Revengers - 07 (1080p).mkv")
|
| 602 |
-
(job,) = ctx.app.queue.jobs.values()
|
| 603 |
-
assert job.new_name == "TR - Tokyo Revengers - E07.mkv"
|
| 604 |
-
assert job.settings_override["template"] == "TR - {title} - E{episode}"
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
async def test_a_format_can_suppress_the_thumbnail_entirely(ctx):
|
| 608 |
-
fmt = await ctx.db.create_format(OWNER, "No Thumb")
|
| 609 |
-
await ctx.db.update_format(OWNER, fmt["id"], thumb=b"")
|
| 610 |
-
await click(ctx, OWNER, f"fm:{fmt['id']}")
|
| 611 |
-
await send_doc(ctx, OWNER, "Movie.mkv")
|
| 612 |
-
(job,) = ctx.app.queue.jobs.values()
|
| 613 |
-
assert job.thumbnail_mode == "none" and job.thumbnail is None
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
async def test_a_format_carries_its_own_thumbnail(ctx):
|
| 617 |
-
fmt = await ctx.db.create_format(OWNER, "Poster")
|
| 618 |
-
await ctx.db.update_format(OWNER, fmt["id"], thumb=b"\xff\xd8jpeg")
|
| 619 |
-
await click(ctx, OWNER, f"fm:{fmt['id']}")
|
| 620 |
-
await send_doc(ctx, OWNER, "Movie.mkv")
|
| 621 |
(job,) = ctx.app.queue.jobs.values()
|
| 622 |
-
assert job.
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
await click(ctx, OWNER, f"fset:fmt_title:{fmt['id']}")
|
| 639 |
-
assert ctx.app.pending[OWNER].kind == "fmt_title"
|
| 640 |
-
await say(ctx, OWNER, "One Piece")
|
| 641 |
-
got = await ctx.db.get_format(OWNER, fmt["id"])
|
| 642 |
-
assert got["meta"]["title"] == "One Piece"
|
| 643 |
-
|
| 644 |
-
await click(ctx, OWNER, f"fset:fmt_title:{fmt['id']}")
|
| 645 |
-
await say(ctx, OWNER, "default")
|
| 646 |
-
assert (await ctx.db.get_format(OWNER, fmt["id"]))["meta"]["title"] is None # inherit
|
| 647 |
-
|
| 648 |
-
await click(ctx, OWNER, f"fthumbnone:{fmt['id']}")
|
| 649 |
-
assert (await ctx.db.get_format(OWNER, fmt["id"]))["thumb"] == b""
|
| 650 |
-
await click(ctx, OWNER, f"fthumbdef:{fmt['id']}")
|
| 651 |
-
assert (await ctx.db.get_format(OWNER, fmt["id"]))["thumb"] is None
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
async def test_creating_and_deleting_a_format(ctx):
|
| 655 |
-
await click(ctx, OWNER, "fnew")
|
| 656 |
-
assert ctx.app.pending[OWNER].kind == "fmt_name"
|
| 657 |
-
await say(ctx, OWNER, "Dragon Ball")
|
| 658 |
-
formats = await ctx.db.list_formats(OWNER)
|
| 659 |
-
assert [f["name"] for f in formats] == ["Dragon Ball"]
|
| 660 |
-
fmt_id = formats[0]["id"]
|
| 661 |
-
|
| 662 |
-
await click(ctx, OWNER, f"fdel:{fmt_id}")
|
| 663 |
-
await click(ctx, OWNER, f"fdelok:{fmt_id}")
|
| 664 |
-
assert await ctx.db.list_formats(OWNER) == []
|
| 665 |
-
|
| 666 |
-
|
| 667 |
-
async def test_a_format_belongs_to_one_user(ctx):
|
| 668 |
-
fmt = await ctx.db.create_format(OWNER, "Mine")
|
| 669 |
-
ev = await click(ctx, ADMIN_CFG, f"fl:{fmt['id']}")
|
| 670 |
-
assert "MY FORMATS" in ev.edits[0][0]
|
| 671 |
-
assert await ctx.db.list_formats(ADMIN_CFG) == []
|
| 672 |
-
|
| 673 |
-
|
| 674 |
-
async def test_an_open_sequence_collects_files_instead_of_renaming_them(ctx):
|
| 675 |
-
ev = await say(ctx, OWNER, "/sequence")
|
| 676 |
-
assert "Send me the files" in ev.texts[0]
|
| 677 |
-
await send_doc(ctx, OWNER, "Show S01E02.mkv")
|
| 678 |
-
await send_doc(ctx, OWNER, "Show S01E01.mkv")
|
| 679 |
-
assert not ctx.app.queue.jobs # nothing was renamed
|
| 680 |
-
seq = await ctx.db.get_sequence(OWNER)
|
| 681 |
-
assert [i["name"] for i in seq["items"]] == ["Show S01E02.mkv", "Show S01E01.mkv"]
|
| 682 |
-
|
| 683 |
-
|
| 684 |
-
async def test_sort_copies_the_files_back_in_order(ctx):
|
| 685 |
-
await say(ctx, OWNER, "/sequence")
|
| 686 |
-
await send_doc(ctx, OWNER, "Show S01E02.mkv", mid=11)
|
| 687 |
-
await send_doc(ctx, OWNER, "Show S01E01.mkv", mid=12)
|
| 688 |
-
await say(ctx, OWNER, "/sort")
|
| 689 |
-
# the files go back one at a time, in episode order, with no forward header
|
| 690 |
-
assert [ids for _to, _from, ids, _kw in ctx.client.copies] == [[12], [11]]
|
| 691 |
-
assert all(kw.get("drop_author") is True for _to, _from, _ids, kw in ctx.client.copies)
|
| 692 |
-
assert await ctx.db.get_sequence(OWNER) is None # the sequence closed
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
async def test_sort_reports_missing_and_duplicate_episodes(ctx):
|
| 696 |
-
await say(ctx, OWNER, "/sequence")
|
| 697 |
-
await send_doc(ctx, OWNER, "Show S01E01.mkv", mid=21)
|
| 698 |
-
await send_doc(ctx, OWNER, "Show S01E03.mkv", mid=22)
|
| 699 |
-
await send_doc(ctx, OWNER, "Show S01E03 (copy).mkv", mid=23)
|
| 700 |
-
await say(ctx, OWNER, "/sort")
|
| 701 |
-
report = [t for _e, t, _k in ctx.client.edited if t and "Sorted" in t]
|
| 702 |
-
assert report and "S01E02" in report[0]
|
| 703 |
-
assert "Duplicate S01E03" in report[0]
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
async def test_a_picked_format_wins_over_an_open_sequence(ctx):
|
| 707 |
-
await say(ctx, OWNER, "/sequence")
|
| 708 |
-
fmt = await ctx.db.create_format(OWNER, "One Piece")
|
| 709 |
-
await click(ctx, OWNER, f"fm:{fmt['id']}")
|
| 710 |
-
await send_doc(ctx, OWNER, "Show S01E01.mkv")
|
| 711 |
-
(job,) = ctx.app.queue.jobs.values()
|
| 712 |
-
assert job.new_name == "[S01E01] Show.mkv" # renamed, not collected
|
| 713 |
-
seq = await ctx.db.get_sequence(OWNER)
|
| 714 |
-
assert seq["items"] == []
|
| 715 |
-
|
| 716 |
-
|
| 717 |
-
async def test_a_sequence_can_be_cancelled(ctx):
|
| 718 |
-
await say(ctx, OWNER, "/sequence")
|
| 719 |
-
await send_doc(ctx, OWNER, "Show S01E01.mkv")
|
| 720 |
-
await click(ctx, OWNER, "sq:cancel")
|
| 721 |
-
assert await ctx.db.get_sequence(OWNER) is None
|
| 722 |
-
|
| 723 |
-
|
| 724 |
-
async def test_check_reports_what_the_bot_extracted(ctx):
|
| 725 |
-
reply = document_message("Dragon Ball Super Beerus S01E02 1080p.mkv", mid=40, caption="")
|
| 726 |
-
ev = await say(ctx, OWNER, "/check", reply=reply)
|
| 727 |
-
text = ev.texts[0]
|
| 728 |
-
assert "Title ⭆ Dragon Ball Super Beerus" in text
|
| 729 |
-
assert "Season ⭆ 01" in text and "Episode ⭆ 02" in text
|
| 730 |
-
assert "Quality ⭆ 1080p" in text
|
| 731 |
-
assert text.count("➦") == 8 # the name and the caption sections
|
| 732 |
-
|
| 733 |
-
|
| 734 |
-
async def test_check_shows_the_caption_section(ctx):
|
| 735 |
-
reply = document_message("video.mkv", mid=41, caption="Tokyo Revengers S02E05 720p")
|
| 736 |
-
ev = await say(ctx, OWNER, "/check", reply=reply)
|
| 737 |
-
text = ev.texts[0]
|
| 738 |
-
assert "Episode ⭆ 05" in text and "Quality ⭆ 720p" in text
|
| 739 |
-
|
| 740 |
-
|
| 741 |
-
async def test_check_needs_a_file(ctx):
|
| 742 |
-
ev = await say(ctx, OWNER, "/check")
|
| 743 |
assert "Reply to a file" in ev.texts[0]
|
| 744 |
-
|
| 745 |
-
|
| 746 |
-
async def test_resize_answers_with_size_buttons(ctx):
|
| 747 |
-
reply = FakeMessage(mid=60, photo=object(), media_bytes=jpeg())
|
| 748 |
-
ev = await say(ctx, OWNER, "/resize", reply=reply)
|
| 749 |
-
data = data_of(ev.out[0][1])
|
| 750 |
-
assert "rz:320" in data and "rz:640" in data and "rz:1280" in data and "rz:orig" in data
|
| 751 |
-
|
| 752 |
-
|
| 753 |
-
async def test_resize_needs_an_image(ctx):
|
| 754 |
-
ev = await say(ctx, OWNER, "/resize")
|
| 755 |
-
assert "Reply to an image" in ev.texts[0]
|
| 756 |
-
|
| 757 |
-
|
| 758 |
-
async def test_resizing_sends_the_new_image(ctx):
|
| 759 |
-
reply = FakeMessage(mid=61, photo=object(), media_bytes=jpeg())
|
| 760 |
-
await say(ctx, OWNER, "/resize", reply=reply)
|
| 761 |
-
await click(ctx, OWNER, "rz:320")
|
| 762 |
-
assert ctx.client.files
|
| 763 |
-
_chat, file, kw = ctx.client.files[-1]
|
| 764 |
-
assert isinstance(file, io.BytesIO)
|
| 765 |
-
assert "Set as my thumbnail" in [label for label, _d in flat_buttons(kw.get("buttons"))]
|
| 766 |
-
|
| 767 |
-
|
| 768 |
-
async def test_ethumb_needs_a_file(ctx):
|
| 769 |
-
ev = await say(ctx, OWNER, "/ethumb")
|
| 770 |
-
assert "Reply to a video" in ev.texts[0]
|
| 771 |
|
| 772 |
|
| 773 |
async def test_oversized_files_are_refused(ctx):
|
|
|
|
| 1 |
"""Handler-level tests: real Telethon event builders, fake events and client."""
|
| 2 |
import asyncio
|
| 3 |
+
import dataclasses
|
| 4 |
import io
|
| 5 |
import shutil
|
| 6 |
import time
|
|
|
|
| 186 |
assert (await say(ctx, OWNER, text)).out
|
| 187 |
|
| 188 |
|
| 189 |
+
async def test_start_image_is_sent_as_a_photo_with_the_panel_as_caption(ctx):
|
| 190 |
+
ctx.app.cfg = dataclasses.replace(ctx.cfg, start_image="https://example.com/banner.jpg")
|
| 191 |
ev = await say(ctx, OWNER, "/start")
|
| 192 |
assert ev.out == []
|
| 193 |
(chat, file, kw), = ctx.client.files
|
| 194 |
+
assert (chat, file) == (OWNER, "https://example.com/banner.jpg")
|
| 195 |
+
assert "Auto Rename Bot" in kw["caption"] and kw["buttons"] and kw["parse_mode"] == "html"
|
| 196 |
+
ctx.client.fail_send_file = True # a broken banner must not break /start
|
|
|
|
|
|
|
| 197 |
ev = await say(ctx, OWNER, "/start")
|
| 198 |
+
assert "Auto Rename Bot" in ev.texts[0]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
|
| 200 |
|
| 201 |
async def test_start_and_help(ctx):
|
|
|
|
| 370 |
ev = await send_doc(ctx, OWNER, mid=10)
|
| 371 |
(job,) = ctx.app.queue.jobs.values()
|
| 372 |
assert job.new_name == EPISODE_NEW and (job.user_id, job.chat_id, job.msg_id) == (OWNER, OWNER, 10)
|
| 373 |
+
assert job.file_name == EPISODE and job.size == 50 * 1024 * 1024 and not job.manual and job.state == "queued"
|
| 374 |
text, buttons, kw = ev.out[0]
|
| 375 |
assert "Queued" in text and "starting soon" in text and EPISODE_NEW in text
|
| 376 |
assert flat_buttons(buttons) == [("✖ Cancel", f"job:x:{job.id}")]
|
|
|
|
| 415 |
assert "in the caption" in ev.texts[0]
|
| 416 |
|
| 417 |
|
| 418 |
+
async def test_default_template_and_manual_names_never_warn(ctx):
|
| 419 |
ev = await send_doc(ctx, OWNER, "Plain Movie.mkv")
|
| 420 |
assert "⚠️" not in ev.texts[0] # bracket groups vanish on purpose
|
| 421 |
+
await say(ctx, OWNER, "/template One Piece - E{episode}")
|
| 422 |
+
ev = await say(ctx, OWNER, "/rename My Name", reply=document_message("Plain Movie.mkv", mid=40), mid=41)
|
| 423 |
+
assert "⚠️" not in ev.texts[0]
|
| 424 |
|
| 425 |
|
| 426 |
class SlowEvent(FakeEvent):
|
|
|
|
| 438 |
async def test_jobs_are_ordered_by_the_message_that_triggered_them(ctx):
|
| 439 |
await send_doc(ctx, OWNER, "B.mkv", mid=31)
|
| 440 |
await send_doc(ctx, OWNER, "A.mkv", mid=30)
|
| 441 |
+
await say(ctx, OWNER, "/rename X", reply=document_message("old.mkv", 1024, mid=5), mid=34)
|
|
|
|
|
|
|
| 442 |
assert sorted(j.order for j in ctx.app.queue.jobs.values()) == [30, 31, 34]
|
| 443 |
assert [j.file_name for j in ctx.app.queue.for_user(OWNER)] == ["A.mkv", "B.mkv", "old.mkv"]
|
| 444 |
|
|
|
|
| 467 |
assert ran == [names[1145], names[1146], names[1147]]
|
| 468 |
|
| 469 |
|
| 470 |
+
async def test_rename_command_uses_the_given_name_and_keeps_the_extension(ctx):
|
| 471 |
+
reply = document_message("old.name.mkv", 10 * 1024 * 1024, mid=33)
|
| 472 |
+
ev = await say(ctx, OWNER, "/rename My Custom Name", reply=reply, mid=34)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 473 |
(job,) = ctx.app.queue.jobs.values()
|
| 474 |
+
assert job.new_name == "My Custom Name.mkv" and job.manual and job.msg_id == 33
|
| 475 |
+
assert "My Custom Name.mkv" in ev.texts[0]
|
| 476 |
+
|
| 477 |
+
ev = await say(ctx, OWNER, "/rename Movie.2024.mp4", reply=reply, mid=35) # a typed extension never wins
|
| 478 |
+
assert sorted(j.new_name for j in ctx.app.queue.jobs.values())[1].endswith(".mkv")
|
| 479 |
+
|
| 480 |
+
|
| 481 |
+
async def test_rename_usage_messages(ctx):
|
| 482 |
+
for kwargs in (
|
| 483 |
+
{"reply": None}, # no reply
|
| 484 |
+
{"reply": FakeMessage("just text", mid=5)}, # reply to text
|
| 485 |
+
{"reply": FakeMessage("", mid=6, file=make_file("s.webp"), sticker=True)},
|
| 486 |
+
):
|
| 487 |
+
ev = await say(ctx, OWNER, "/rename New Name", **kwargs)
|
| 488 |
+
assert "Reply to a file" in ev.texts[0], kwargs
|
| 489 |
+
ev = await say(ctx, OWNER, "/rename", reply=document_message()) # reply but no name
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 490 |
assert "Reply to a file" in ev.texts[0]
|
| 491 |
+
assert not ctx.app.queue.jobs
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 492 |
|
| 493 |
|
| 494 |
async def test_oversized_files_are_refused(ctx):
|
tests/test_check.py
DELETED
|
@@ -1,119 +0,0 @@
|
|
| 1 |
-
from bot.parser import Info, effective_info
|
| 2 |
-
from bot.panels import check_report
|
| 3 |
-
from bot.utils import to_small_caps
|
| 4 |
-
|
| 5 |
-
# the small-capital glyph of every ASCII letter
|
| 6 |
-
SMALL_CAP_TABLE = {
|
| 7 |
-
"a": "\u1d43", "b": "\u0299", "c": "\u1d04", "d": "\u1d05", "e": "\u1d07",
|
| 8 |
-
"f": "\ua730", "g": "\u0262", "h": "\u029c", "i": "\u026a", "j": "\u1d0a",
|
| 9 |
-
"k": "\u1d0b", "l": "\u029f", "m": "\u1d0d", "n": "\u0274", "o": "\u1d0f",
|
| 10 |
-
"p": "\u1d18", "q": "\u01eb", "r": "\u0280", "s": "\u0073", "t": "\u1d1b",
|
| 11 |
-
"u": "\u1d1c", "v": "\u1d20", "w": "\u1d21", "x": "\u0078", "y": "\u02bf",
|
| 12 |
-
"z": "\u1d22",
|
| 13 |
-
}
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
def test_to_small_caps_maps_every_letter_and_leaves_the_rest():
|
| 17 |
-
assert to_small_caps("Your") == "\u02bf\u1d0f\u1d1c\u0280" # ʏ ᴏ ᴜ ʀ
|
| 18 |
-
assert to_small_caps("FileName") == to_small_caps("filename")
|
| 19 |
-
# The brief wrote this line with the literal "123 /mode!" on both sides, but every
|
| 20 |
-
# ASCII letter maps, so "mode" must come out small-capped; the literal was a
|
| 21 |
-
# Unicode-normalisation artefact. The correct expected value is spelled out below.
|
| 22 |
-
assert to_small_caps("123 /mode!") == "123 /\u1d0d\u1d0f\u1d05\u1d07!"
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
def test_to_small_caps_covers_the_whole_alphabet():
|
| 26 |
-
for letter, glyph in SMALL_CAP_TABLE.items():
|
| 27 |
-
assert to_small_caps(letter) == glyph
|
| 28 |
-
assert to_small_caps(letter.upper()) == glyph # both cases share one glyph
|
| 29 |
-
assert to_small_caps("0123456789 .,!?-_") == "0123456789 .,!?-_"
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
def test_effective_info_uses_the_caption_only_when_it_is_structured():
|
| 33 |
-
stem = "Dragon Ball Super Beerus S01E02 1080p"
|
| 34 |
-
assert effective_info(stem, "", "filename").title == "Dragon Ball Super Beerus"
|
| 35 |
-
# an unstructured caption is discarded entirely
|
| 36 |
-
assert effective_info(stem, "My Show", "caption").title == "Dragon Ball Super Beerus"
|
| 37 |
-
# a structured caption wins, and the filename fills in what it lacks
|
| 38 |
-
info = effective_info(stem, "Beerus 1080p S01E02", "caption")
|
| 39 |
-
assert info.season == "01" and info.episode == "02" and info.quality == "1080p"
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
def test_effective_info_in_filename_mode_ignores_the_caption():
|
| 43 |
-
stem = "Dragon Ball Super Beerus S01E02 1080p"
|
| 44 |
-
info = effective_info(stem, "Beerus 1080p S01E02", "filename")
|
| 45 |
-
assert info.title == "Dragon Ball Super Beerus"
|
| 46 |
-
assert info.season == "01" and info.episode == "02" and info.quality == "1080p"
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
def test_effective_info_fills_only_the_fields_the_caption_left_empty():
|
| 50 |
-
stem = "Dragon Ball Super Beerus S01E02 1080p"
|
| 51 |
-
info = effective_info(stem, "Beerus 720p", "caption")
|
| 52 |
-
assert info.title == "Beerus" # the caption wins
|
| 53 |
-
assert info.quality == "720p" # ... here too
|
| 54 |
-
assert info.season == "01" and info.episode == "02" # the filename fills the rest
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
def test_effective_info_ignores_a_blank_caption():
|
| 58 |
-
stem = "Dragon Ball Super Beerus S01E02 1080p"
|
| 59 |
-
for blank in ("", " ", "\n"):
|
| 60 |
-
info = effective_info(stem, blank, "caption")
|
| 61 |
-
assert info.title == "Dragon Ball Super Beerus"
|
| 62 |
-
assert info.season == "01" and info.episode == "02" and info.quality == "1080p"
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
def test_check_report_has_the_agreed_layout():
|
| 66 |
-
info = Info(title="Dragon Ball Super Beerus", season="01", episode="02", quality="1080p")
|
| 67 |
-
text = check_report(info, info)
|
| 68 |
-
assert "\u02bf\u1d0f\u1d1c\u0280" in text # ʏ ᴏ ᴜ ʀ — the small-capped header
|
| 69 |
-
assert "\ua730\u026a\u029f\u1d07\u0274\u1d43\u1d0d\u1d07:" in text # ꜰɪʟᴇɴᴀᴍᴇ
|
| 70 |
-
assert "\u27a6 Title \u2b46 Dragon Ball Super Beerus" in text
|
| 71 |
-
assert "\u27a6 Season \u2b46 01" in text
|
| 72 |
-
assert "\u27a6 Episode \u2b46 02" in text
|
| 73 |
-
assert "\u27a6 Quality \u2b46 1080p" in text
|
| 74 |
-
assert text.count("\u27a6") == 8 # four fields in each of the two sections
|
| 75 |
-
assert "Chapter" not in text
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
def test_check_report_matches_the_agreed_layout_exactly():
|
| 79 |
-
info = Info(title="Dragon Ball Super Beerus", season="01", episode="02", quality="1080p")
|
| 80 |
-
assert check_report(info, info) == (
|
| 81 |
-
"\u02bf\u1d0f\u1d1c\u0280 \u1d04\u1d43\u0274 \u1d04\u029c\u1d43\u0274\u0262\u1d07 "
|
| 82 |
-
"\u1d0d\u1d0f\u1d05\u1d07 \u0299\u02bf /mode\n"
|
| 83 |
-
"\n"
|
| 84 |
-
"\ua730\u026a\u029f\u1d07\u0274\u1d43\u1d0d\u1d07:\n"
|
| 85 |
-
"\u27a6 Title \u2b46 Dragon Ball Super Beerus\n"
|
| 86 |
-
"\u27a6 Season \u2b46 01\n"
|
| 87 |
-
"\u27a6 Episode \u2b46 02\n"
|
| 88 |
-
"\u27a6 Quality \u2b46 1080p\n"
|
| 89 |
-
"\n"
|
| 90 |
-
"\u1d04\u1d43\u1d18\u1d1b\u026a\u1d0f\u0274 \u1d0d\u1d0f\u1d05\u1d07:\n"
|
| 91 |
-
"\u27a6 Title \u2b46 Dragon Ball Super Beerus\n"
|
| 92 |
-
"\u27a6 Season \u2b46 01\n"
|
| 93 |
-
"\u27a6 Episode \u2b46 02\n"
|
| 94 |
-
"\u27a6 Quality \u2b46 1080p"
|
| 95 |
-
)
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
def test_check_report_shows_none_for_empty_values_and_extra_fields_only_when_present():
|
| 99 |
-
text = check_report(Info(title="X"), Info(title="X"))
|
| 100 |
-
assert "\u27a6 Season \u2b46 None" in text
|
| 101 |
-
assert "Year" not in text and "Codec" not in text
|
| 102 |
-
text = check_report(Info(title="X", year="2023", codec="HEVC"), Info(title="X"))
|
| 103 |
-
assert "\u27a6 Year \u2b46 2023" in text and "\u27a6 Codec \u2b46 HEVC" in text
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
def test_check_report_shows_year_and_codec_only_in_the_section_that_has_them():
|
| 107 |
-
text = check_report(Info(title="X"), Info(title="X", year="2023", codec="HEVC"))
|
| 108 |
-
name_section, caption_section = text.split(
|
| 109 |
-
"\u1d04\u1d43\u1d18\u1d1b\u026a\u1d0f\u0274 \u1d0d\u1d0f\u1d05\u1d07:"
|
| 110 |
-
)
|
| 111 |
-
assert "Year" not in name_section and "Codec" not in name_section
|
| 112 |
-
assert "\u27a6 Year \u2b46 2023" in caption_section
|
| 113 |
-
assert "\u27a6 Codec \u2b46 HEVC" in caption_section
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
def test_check_report_escapes_values_that_come_from_the_file_name():
|
| 117 |
-
text = check_report(Info(title="<b>Bold</b> & Co"), Info(title="X"))
|
| 118 |
-
assert "<b>Bold</b>" not in text
|
| 119 |
-
assert "<b>Bold</b> & Co" in text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_db.py
CHANGED
|
@@ -3,6 +3,14 @@ import pytest
|
|
| 3 |
from bot.db import DEFAULT_SETTINGS, Database, normalize_database_url
|
| 4 |
|
| 5 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
def test_normalize_postgres_urls():
|
| 7 |
url, args = normalize_database_url("postgres://u:p@host/db?sslmode=require&channel_binding=require")
|
| 8 |
assert url == "postgresql+asyncpg://u:p@host/db"
|
|
@@ -77,65 +85,3 @@ async def test_first_use_by_many_concurrent_handlers_does_not_collide(db):
|
|
| 77 |
# the same goes for the other writers that create the row on first use
|
| 78 |
await asyncio.gather(*(db.record_job(78, 10) for _ in range(5)), *(db.set_thumb(79, b"x") for _ in range(5)))
|
| 79 |
assert await db.count_users() == 3
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
async def test_formats_crud_and_the_twelve_slot_limit(db):
|
| 83 |
-
assert await db.count_formats(7) == 0
|
| 84 |
-
first = await db.create_format(7, "One Piece format")
|
| 85 |
-
assert first["id"] and first["name"] == "One Piece format"
|
| 86 |
-
assert first["template"] is None and first["keep_audio"] is None
|
| 87 |
-
assert first["meta"]["title"] is None
|
| 88 |
-
assert await db.create_format(7, "One Piece format") is None # the name is taken
|
| 89 |
-
assert await db.create_format(7, " ") is None # a blank name
|
| 90 |
-
for i in range(11):
|
| 91 |
-
assert await db.create_format(7, f"F{i}") is not None
|
| 92 |
-
assert await db.count_formats(7) == 12
|
| 93 |
-
assert await db.create_format(7, "Thirteenth") is None # the limit
|
| 94 |
-
assert await db.count_formats(8) == 0 # per user
|
| 95 |
-
assert [f["name"] for f in await db.list_formats(7)][0] == "One Piece format"
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
async def test_format_fields_round_trip_through_the_database(db):
|
| 99 |
-
fmt = await db.create_format(7, "Dragon Ball")
|
| 100 |
-
assert await db.update_format(7, fmt["id"], template="[S{season}E{episode}] {title}",
|
| 101 |
-
meta={"title": "", "author": "Kuririn"},
|
| 102 |
-
keep_audio=[], keep_subtitle=["english"])
|
| 103 |
-
got = await db.get_format(7, fmt["id"])
|
| 104 |
-
assert got["template"] == "[S{season}E{episode}] {title}"
|
| 105 |
-
assert got["meta"]["title"] == "" and got["meta"]["author"] == "Kuririn"
|
| 106 |
-
assert got["meta"]["artist"] is None
|
| 107 |
-
assert got["keep_audio"] == [] and got["keep_subtitle"] == ["english"]
|
| 108 |
-
assert await db.get_format(9, fmt["id"]) is None # another user
|
| 109 |
-
assert await db.delete_format(7, fmt["id"]) is True
|
| 110 |
-
assert await db.delete_format(7, fmt["id"]) is False
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
async def test_format_thumbnail_distinguishes_inherit_from_none(db):
|
| 114 |
-
fmt = await db.create_format(7, "Posters")
|
| 115 |
-
assert (await db.get_format(7, fmt["id"]))["thumb"] is None # inherit
|
| 116 |
-
await db.update_format(7, fmt["id"], thumb=b"")
|
| 117 |
-
assert (await db.get_format(7, fmt["id"]))["thumb"] == b"" # explicitly none
|
| 118 |
-
await db.update_format(7, fmt["id"], thumb=b"\xff\xd8jpeg")
|
| 119 |
-
assert (await db.get_format(7, fmt["id"]))["thumb"] == b"\xff\xd8jpeg"
|
| 120 |
-
await db.update_format(7, fmt["id"], thumb=None)
|
| 121 |
-
assert (await db.get_format(7, fmt["id"]))["thumb"] is None # back to inherit
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
async def test_sequence_and_start_media(db):
|
| 125 |
-
assert await db.get_sequence(3) is None
|
| 126 |
-
await db.set_sequence(3, [{"msg_id": 1, "name": "a.mkv", "caption": ""}])
|
| 127 |
-
seq = await db.get_sequence(3)
|
| 128 |
-
assert seq["items"][0]["name"] == "a.mkv" and seq["copied"] == 0
|
| 129 |
-
await db.set_sequence(3, seq["items"], copied=2)
|
| 130 |
-
assert (await db.get_sequence(3))["copied"] == 2
|
| 131 |
-
await db.clear_sequence(3)
|
| 132 |
-
assert await db.get_sequence(3) is None
|
| 133 |
-
|
| 134 |
-
assert await db.get_start_media() is None
|
| 135 |
-
await db.set_start_media("gif", b"\x00gif")
|
| 136 |
-
assert (await db.get_start_media())["kind"] == "gif"
|
| 137 |
-
assert (await db.get_start_media())["data"] == b"\x00gif"
|
| 138 |
-
await db.set_start_media("image", b"\xff\xd8")
|
| 139 |
-
assert (await db.get_start_media())["kind"] == "image" # one row, replaced
|
| 140 |
-
await db.clear_start_media()
|
| 141 |
-
assert await db.get_start_media() is None
|
|
|
|
| 3 |
from bot.db import DEFAULT_SETTINGS, Database, normalize_database_url
|
| 4 |
|
| 5 |
|
| 6 |
+
@pytest.fixture
|
| 7 |
+
async def db(tmp_path):
|
| 8 |
+
database = Database(f"sqlite+aiosqlite:///{tmp_path}/t.db")
|
| 9 |
+
await database.init()
|
| 10 |
+
yield database
|
| 11 |
+
await database.close()
|
| 12 |
+
|
| 13 |
+
|
| 14 |
def test_normalize_postgres_urls():
|
| 15 |
url, args = normalize_database_url("postgres://u:p@host/db?sslmode=require&channel_binding=require")
|
| 16 |
assert url == "postgresql+asyncpg://u:p@host/db"
|
|
|
|
| 85 |
# the same goes for the other writers that create the row on first use
|
| 86 |
await asyncio.gather(*(db.record_job(78, 10) for _ in range(5)), *(db.set_thumb(79, b"x") for _ in range(5)))
|
| 87 |
assert await db.count_users() == 3
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_formats.py
DELETED
|
@@ -1,67 +0,0 @@
|
|
| 1 |
-
from bot.db import DEFAULT_SETTINGS
|
| 2 |
-
from bot.formats import apply_format_field, resolve_format, resolve_thumbnail
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
def test_a_format_that_inherits_everything_changes_nothing():
|
| 6 |
-
fmt = {"template": None, "meta": {f: None for f in ("title", "author", "artist", "audio", "subtitle", "video")},
|
| 7 |
-
"keep_audio": None, "keep_subtitle": None}
|
| 8 |
-
settings = dict(DEFAULT_SETTINGS, template="GLOBAL", keep_audio=["english"])
|
| 9 |
-
assert resolve_format(fmt, settings) == settings
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
def test_a_format_overlays_only_the_fields_it_sets():
|
| 13 |
-
fmt = {"template": "[S{season}E{episode}] {title}",
|
| 14 |
-
"meta": {"title": "One Piece", "author": None, "artist": None, "audio": None, "subtitle": None, "video": None},
|
| 15 |
-
"keep_audio": [], "keep_subtitle": None}
|
| 16 |
-
settings = dict(DEFAULT_SETTINGS, template="GLOBAL", keep_audio=["english"], keep_subtitle=["japanese"])
|
| 17 |
-
out = resolve_format(fmt, settings)
|
| 18 |
-
assert out["template"] == "[S{season}E{episode}] {title}"
|
| 19 |
-
assert out["meta"]["title"] == "One Piece" and out["meta"]["author"] == ""
|
| 20 |
-
assert out["keep_audio"] == [] and out["keep_subtitle"] == ["japanese"] # inherit
|
| 21 |
-
assert settings["template"] == "GLOBAL" # the input is not mutated
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
def test_resolve_thumbnail_states():
|
| 25 |
-
inherit = {"thumb": None}
|
| 26 |
-
custom = {"thumb": b"\xff\xd8jpeg"}
|
| 27 |
-
none = {"thumb": b""}
|
| 28 |
-
assert resolve_thumbnail(inherit, b"GLOBAL") == ("auto", b"GLOBAL")
|
| 29 |
-
assert resolve_thumbnail(inherit, None) == ("auto", None) # a video frame may still be used
|
| 30 |
-
assert resolve_thumbnail(custom, b"GLOBAL") == ("custom", b"\xff\xd8jpeg")
|
| 31 |
-
assert resolve_thumbnail(none, b"GLOBAL") == ("none", None) # explicitly none: never a frame
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
async def test_apply_format_field_clearing_and_inheriting(db):
|
| 35 |
-
fmt = await db.create_format(1, "One Piece")
|
| 36 |
-
ok, msg = await apply_format_field(db, 1, fmt["id"], "fmt_title", "One Piece")
|
| 37 |
-
assert ok and "One Piece" in msg
|
| 38 |
-
ok, _ = await apply_format_field(db, 1, fmt["id"], "fmt_title", "off")
|
| 39 |
-
assert ok
|
| 40 |
-
assert (await db.get_format(1, fmt["id"]))["meta"]["title"] == "" # explicitly empty
|
| 41 |
-
ok, _ = await apply_format_field(db, 1, fmt["id"], "fmt_keep_audio", "off")
|
| 42 |
-
assert ok
|
| 43 |
-
assert (await db.get_format(1, fmt["id"]))["keep_audio"] == [] # keep every track
|
| 44 |
-
ok, msg = await apply_format_field(db, 1, fmt["id"], "fmt_name", "off")
|
| 45 |
-
assert not ok # a format must have a name
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
async def test_apply_format_field_saves_a_template_and_a_keep_list(db):
|
| 49 |
-
fmt = await db.create_format(1, "Dragon Ball")
|
| 50 |
-
ok, msg = await apply_format_field(db, 1, fmt["id"], "fmt_template", "[S{season}E{episode}] {title}")
|
| 51 |
-
assert ok and "Template saved" in msg
|
| 52 |
-
assert (await db.get_format(1, fmt["id"]))["template"] == "[S{season}E{episode}] {title}"
|
| 53 |
-
ok, _ = await apply_format_field(db, 1, fmt["id"], "fmt_template", "off")
|
| 54 |
-
assert ok
|
| 55 |
-
assert (await db.get_format(1, fmt["id"]))["template"] is None # back to inherit
|
| 56 |
-
|
| 57 |
-
ok, msg = await apply_format_field(db, 1, fmt["id"], "fmt_keep_subtitle", "English Japanese")
|
| 58 |
-
assert ok and "English" in msg
|
| 59 |
-
assert (await db.get_format(1, fmt["id"]))["keep_subtitle"] == ["english", "japanese"]
|
| 60 |
-
|
| 61 |
-
ok, msg = await apply_format_field(db, 1, fmt["id"], "fmt_keep_subtitle", "0")
|
| 62 |
-
assert not ok and "start at 1" in msg
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
async def test_apply_format_field_rejects_a_missing_format(db):
|
| 66 |
-
ok, msg = await apply_format_field(db, 1, 999, "fmt_title", "x")
|
| 67 |
-
assert not ok and "gone" in msg
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_media.py
CHANGED
|
@@ -9,15 +9,12 @@ from bot.media import (
|
|
| 9 |
apply_plan,
|
| 10 |
build_plan,
|
| 11 |
canonical_lang,
|
| 12 |
-
extract_frame,
|
| 13 |
format_tracks,
|
| 14 |
make_video_thumbnail,
|
| 15 |
parse_probe,
|
| 16 |
-
parse_size,
|
| 17 |
parse_track_tokens,
|
| 18 |
prepare_thumb,
|
| 19 |
probe,
|
| 20 |
-
resize_image,
|
| 21 |
resolve_tokens,
|
| 22 |
validate_track_tokens,
|
| 23 |
)
|
|
@@ -322,41 +319,3 @@ async def test_thumbnail_and_prepare_thumb(media_dir, tmp_path):
|
|
| 322 |
img = Image.open(io.BytesIO(jpg))
|
| 323 |
assert max(img.size) <= 320 and len(jpg) <= 200 * 1024 and img.format == "JPEG"
|
| 324 |
assert not await make_video_thumbnail(tmp_path / "missing.mkv", tmp_path / "x.jpg", 1.0)
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
@needs_ffmpeg
|
| 328 |
-
async def test_extract_frame_keeps_the_source_width(media_dir, tmp_path):
|
| 329 |
-
out = tmp_path / "frame.jpg"
|
| 330 |
-
assert await extract_frame(media_dir / "sample.mkv", out, 2.0)
|
| 331 |
-
from PIL import Image
|
| 332 |
-
import io
|
| 333 |
-
|
| 334 |
-
img = Image.open(io.BytesIO(out.read_bytes()))
|
| 335 |
-
assert img.size[0] == 320 # the sample video is 320 wide
|
| 336 |
-
assert not await extract_frame(tmp_path / "missing.mkv", tmp_path / "x.jpg", 1.0)
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
def test_parse_size_accepts_a_width_or_an_exact_size():
|
| 340 |
-
assert parse_size("800") == (800, None, None)
|
| 341 |
-
assert parse_size("800x450") == (800, 450, None)
|
| 342 |
-
assert parse_size(" 640 ") == (640, None, None)
|
| 343 |
-
w, h, err = parse_size("huge")
|
| 344 |
-
assert (w, h) == (None, None) and "size" in err
|
| 345 |
-
w, h, err = parse_size("0")
|
| 346 |
-
assert (w, h) == (None, None) and "1 pixel" in err
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
def test_resize_image_keeps_the_aspect_ratio_or_forces_the_exact_size():
|
| 350 |
-
import io
|
| 351 |
-
|
| 352 |
-
from PIL import Image
|
| 353 |
-
|
| 354 |
-
buf = io.BytesIO()
|
| 355 |
-
Image.new("RGB", (1000, 500), (10, 20, 30)).save(buf, "PNG")
|
| 356 |
-
data = buf.getvalue()
|
| 357 |
-
out = Image.open(io.BytesIO(resize_image(data, width=200)))
|
| 358 |
-
assert out.size == (200, 100)
|
| 359 |
-
out = Image.open(io.BytesIO(resize_image(data, width=200, height=200)))
|
| 360 |
-
assert out.size == (200, 200)
|
| 361 |
-
out = Image.open(io.BytesIO(resize_image(data, height=250)))
|
| 362 |
-
assert out.size == (500, 250)
|
|
|
|
| 9 |
apply_plan,
|
| 10 |
build_plan,
|
| 11 |
canonical_lang,
|
|
|
|
| 12 |
format_tracks,
|
| 13 |
make_video_thumbnail,
|
| 14 |
parse_probe,
|
|
|
|
| 15 |
parse_track_tokens,
|
| 16 |
prepare_thumb,
|
| 17 |
probe,
|
|
|
|
| 18 |
resolve_tokens,
|
| 19 |
validate_track_tokens,
|
| 20 |
)
|
|
|
|
| 319 |
img = Image.open(io.BytesIO(jpg))
|
| 320 |
assert max(img.size) <= 320 and len(jpg) <= 200 * 1024 and img.format == "JPEG"
|
| 321 |
assert not await make_video_thumbnail(tmp_path / "missing.mkv", tmp_path / "x.jpg", 1.0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_sequence.py
DELETED
|
@@ -1,57 +0,0 @@
|
|
| 1 |
-
from bot.sequence import detect_episodes, format_report, plan_sort
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
def items(*names):
|
| 5 |
-
return [{"msg_id": i, "name": n, "caption": ""} for i, n in enumerate(names)]
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
def test_episodes_are_detected_from_the_file_name():
|
| 9 |
-
out = detect_episodes(items("Show S01E02.mkv", "Show S01E10.mkv"), "filename")
|
| 10 |
-
assert [(i["season"], i["episode"]) for i in out] == [("01", "02"), ("01", "10")]
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
def test_the_caption_is_used_in_caption_mode():
|
| 14 |
-
out = detect_episodes(
|
| 15 |
-
[{"msg_id": 1, "name": "video.mkv", "caption": "Show S02E05 1080p"}], "caption"
|
| 16 |
-
)
|
| 17 |
-
assert (out[0]["season"], out[0]["episode"]) == ("02", "05")
|
| 18 |
-
out = detect_episodes(
|
| 19 |
-
[{"msg_id": 1, "name": "video.mkv", "caption": "Show S02E05 1080p"}], "filename"
|
| 20 |
-
)
|
| 21 |
-
assert out[0]["episode"] == ""
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
def test_sorting_is_by_season_then_episode_ascending():
|
| 25 |
-
out = plan_sort(detect_episodes(
|
| 26 |
-
items("S01E10.mkv", "S02E01.mkv", "S01E02.mkv", "S01E03.mkv"), "filename"))
|
| 27 |
-
assert [i["name"] for i in out.ordered] == ["S01E02.mkv", "S01E03.mkv", "S01E10.mkv", "S02E01.mkv"]
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
def test_missing_episodes_are_reported_per_season():
|
| 31 |
-
out = plan_sort(detect_episodes(items("S01E01.mkv", "S01E04.mkv", "S02E01.mkv"), "filename"))
|
| 32 |
-
assert out.missing == ["S01E02", "S01E03"]
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
def test_duplicate_episodes_are_reported_with_their_file_names():
|
| 36 |
-
out = plan_sort(detect_episodes(items("S01E02.mkv", "S01E02 (copy).mkv", "S01E03.mkv"), "filename"))
|
| 37 |
-
assert len(out.duplicates) == 1
|
| 38 |
-
assert out.duplicates[0].episode == "02"
|
| 39 |
-
assert out.duplicates[0].names == ["S01E02.mkv", "S01E02 (copy).mkv"]
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
def test_a_file_without_an_episode_is_kept_and_reported():
|
| 43 |
-
out = plan_sort(detect_episodes(items("S01E02.mkv", "bonus.mkv", "extra.mkv"), "filename"))
|
| 44 |
-
assert [i["name"] for i in out.ordered] == ["S01E02.mkv", "bonus.mkv", "extra.mkv"]
|
| 45 |
-
assert out.no_episode == ["bonus.mkv", "extra.mkv"]
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
def test_the_report_lists_only_what_is_wrong():
|
| 49 |
-
out = plan_sort(detect_episodes(items("S01E01.mkv", "S01E03.mkv"), "filename"))
|
| 50 |
-
text = format_report(out, 2)
|
| 51 |
-
assert "S01E02" in text and "Duplicate" not in text and "No episode" not in text
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
def test_the_report_names_the_duplicates_and_the_odds():
|
| 55 |
-
out = plan_sort(detect_episodes(items("S01E01.mkv", "S01E01.mkv", "bonus.mkv"), "filename"))
|
| 56 |
-
text = format_report(out, 3)
|
| 57 |
-
assert "Duplicate S01E01" in text and "No episode number" in text and "bonus.mkv" in text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_settings_ops.py
CHANGED
|
@@ -1,11 +1,19 @@
|
|
| 1 |
import pytest
|
| 2 |
|
| 3 |
-
from bot.db import DEFAULT_SETTINGS
|
| 4 |
from bot.settings_ops import META_COMMANDS, META_KINDS, apply_setting, parse_channel, preview_template
|
| 5 |
|
| 6 |
UID = 7
|
| 7 |
|
| 8 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
async def get(db):
|
| 10 |
return await db.get_settings(UID)
|
| 11 |
|
|
|
|
| 1 |
import pytest
|
| 2 |
|
| 3 |
+
from bot.db import DEFAULT_SETTINGS, Database
|
| 4 |
from bot.settings_ops import META_COMMANDS, META_KINDS, apply_setting, parse_channel, preview_template
|
| 5 |
|
| 6 |
UID = 7
|
| 7 |
|
| 8 |
|
| 9 |
+
@pytest.fixture
|
| 10 |
+
async def db(tmp_path):
|
| 11 |
+
d = Database(f"sqlite+aiosqlite:///{tmp_path}/s.db")
|
| 12 |
+
await d.init()
|
| 13 |
+
yield d
|
| 14 |
+
await d.close()
|
| 15 |
+
|
| 16 |
+
|
| 17 |
async def get(db):
|
| 18 |
return await db.get_settings(UID)
|
| 19 |
|
tests/test_speedtest.py
DELETED
|
@@ -1,51 +0,0 @@
|
|
| 1 |
-
import pytest
|
| 2 |
-
|
| 3 |
-
from bot.speedtest import format_speed, parse_ookla_json
|
| 4 |
-
|
| 5 |
-
# A real `ookla-speedtest --accept-license --accept-gdpr --format=json` run.
|
| 6 |
-
RAW = """{"type":"result","timestamp":"2026-10-20T10:45:15Z",
|
| 7 |
-
"ping":{"jitter":0.273,"latency":2.998,"low":2.833,"high":3.315},
|
| 8 |
-
"download":{"bandwidth":11034552,"bytes":39707592,"elapsed":3602,
|
| 9 |
-
"latency":{"iqm":16.372,"low":6.344,"high":48.044,"jitter":3.565}},
|
| 10 |
-
"upload":{"bandwidth":11886628,"bytes":162534754,"elapsed":14821,
|
| 11 |
-
"latency":{"iqm":128.627,"low":5.8,"high":523.355,"jitter":48.431}},
|
| 12 |
-
"packetLoss":0,"isp":"ACT Fibernet",
|
| 13 |
-
"interface":{"internalIp":"192.168.32.3","name":"wlan0","externalIp":"49.204.136.88"},
|
| 14 |
-
"server":{"id":60836,"host":"chn.infonetcomm.com","port":8080,"name":"ACT FIBERNET",
|
| 15 |
-
"location":"Chennai","country":"India","ip":"2406:7400:b0:1::2"},
|
| 16 |
-
"result":{"id":"18dc","url":"https://www.speedtest.net/result/c/18dc","persisted":true}}"""
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
def test_parse_ookla_json_reads_the_speeds():
|
| 20 |
-
r = parse_ookla_json(RAW)
|
| 21 |
-
assert r.download_mbps == pytest.approx(11.034552, rel=1e-4)
|
| 22 |
-
assert r.upload_mbps == pytest.approx(11.886628, rel=1e-4)
|
| 23 |
-
assert r.ping_ms == 2.998 # the CLI reports the round trip as "latency"
|
| 24 |
-
assert r.server == "ACT FIBERNET — Chennai, India"
|
| 25 |
-
assert r.isp == "ACT Fibernet"
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
def test_parse_ookla_json_survives_a_leading_licence_notice():
|
| 29 |
-
r = parse_ookla_json("You may only use this Speedtest software...\n" + RAW)
|
| 30 |
-
assert r.download_mbps == pytest.approx(11.034552, rel=1e-4)
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
def test_parse_ookla_json_rejects_rubbish():
|
| 34 |
-
with pytest.raises(ValueError):
|
| 35 |
-
parse_ookla_json("not json")
|
| 36 |
-
with pytest.raises(ValueError):
|
| 37 |
-
parse_ookla_json("{}")
|
| 38 |
-
with pytest.raises(ValueError):
|
| 39 |
-
parse_ookla_json("[]")
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
def test_format_speed_shows_the_numbers():
|
| 43 |
-
text = format_speed(parse_ookla_json(RAW))
|
| 44 |
-
assert "11.03" in text and "11.89" in text and "3" in text
|
| 45 |
-
assert "ACT FIBERNET" in text and "Chennai" in text and "ACT Fibernet" in text
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
def test_format_speed_copes_with_missing_details():
|
| 49 |
-
text = format_speed(parse_ookla_json('{"download":{"bandwidth":1000000},"upload":{"bandwidth":2000000}}'))
|
| 50 |
-
assert "1.00 Mbps" in text and "2.00 Mbps" in text
|
| 51 |
-
assert "Server" not in text and "ISP" not in text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_telegraph.py
DELETED
|
@@ -1,44 +0,0 @@
|
|
| 1 |
-
from bot.telegraph import build_page, build_summary
|
| 2 |
-
|
| 3 |
-
PROBE = {
|
| 4 |
-
"format": {"filename": "movie.mkv", "format_name": "matroska,webm", "duration": "120.0",
|
| 5 |
-
"size": "1048576", "bit_rate": "8000000",
|
| 6 |
-
"tags": {"title": "Movie Title"}},
|
| 7 |
-
"streams": [
|
| 8 |
-
{"index": 0, "codec_type": "video", "codec_name": "h264", "width": 1920, "height": 1080,
|
| 9 |
-
"r_frame_rate": "24/1", "bit_rate": "7000000", "disposition": {"default": 1}},
|
| 10 |
-
{"index": 1, "codec_type": "audio", "codec_name": "aac", "channels": 2, "sample_rate": "48000",
|
| 11 |
-
"tags": {"language": "eng", "title": "English"}},
|
| 12 |
-
{"index": 2, "codec_type": "subtitle", "codec_name": "subrip",
|
| 13 |
-
"tags": {"language": "eng"}},
|
| 14 |
-
],
|
| 15 |
-
"chapters": [{"id": 0, "start_time": "0.0", "end_time": "60.0", "tags": {"title": "Intro"}}],
|
| 16 |
-
}
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
def test_build_page_lists_the_format_the_streams_and_the_chapters():
|
| 20 |
-
html = build_page(PROBE, "movie.mkv")
|
| 21 |
-
assert "movie.mkv" in html and "matroska" in html
|
| 22 |
-
assert "h264" in html and "1920x1080" in html
|
| 23 |
-
assert "aac" in html and "English" in html
|
| 24 |
-
assert "subrip" in html
|
| 25 |
-
assert "Intro" in html
|
| 26 |
-
assert "Movie Title" in html
|
| 27 |
-
assert "<pre" in html
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
def test_build_page_includes_the_raw_json():
|
| 31 |
-
html = build_page(PROBE, "movie.mkv")
|
| 32 |
-
assert '"streams"' in html # the raw ffprobe output is in the page
|
| 33 |
-
assert "h264" in html.split("<pre")[1] # ...inside the <pre> block
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
def test_build_page_survives_an_empty_probe():
|
| 37 |
-
html = build_page({}, "nothing.mkv")
|
| 38 |
-
assert "nothing.mkv" in html
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
def test_the_plain_summary_has_no_html():
|
| 42 |
-
text = build_summary(PROBE, "movie.mkv")
|
| 43 |
-
assert "movie.mkv" in text and "1920x1080" in text and "Intro" in text
|
| 44 |
-
assert "<" not in text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/tgfakes.py
CHANGED
|
@@ -23,8 +23,7 @@ def make_file(name: Optional[str] = "Movie.mkv", size: int = 5 * 1024 * 1024, mi
|
|
| 23 |
class FakeMessage:
|
| 24 |
def __init__(self, text: str = "", *, mid: int = 1, sender: int = 1, file: Any = None, video: bool = False,
|
| 25 |
audio: bool = False, photo: bool = False, sticker: bool = False, gif: bool = False,
|
| 26 |
-
voice: bool = False, video_note: bool = False, fwd: bool = False, out: bool = False
|
| 27 |
-
media_bytes: Optional[bytes] = None):
|
| 28 |
self.id = mid
|
| 29 |
self.message = text
|
| 30 |
self.raw_text = text
|
|
@@ -41,10 +40,6 @@ class FakeMessage:
|
|
| 41 |
self.voice = object() if voice else None
|
| 42 |
self.video_note = object() if video_note else None
|
| 43 |
self.media = object() if (file is not None or photo) else None
|
| 44 |
-
self.media_bytes = media_bytes
|
| 45 |
-
|
| 46 |
-
async def download_media(self, file: Any = None) -> Optional[bytes]:
|
| 47 |
-
return self.media_bytes
|
| 48 |
|
| 49 |
|
| 50 |
def document_message(name: str = "Movie.mkv", size: int = 5 * 1024 * 1024, *, mid: int = 10, caption: str = "",
|
|
@@ -137,7 +132,6 @@ class FakeClient:
|
|
| 137 |
self.sent: List[tuple] = [] # send_message(entity, text, **kw)
|
| 138 |
self.files: List[tuple] = [] # send_file(entity, file, **kw)
|
| 139 |
self.edited: List[tuple] = [] # edit_message(entity, text, **kw)
|
| 140 |
-
self.copies: List[tuple] = [] # forward_messages(to, messages, from_peer, **kw)
|
| 141 |
self.requests: List[Any] = [] # raw MTProto requests
|
| 142 |
self.bad_entities: set = set()
|
| 143 |
self.fail_send_file = False
|
|
@@ -169,10 +163,6 @@ class FakeClient:
|
|
| 169 |
text, message = message, None
|
| 170 |
self.edited.append((entity, text, {**kw, "message": message}))
|
| 171 |
|
| 172 |
-
async def forward_messages(self, to: Any, messages: Any, from_peer: Any = None, **kw: Any) -> List[SimpleNamespace]:
|
| 173 |
-
self.copies.append((to, from_peer, list(messages), kw))
|
| 174 |
-
return [SimpleNamespace(id=4000 + len(self.copies))]
|
| 175 |
-
|
| 176 |
|
| 177 |
async def dispatch(client: FakeClient, event: Any) -> None:
|
| 178 |
"""Run ``event`` through the registered handlers like Telethon's dispatcher."""
|
|
|
|
| 23 |
class FakeMessage:
|
| 24 |
def __init__(self, text: str = "", *, mid: int = 1, sender: int = 1, file: Any = None, video: bool = False,
|
| 25 |
audio: bool = False, photo: bool = False, sticker: bool = False, gif: bool = False,
|
| 26 |
+
voice: bool = False, video_note: bool = False, fwd: bool = False, out: bool = False):
|
|
|
|
| 27 |
self.id = mid
|
| 28 |
self.message = text
|
| 29 |
self.raw_text = text
|
|
|
|
| 40 |
self.voice = object() if voice else None
|
| 41 |
self.video_note = object() if video_note else None
|
| 42 |
self.media = object() if (file is not None or photo) else None
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
|
| 45 |
def document_message(name: str = "Movie.mkv", size: int = 5 * 1024 * 1024, *, mid: int = 10, caption: str = "",
|
|
|
|
| 132 |
self.sent: List[tuple] = [] # send_message(entity, text, **kw)
|
| 133 |
self.files: List[tuple] = [] # send_file(entity, file, **kw)
|
| 134 |
self.edited: List[tuple] = [] # edit_message(entity, text, **kw)
|
|
|
|
| 135 |
self.requests: List[Any] = [] # raw MTProto requests
|
| 136 |
self.bad_entities: set = set()
|
| 137 |
self.fail_send_file = False
|
|
|
|
| 163 |
text, message = message, None
|
| 164 |
self.edited.append((entity, text, {**kw, "message": message}))
|
| 165 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
|
| 167 |
async def dispatch(client: FakeClient, event: Any) -> None:
|
| 168 |
"""Run ``event`` through the registered handlers like Telethon's dispatcher."""
|