Spaces:
Running
Running
github-actions[bot] commited on
Commit ·
5f400f8
1
Parent(s): 49b8170
Automated sync from GitHub
Browse files- .gitattributes +2 -2
- Dockerfile +14 -2
- bot/app.py +1087 -67
- bot/config.py +0 -2
- bot/db.py +131 -0
- bot/errors.py +8 -0
- bot/formats.py +78 -0
- bot/main.py +5 -1
- bot/media.py +105 -0
- bot/mediainfo.py +491 -0
- bot/panels.py +227 -4
- bot/parser.py +0 -8
- bot/pipeline.py +6 -1
- bot/queue.py +1 -1
- bot/sequence.py +173 -0
- bot/speed.py +226 -0
- bot/telegraph.py +130 -0
- requirements.txt +2 -0
- tests/appkit.py +72 -0
- tests/conftest.py +4 -0
- tests/faketelegraph.py +53 -0
- tests/test_app.py +5 -106
- tests/test_check.py +83 -0
- tests/test_ethumb.py +209 -0
- tests/test_formats.py +202 -0
- tests/test_formats_app.py +436 -0
- tests/test_media.py +119 -0
- tests/test_mediainfo.py +383 -0
- tests/test_mediainfo_app.py +276 -0
- tests/test_parser.py +0 -13
- tests/test_pipeline.py +81 -0
- tests/test_resize.py +225 -0
- tests/test_sequence.py +199 -0
- tests/test_sequence_app.py +442 -0
- tests/test_smedia.py +337 -0
- tests/test_speed.py +255 -0
- tests/test_speed_app.py +83 -0
- tests/test_telegraph.py +157 -0
- tests/tgfakes.py +33 -6
.gitattributes
CHANGED
|
@@ -1,8 +1,8 @@
|
|
| 1 |
-
*.png filter=lfs diff=lfs merge=lfs -text
|
| 2 |
-
*.ttf filter=lfs diff=lfs merge=lfs -text
|
| 3 |
*.otf filter=lfs diff=lfs merge=lfs -text
|
| 4 |
*.webp filter=lfs diff=lfs merge=lfs -text
|
| 5 |
*.mp4 filter=lfs diff=lfs merge=lfs -text
|
| 6 |
*.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 7 |
*.jpg filter=lfs diff=lfs merge=lfs -text
|
| 8 |
*.jpeg filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
*.otf filter=lfs diff=lfs merge=lfs -text
|
| 2 |
*.webp filter=lfs diff=lfs merge=lfs -text
|
| 3 |
*.mp4 filter=lfs diff=lfs merge=lfs -text
|
| 4 |
*.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
*.jpg filter=lfs diff=lfs merge=lfs -text
|
| 6 |
*.jpeg filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.png filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.ttf filter=lfs diff=lfs merge=lfs -text
|
Dockerfile
CHANGED
|
@@ -1,10 +1,22 @@
|
|
| 1 |
FROM python:3.12-slim
|
| 2 |
|
| 3 |
-
# ffmpeg/ffprobe: metadata + track keep/reorder (stream copy, no re-encoding)
|
| 4 |
RUN apt-get update \
|
| 5 |
-
&& apt-get install -y --no-install-recommends ffmpeg ca-certificates \
|
| 6 |
&& rm -rf /var/lib/apt/lists/*
|
| 7 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
# Hugging Face Spaces run Docker containers as user 1000
|
| 9 |
RUN useradd -m -u 1000 user
|
| 10 |
USER user
|
|
|
|
| 1 |
FROM python:3.12-slim
|
| 2 |
|
| 3 |
+
# ffmpeg/ffprobe: metadata + track keep/reorder (stream copy, no re-encoding); curl: fetches the Speedtest CLI below
|
| 4 |
RUN apt-get update \
|
| 5 |
+
&& apt-get install -y --no-install-recommends ffmpeg ca-certificates curl \
|
| 6 |
&& rm -rf /var/lib/apt/lists/*
|
| 7 |
|
| 8 |
+
# Ookla's official Speedtest CLI for /speed. Optional: if the download fails the build goes on and /speed
|
| 9 |
+
# uses the speedtest-cli Python package (also Ookla) instead.
|
| 10 |
+
RUN set -eux; \
|
| 11 |
+
case "$(dpkg --print-architecture)" in amd64) arch=x86_64 ;; arm64) arch=aarch64 ;; *) arch="" ;; esac; \
|
| 12 |
+
if [ -n "$arch" ]; then \
|
| 13 |
+
curl -fsSL --retry 3 --max-time 120 -o /tmp/speedtest.tgz "https://install.speedtest.net/app/cli/ookla-speedtest-1.2.0-linux-${arch}.tgz" \
|
| 14 |
+
&& tar -xzf /tmp/speedtest.tgz -C /tmp speedtest \
|
| 15 |
+
&& install -m 0755 /tmp/speedtest /usr/local/bin/ookla-speedtest \
|
| 16 |
+
|| echo "Ookla Speedtest CLI not installed: /speed will use the speedtest-cli package"; \
|
| 17 |
+
rm -f /tmp/speedtest.tgz /tmp/speedtest; \
|
| 18 |
+
fi
|
| 19 |
+
|
| 20 |
# Hugging Face Spaces run Docker containers as user 1000
|
| 21 |
RUN useradd -m -u 1000 user
|
| 22 |
USER user
|
bot/app.py
CHANGED
|
@@ -2,36 +2,47 @@
|
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
import asyncio
|
|
|
|
| 5 |
import html
|
| 6 |
import io
|
|
|
|
| 7 |
import logging
|
| 8 |
import random
|
| 9 |
import re
|
| 10 |
import shutil
|
| 11 |
import time
|
| 12 |
from dataclasses import dataclass
|
|
|
|
| 13 |
from pathlib import Path
|
| 14 |
from typing import Any, Awaitable, Callable, Dict, List, Optional, Set
|
| 15 |
|
| 16 |
import telethon
|
| 17 |
from telethon import errors, events, functions, types
|
|
|
|
| 18 |
|
| 19 |
from . import fasttransfer as transfer
|
| 20 |
-
from . import media, panels, sysinfo
|
| 21 |
from .config import Config
|
| 22 |
-
from .db import Database
|
| 23 |
-
from .
|
| 24 |
-
from .
|
|
|
|
|
|
|
| 25 |
from .queue import Job, JobQueue
|
|
|
|
| 26 |
from .settings_ops import META_COMMANDS, apply_setting, parse_channel, preview_template
|
| 27 |
-
from .
|
|
|
|
| 28 |
|
| 29 |
log = logging.getLogger(__name__)
|
| 30 |
|
| 31 |
COMMANDS = [
|
| 32 |
("start", "Open the main menu"),
|
| 33 |
("help", "How to use the bot"),
|
| 34 |
-
("rename", "
|
|
|
|
|
|
|
|
|
|
| 35 |
("mode", "Choose Filename or Caption mode"),
|
| 36 |
("template", "Set the filename template"),
|
| 37 |
("caption", "Set the caption template"),
|
|
@@ -40,22 +51,42 @@ COMMANDS = [
|
|
| 40 |
("keepaudio", "Keep audio tracks by language or number"),
|
| 41 |
("keepsubtitle", "Keep subtitle tracks by language or number"),
|
| 42 |
("tracks", "List the tracks of a file (reply to it)"),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
("thumb", "View your thumbnail"),
|
| 44 |
("delthumb", "Delete your thumbnail"),
|
| 45 |
("queue", "Show your queued files"),
|
| 46 |
("cancel", "Cancel your current job"),
|
|
|
|
| 47 |
("status", "Show basic bot uptime, ping, and version information."),
|
| 48 |
("stats", "Show detailed system stats such as users, CPU, RAM, and disk usage."),
|
| 49 |
]
|
| 50 |
-
OWNER_COMMANDS = {"addadmin", "deladmin", "admins"}
|
| 51 |
BACK = {
|
| 52 |
"template": "rename", "remove": "rename", "caption": "cap",
|
| 53 |
"mt_title": "meta", "mt_author": "meta", "mt_artist": "meta",
|
| 54 |
"mt_audio": "meta", "mt_subtitle": "meta", "mt_video": "meta",
|
| 55 |
"keep_audio": "tool", "keep_subtitle": "tool",
|
| 56 |
}
|
| 57 |
-
PANEL_NAMES = {"home", "rename", "cap", "meta", "tool", "ftype", "help", "admin"}
|
| 58 |
QUEUE_SETTLE = 1.5 # seconds an idle worker waits so files sent together are queued in message order
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
TRACKS_PROBE_BYTES = 12 * 1024 * 1024
|
| 60 |
TRACKS_FULL_LIMIT = 700 * 1024 * 1024
|
| 61 |
|
|
@@ -68,12 +99,27 @@ class Pending:
|
|
| 68 |
back: str
|
| 69 |
is_photo: bool
|
| 70 |
expires: float
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
|
| 72 |
|
| 73 |
def cmd_pattern(name: str) -> "re.Pattern[str]":
|
| 74 |
return re.compile(rf"(?is)^/{name}(?:@\w+)?(?:\s+(.*))?$")
|
| 75 |
|
| 76 |
|
|
|
|
|
|
|
|
|
|
| 77 |
def visible_len(html_text: str) -> int:
|
| 78 |
return len(html.unescape(re.sub(r"<[^>]+>", "", html_text)))
|
| 79 |
|
|
@@ -89,6 +135,51 @@ def is_renameable(message: Any) -> bool:
|
|
| 89 |
)
|
| 90 |
|
| 91 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
class App:
|
| 93 |
PENDING_TTL = 300
|
| 94 |
|
|
@@ -98,6 +189,12 @@ class App:
|
|
| 98 |
self.db = db
|
| 99 |
self.admins: Set[int] = set(cfg.admin_ids)
|
| 100 |
self.pending: Dict[int, Pending] = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
self.renamer = Renamer(client, cfg, db, self._log_send)
|
| 102 |
self.queue = JobQueue(self.renamer.process, workers=cfg.max_jobs, on_cancelled=self._on_job_cancelled, settle=QUEUE_SETTLE)
|
| 103 |
self._warned: Set[int] = set()
|
|
@@ -123,6 +220,8 @@ class App:
|
|
| 123 |
self.queue.start()
|
| 124 |
|
| 125 |
async def shutdown(self) -> None:
|
|
|
|
|
|
|
| 126 |
await self.queue.stop()
|
| 127 |
|
| 128 |
async def _set_commands(self) -> None:
|
|
@@ -195,6 +294,7 @@ class App:
|
|
| 195 |
)
|
| 196 |
add(self._guard(self.on_media), events.NewMessage(incoming=True, func=lambda e: e.is_private and is_renameable(e.message)))
|
| 197 |
add(self._guard(self.on_photo), events.NewMessage(incoming=True, func=lambda e: e.is_private and e.message.photo is not None))
|
|
|
|
| 198 |
add(self._guard(self.on_text), events.NewMessage(incoming=True, func=self._wants_text))
|
| 199 |
add(self._guard(self.on_callback), events.CallbackQuery())
|
| 200 |
add(self._on_stranger, events.NewMessage(incoming=True, func=lambda e: e.is_private and not self.is_allowed(e.sender_id)))
|
|
@@ -208,14 +308,17 @@ class App:
|
|
| 208 |
|
| 209 |
def _command_table(self) -> Dict[str, Callable[[Any], Awaitable[None]]]:
|
| 210 |
table: Dict[str, Callable[[Any], Awaitable[None]]] = {
|
| 211 |
-
"start": self.cmd_start, "help": self.cmd_help, "rename": self.cmd_rename, "
|
|
|
|
| 212 |
"template": self.cmd_template, "caption": self.cmd_caption, "filetype": self.cmd_filetype,
|
| 213 |
"metadata": self.cmd_metadata, "keepaudio": self._cmd_keep("keep_audio", "Keep Audio", "/keepaudio English Japanese"),
|
| 214 |
"keepsubtitle": self._cmd_keep("keep_subtitle", "Keep Subtitle", "/keepsubtitle English"),
|
| 215 |
-
"tracks": self.cmd_tracks, "
|
|
|
|
|
|
|
| 216 |
"removewords": self.cmd_remove, "setchannel": self.cmd_setchannel, "queue": self.cmd_queue,
|
| 217 |
-
"cancel": self.cmd_cancel, "status": self.cmd_status, "stats": self.cmd_stats,
|
| 218 |
-
"addadmin": self.cmd_addadmin, "deladmin": self.cmd_deladmin, "admins": self.cmd_admins,
|
| 219 |
}
|
| 220 |
for command, kind in META_COMMANDS.items():
|
| 221 |
table[command] = self._cmd_meta_field(command, kind)
|
|
@@ -246,6 +349,8 @@ class App:
|
|
| 246 |
return panels.filetype(s)
|
| 247 |
if name == "help":
|
| 248 |
return panels.help_panel()
|
|
|
|
|
|
|
| 249 |
if name == "admin":
|
| 250 |
return panels.admin(
|
| 251 |
self.cfg.owner_id, sorted(self.admins), self.queue.running, self.queue.waiting, self.cfg.log_channel
|
|
@@ -268,19 +373,70 @@ class App:
|
|
| 268 |
except errors.MessageNotModifiedError:
|
| 269 |
pass
|
| 270 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
# --------------------------------------------------------------- commands
|
| 272 |
async def cmd_start(self, event: Any) -> None:
|
| 273 |
self.pending.pop(event.sender_id, None)
|
| 274 |
text, rows = await self.panel(event.sender_id, "home")
|
| 275 |
-
if self.
|
| 276 |
-
|
| 277 |
-
await self.client.send_file(
|
| 278 |
-
event.chat_id, self.cfg.start_image, caption=text, buttons=panels.to_buttons(rows), parse_mode="html"
|
| 279 |
-
)
|
| 280 |
-
return
|
| 281 |
-
except Exception as exc: # noqa: BLE001
|
| 282 |
-
log.warning("could not send START_IMAGE (%s); sending text only", exc)
|
| 283 |
-
await self._reply(event, text, rows)
|
| 284 |
|
| 285 |
async def cmd_help(self, event: Any) -> None:
|
| 286 |
text, _ = panels.help_panel()
|
|
@@ -439,6 +595,16 @@ class App:
|
|
| 439 |
await self._reply(event, "You have no thumbnail.")
|
| 440 |
|
| 441 |
async def on_photo(self, event: Any) -> None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 442 |
raw = await event.download_media(bytes)
|
| 443 |
if not raw:
|
| 444 |
await self._reply(event, "❌ I couldn't download that photo.")
|
|
@@ -448,22 +614,208 @@ class App:
|
|
| 448 |
except Exception: # noqa: BLE001
|
| 449 |
await self._reply(event, "❌ I couldn't read that image.")
|
| 450 |
return
|
|
|
|
|
|
|
|
|
|
| 451 |
await self.db.set_thumb(event.sender_id, thumb)
|
| 452 |
await self._reply(event, "✅ Thumbnail saved. It will be used for the files I send back.", [[("🗑 Delete", "th:rm"), ("✖ Close", "x:close")]])
|
| 453 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 454 |
# ------------------------------------------------------------ rename flow
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 455 |
async def cmd_rename(self, event: Any) -> None:
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 467 |
uid = event.sender_id
|
| 468 |
size = int(msg.file.size or 0) if msg.file is not None else 0
|
| 469 |
if size > self.cfg.max_file_bytes:
|
|
@@ -472,29 +824,28 @@ class App:
|
|
| 472 |
s = await self.db.get_settings(uid)
|
| 473 |
orig = original_name_of(msg)
|
| 474 |
ext = source_ext(msg, orig)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 475 |
notes: List[str] = []
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 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,
|
| 493 |
)
|
| 494 |
ahead = self.queue.running + self.queue.waiting
|
| 495 |
where = "starting soon" if ahead == 0 else f"{ahead} ahead of it"
|
|
|
|
| 496 |
job.status_msg = await event.reply(
|
| 497 |
-
f"📥 <b>Queued</b> ({where})\n<code>{esc(clip(new_name, 120))}</code>" + "".join(f"\n⚠️ {esc(n)}" for n in notes),
|
| 498 |
buttons=panels.to_buttons([[("✖ Cancel", f"job:x:{job.id}")]]),
|
| 499 |
parse_mode="html",
|
| 500 |
)
|
|
@@ -528,6 +879,18 @@ class App:
|
|
| 528 |
if not arg and self.pending.pop(uid, None) is not None:
|
| 529 |
await self._reply(event, "Cancelled — nothing was changed.")
|
| 530 |
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 531 |
owner_scope = None if self.is_owner(uid) else uid
|
| 532 |
if arg.isdigit():
|
| 533 |
ok = self.queue.cancel(int(arg), user_id=owner_scope)
|
|
@@ -536,42 +899,492 @@ class App:
|
|
| 536 |
count = self.queue.cancel_all() if (arg == "all" and self.is_owner(uid)) else self.queue.cancel_user(uid)
|
| 537 |
await self._reply(event, f"🛑 Cancelling {count} job(s)…" if count else "You have no active jobs.")
|
| 538 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 539 |
# ------------------------------------------------------------------ tracks
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 540 |
async def cmd_tracks(self, event: Any) -> None:
|
| 541 |
reply = await event.get_reply_message()
|
| 542 |
if reply is None or not is_renameable(reply):
|
| 543 |
await self._reply(event, "Reply to a video or audio file with /tracks to list its audio and subtitle tracks.")
|
| 544 |
return
|
| 545 |
status = await self._reply(event, "🔎 Reading the tracks…")
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
tmp_dir = Path(self.cfg.temp_dir) / f"tracks{event.chat_id}_{event.id}"
|
| 549 |
-
tmp_dir.mkdir(parents=True, exist_ok=True)
|
| 550 |
-
try:
|
| 551 |
-
path = tmp_dir / f"probe{ext}"
|
| 552 |
-
size = int(reply.file.size or 0)
|
| 553 |
-
info = None
|
| 554 |
-
await transfer.download(
|
| 555 |
-
self.client, reply, path, fast=self.cfg.fast_transfer, connections=2, max_bytes=TRACKS_PROBE_BYTES
|
| 556 |
-
)
|
| 557 |
try:
|
| 558 |
info = await media.probe(path)
|
| 559 |
except media.MediaError:
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 564 |
try:
|
| 565 |
-
|
| 566 |
except media.MediaError:
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 570 |
return
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 575 |
|
| 576 |
# ----------------------------------------------------------- status/stats
|
| 577 |
async def cmd_status(self, event: Any) -> None:
|
|
@@ -630,6 +1443,12 @@ class App:
|
|
| 630 |
if p is None or p.expires < time.time():
|
| 631 |
self.pending.pop(uid, None)
|
| 632 |
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 633 |
ok, msg = await apply_setting(self.db, uid, p.kind, event.raw_text)
|
| 634 |
if not ok:
|
| 635 |
await self._reply(event, msg + "\n\nTry again, or tap Cancel.")
|
|
@@ -674,6 +1493,33 @@ class App:
|
|
| 674 |
await self._cb_set(event, uid, parts[1], parts[2])
|
| 675 |
return
|
| 676 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 677 |
if head == "in":
|
| 678 |
await self._cb_input(event, uid, parts)
|
| 679 |
return
|
|
@@ -776,8 +1622,182 @@ class App:
|
|
| 776 |
msg_obj = await event.get_message()
|
| 777 |
self.pending[uid] = Pending(
|
| 778 |
kind=kind, chat_id=event.chat_id, msg_id=event.message_id, back=BACK[kind],
|
| 779 |
-
is_photo=bool(msg_obj is not None and msg_obj.photo is not None
|
|
|
|
| 780 |
)
|
| 781 |
text, rows = panels.input_prompt(kind, BACK[kind])
|
| 782 |
await self._edit_panel(event, text, rows)
|
| 783 |
await event.answer()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
import asyncio
|
| 5 |
+
import base64
|
| 6 |
import html
|
| 7 |
import io
|
| 8 |
+
import json
|
| 9 |
import logging
|
| 10 |
import random
|
| 11 |
import re
|
| 12 |
import shutil
|
| 13 |
import time
|
| 14 |
from dataclasses import dataclass
|
| 15 |
+
from datetime import timezone
|
| 16 |
from pathlib import Path
|
| 17 |
from typing import Any, Awaitable, Callable, Dict, List, Optional, Set
|
| 18 |
|
| 19 |
import telethon
|
| 20 |
from telethon import errors, events, functions, types
|
| 21 |
+
from telethon.tl.custom import Button
|
| 22 |
|
| 23 |
from . import fasttransfer as transfer
|
| 24 |
+
from . import media, mediainfo, panels, speed, sysinfo
|
| 25 |
from .config import Config
|
| 26 |
+
from .db import MAX_FORMATS, Database
|
| 27 |
+
from .errors import FormatError
|
| 28 |
+
from .formats import FIELD_KINDS, FormatSettings, validate_name
|
| 29 |
+
from .parser import compute_new_name, missing_variables, parse_caption, parse_info, split_ext
|
| 30 |
+
from .pipeline import PROBE_EXTS, Renamer, original_name_of, source_ext
|
| 31 |
from .queue import Job, JobQueue
|
| 32 |
+
from .sequence import SeqItem, SequenceState, analyse, format_report
|
| 33 |
from .settings_ops import META_COMMANDS, apply_setting, parse_channel, preview_template
|
| 34 |
+
from .telegraph import Telegraph, TelegraphError
|
| 35 |
+
from .utils import clip, esc, human_size, human_uptime
|
| 36 |
|
| 37 |
log = logging.getLogger(__name__)
|
| 38 |
|
| 39 |
COMMANDS = [
|
| 40 |
("start", "Open the main menu"),
|
| 41 |
("help", "How to use the bot"),
|
| 42 |
+
("rename", "Rename files with a saved format"),
|
| 43 |
+
("formats", "Create and edit your saved formats"),
|
| 44 |
+
("sequence", "Collect files to sort by episode"),
|
| 45 |
+
("sort", "Sort the collected files by episode"),
|
| 46 |
("mode", "Choose Filename or Caption mode"),
|
| 47 |
("template", "Set the filename template"),
|
| 48 |
("caption", "Set the caption template"),
|
|
|
|
| 51 |
("keepaudio", "Keep audio tracks by language or number"),
|
| 52 |
("keepsubtitle", "Keep subtitle tracks by language or number"),
|
| 53 |
("tracks", "List the tracks of a file (reply to it)"),
|
| 54 |
+
("check", "See what I can read from a file (reply to it)"),
|
| 55 |
+
("mediainfo", "Full media details of a file on Telegraph (reply to it)"),
|
| 56 |
+
("ethumb", "Extract the thumbnail of a file or video"),
|
| 57 |
+
("resize", "Resize an image, for thumbnails"),
|
| 58 |
("thumb", "View your thumbnail"),
|
| 59 |
("delthumb", "Delete your thumbnail"),
|
| 60 |
("queue", "Show your queued files"),
|
| 61 |
("cancel", "Cancel your current job"),
|
| 62 |
+
("speed", "Test the bot's internet speed (Ookla)"),
|
| 63 |
("status", "Show basic bot uptime, ping, and version information."),
|
| 64 |
("stats", "Show detailed system stats such as users, CPU, RAM, and disk usage."),
|
| 65 |
]
|
| 66 |
+
OWNER_COMMANDS = {"addadmin", "deladmin", "admins", "smedia"}
|
| 67 |
BACK = {
|
| 68 |
"template": "rename", "remove": "rename", "caption": "cap",
|
| 69 |
"mt_title": "meta", "mt_author": "meta", "mt_artist": "meta",
|
| 70 |
"mt_audio": "meta", "mt_subtitle": "meta", "mt_video": "meta",
|
| 71 |
"keep_audio": "tool", "keep_subtitle": "tool",
|
| 72 |
}
|
| 73 |
+
PANEL_NAMES = {"home", "rename", "cap", "meta", "tool", "ftype", "help", "admin", "formats"}
|
| 74 |
QUEUE_SETTLE = 1.5 # seconds an idle worker waits so files sent together are queued in message order
|
| 75 |
+
ONE_FILE_WAIT = 600 # seconds the bot waits for the file after /rename (or /ethumb ...) before forgetting about it
|
| 76 |
+
ONE_SHOT_GRACE = 8 # seconds after a one-file command during which further files get a hint, not an auto-rename
|
| 77 |
+
ONE_SHOT_TAKES = { # what each one-file command consumes; further files of that kind sent right after it are skipped
|
| 78 |
+
"rename": {"doc"}, "ethumb": {"doc"}, "resize": {"doc", "photo"}, "smedia": {"doc", "photo", "gif"},
|
| 79 |
+
}
|
| 80 |
+
ETHUMB_FRAME_WIDTH = 1280
|
| 81 |
+
START_MEDIA_MAX_BYTES = 10 * 1024 * 1024 # biggest /start picture or GIF; a copy of it is kept in the database
|
| 82 |
+
START_IMAGE_BOX = 1280 # pictures are shrunk to fit this box, as Telegram does with photos
|
| 83 |
+
RESIZE_MAX_BYTES = 20 * 1024 * 1024
|
| 84 |
+
SEQUENCE_WAIT = 1800 # seconds /sequence keeps the collected files without a new one arriving
|
| 85 |
+
SEQUENCE_MAX = 200 # files one /sequence collects
|
| 86 |
+
SEQUENCE_STATUS_DELAY = 1.2 # seconds without a new file before "N files collected" is sent
|
| 87 |
+
SORT_GAP = 0.3 # seconds between the files /sort sends back
|
| 88 |
+
SORT_MAX_WAIT = 120 # a Telegram flood wait longer than this (seconds) stops /sort instead of being waited out
|
| 89 |
+
SORT_PROGRESS_EVERY = 10 # files between two updates of the "Sorting…" message
|
| 90 |
TRACKS_PROBE_BYTES = 12 * 1024 * 1024
|
| 91 |
TRACKS_FULL_LIMIT = 700 * 1024 * 1024
|
| 92 |
|
|
|
|
| 99 |
back: str
|
| 100 |
is_photo: bool
|
| 101 |
expires: float
|
| 102 |
+
fid: int = 0 # the saved format a "fmt:*" prompt is about (0 = one that is being created)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
@dataclass
|
| 106 |
+
class Mode:
|
| 107 |
+
"""What a user's next files are for: a /rename format session, /sequence, /ethumb, /resize or /smedia."""
|
| 108 |
+
|
| 109 |
+
kind: str
|
| 110 |
+
ttl: float
|
| 111 |
+
expires: float
|
| 112 |
+
data: Any = None
|
| 113 |
+
dropped: str = "" # a note about what starting this mode threw away, for the reply that announces it
|
| 114 |
|
| 115 |
|
| 116 |
def cmd_pattern(name: str) -> "re.Pattern[str]":
|
| 117 |
return re.compile(rf"(?is)^/{name}(?:@\w+)?(?:\s+(.*))?$")
|
| 118 |
|
| 119 |
|
| 120 |
+
MEDIAINFO_CAPTION = cmd_pattern("mediainfo") # a file sent with this as its caption is for /mediainfo, not for renaming
|
| 121 |
+
|
| 122 |
+
|
| 123 |
def visible_len(html_text: str) -> int:
|
| 124 |
return len(html.unescape(re.sub(r"<[^>]+>", "", html_text)))
|
| 125 |
|
|
|
|
| 135 |
)
|
| 136 |
|
| 137 |
|
| 138 |
+
def is_image(message: Any) -> bool:
|
| 139 |
+
"""A photo, or a file that is a still picture (not a GIF animation)."""
|
| 140 |
+
if getattr(message, "photo", None) is not None:
|
| 141 |
+
return True
|
| 142 |
+
f = getattr(message, "file", None)
|
| 143 |
+
return bool(
|
| 144 |
+
getattr(message, "document", None) is not None and not getattr(message, "gif", None)
|
| 145 |
+
and f is not None and (f.mime_type or "").startswith("image/")
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def sent_as(message: Any) -> str:
|
| 150 |
+
"""How Telegram labels the file: a video, an audio file, a voice message ..., or a plain document."""
|
| 151 |
+
for attr, label in (
|
| 152 |
+
("voice", "Voice message"), ("video_note", "Video message"), ("gif", "GIF"), ("sticker", "Sticker"),
|
| 153 |
+
("video", "Video"), ("audio", "Audio"),
|
| 154 |
+
):
|
| 155 |
+
if getattr(message, attr, None) is not None:
|
| 156 |
+
return label
|
| 157 |
+
return "Document"
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def is_playable(message: Any, name: str) -> bool:
|
| 161 |
+
"""A file that should hold audio or video streams, going by its MIME type and extension."""
|
| 162 |
+
mime = (getattr(message.file, "mime_type", "") or "").lower()
|
| 163 |
+
return mime.startswith(("video/", "audio/")) or source_ext(message, name).lower() in PROBE_EXTS
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def telegram_facts(message: Any, caption: bool = True) -> Dict[str, str]:
|
| 167 |
+
"""What Telegram knows about the file, for the "Telegram" part of the /mediainfo page."""
|
| 168 |
+
date = getattr(message, "date", None)
|
| 169 |
+
stamp = ""
|
| 170 |
+
if date is not None:
|
| 171 |
+
if date.tzinfo is None:
|
| 172 |
+
date = date.replace(tzinfo=timezone.utc)
|
| 173 |
+
stamp = date.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
| 174 |
+
return {
|
| 175 |
+
"mime": getattr(message.file, "mime_type", "") or "",
|
| 176 |
+
"sent_as": sent_as(message),
|
| 177 |
+
"sent_on": stamp,
|
| 178 |
+
"dc": str(getattr(message.document, "dc_id", "") or ""),
|
| 179 |
+
"caption": clip((message.raw_text or "").strip(), 500) if caption else "",
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
|
| 183 |
class App:
|
| 184 |
PENDING_TTL = 300
|
| 185 |
|
|
|
|
| 189 |
self.db = db
|
| 190 |
self.admins: Set[int] = set(cfg.admin_ids)
|
| 191 |
self.pending: Dict[int, Pending] = {}
|
| 192 |
+
self.modes: Dict[int, Mode] = {}
|
| 193 |
+
self._recent: Dict[int, List[Any]] = {} # uid -> [command, finished_at, hint_sent] of the last one-file command
|
| 194 |
+
self._sorting: Dict[int, asyncio.Event] = {} # users whose /sort is sending files; set to stop it
|
| 195 |
+
self._speed_lock = asyncio.Lock() # one speed test at a time
|
| 196 |
+
self.telegraph = Telegraph(self._load_telegraph_token, self._save_telegraph_token)
|
| 197 |
+
self._tasks: Set["asyncio.Future[Any]"] = set()
|
| 198 |
self.renamer = Renamer(client, cfg, db, self._log_send)
|
| 199 |
self.queue = JobQueue(self.renamer.process, workers=cfg.max_jobs, on_cancelled=self._on_job_cancelled, settle=QUEUE_SETTLE)
|
| 200 |
self._warned: Set[int] = set()
|
|
|
|
| 220 |
self.queue.start()
|
| 221 |
|
| 222 |
async def shutdown(self) -> None:
|
| 223 |
+
for task in list(self._tasks):
|
| 224 |
+
task.cancel()
|
| 225 |
await self.queue.stop()
|
| 226 |
|
| 227 |
async def _set_commands(self) -> None:
|
|
|
|
| 294 |
)
|
| 295 |
add(self._guard(self.on_media), events.NewMessage(incoming=True, func=lambda e: e.is_private and is_renameable(e.message)))
|
| 296 |
add(self._guard(self.on_photo), events.NewMessage(incoming=True, func=lambda e: e.is_private and e.message.photo is not None))
|
| 297 |
+
add(self._guard(self.on_gif), events.NewMessage(incoming=True, func=lambda e: e.is_private and e.message.gif is not None))
|
| 298 |
add(self._guard(self.on_text), events.NewMessage(incoming=True, func=self._wants_text))
|
| 299 |
add(self._guard(self.on_callback), events.CallbackQuery())
|
| 300 |
add(self._on_stranger, events.NewMessage(incoming=True, func=lambda e: e.is_private and not self.is_allowed(e.sender_id)))
|
|
|
|
| 308 |
|
| 309 |
def _command_table(self) -> Dict[str, Callable[[Any], Awaitable[None]]]:
|
| 310 |
table: Dict[str, Callable[[Any], Awaitable[None]]] = {
|
| 311 |
+
"start": self.cmd_start, "help": self.cmd_help, "rename": self.cmd_rename, "formats": self.cmd_formats,
|
| 312 |
+
"sequence": self.cmd_sequence, "sort": self.cmd_sort, "mode": self.cmd_mode,
|
| 313 |
"template": self.cmd_template, "caption": self.cmd_caption, "filetype": self.cmd_filetype,
|
| 314 |
"metadata": self.cmd_metadata, "keepaudio": self._cmd_keep("keep_audio", "Keep Audio", "/keepaudio English Japanese"),
|
| 315 |
"keepsubtitle": self._cmd_keep("keep_subtitle", "Keep Subtitle", "/keepsubtitle English"),
|
| 316 |
+
"tracks": self.cmd_tracks, "check": self.cmd_check, "mediainfo": self.cmd_mediainfo,
|
| 317 |
+
"ethumb": self.cmd_ethumb, "resize": self.cmd_resize,
|
| 318 |
+
"thumb": self.cmd_thumb, "delthumb": self.cmd_delthumb,
|
| 319 |
"removewords": self.cmd_remove, "setchannel": self.cmd_setchannel, "queue": self.cmd_queue,
|
| 320 |
+
"cancel": self.cmd_cancel, "speed": self.cmd_speed, "status": self.cmd_status, "stats": self.cmd_stats,
|
| 321 |
+
"addadmin": self.cmd_addadmin, "deladmin": self.cmd_deladmin, "admins": self.cmd_admins, "smedia": self.cmd_smedia,
|
| 322 |
}
|
| 323 |
for command, kind in META_COMMANDS.items():
|
| 324 |
table[command] = self._cmd_meta_field(command, kind)
|
|
|
|
| 349 |
return panels.filetype(s)
|
| 350 |
if name == "help":
|
| 351 |
return panels.help_panel()
|
| 352 |
+
if name == "formats":
|
| 353 |
+
return panels.formats_menu(await self.db.list_formats(uid), MAX_FORMATS)
|
| 354 |
if name == "admin":
|
| 355 |
return panels.admin(
|
| 356 |
self.cfg.owner_id, sorted(self.admins), self.queue.running, self.queue.waiting, self.cfg.log_channel
|
|
|
|
| 373 |
except errors.MessageNotModifiedError:
|
| 374 |
pass
|
| 375 |
|
| 376 |
+
# ------------------------------------------------------------------ modes
|
| 377 |
+
def _mode(self, uid: int, *kinds: str) -> Optional[Mode]:
|
| 378 |
+
"""The user's active mode (of one of ``kinds`` if given); an expired one is dropped."""
|
| 379 |
+
mode = self.modes.get(uid)
|
| 380 |
+
if mode is not None and mode.expires < time.time():
|
| 381 |
+
self._end_mode(uid)
|
| 382 |
+
return None
|
| 383 |
+
if mode is None or (kinds and mode.kind not in kinds):
|
| 384 |
+
return None
|
| 385 |
+
return mode
|
| 386 |
+
|
| 387 |
+
def _start_mode(self, uid: int, kind: str, ttl: float, data: Any = None) -> Mode:
|
| 388 |
+
previous = self._end_mode(uid)
|
| 389 |
+
mode = Mode(kind=kind, ttl=ttl, expires=time.time() + ttl, data=data)
|
| 390 |
+
if previous is not None and previous.kind == "sequence" and previous.expires >= time.time() and previous.data.items:
|
| 391 |
+
mode.dropped = f"⚠️ Your /sequence with {len(previous.data.items)} file(s) was discarded."
|
| 392 |
+
self.modes[uid] = mode
|
| 393 |
+
return mode
|
| 394 |
+
|
| 395 |
+
def _end_mode(self, uid: int) -> Optional[Mode]:
|
| 396 |
+
mode = self.modes.pop(uid, None)
|
| 397 |
+
if mode is not None and isinstance(mode.data, SequenceState):
|
| 398 |
+
mode.data.closed = True
|
| 399 |
+
return mode
|
| 400 |
+
|
| 401 |
+
def _spawn(self, coro: Awaitable[Any]) -> "asyncio.Future[Any]":
|
| 402 |
+
task = asyncio.ensure_future(coro)
|
| 403 |
+
self._tasks.add(task)
|
| 404 |
+
task.add_done_callback(self._tasks.discard)
|
| 405 |
+
return task
|
| 406 |
+
|
| 407 |
+
def _consume(self, uid: int, kind: str) -> Optional[Mode]:
|
| 408 |
+
"""Take the user's one-file mode. Nothing awaits in here, so of several files that arrive together only one gets it."""
|
| 409 |
+
mode = self._mode(uid, kind)
|
| 410 |
+
if mode is None:
|
| 411 |
+
return None
|
| 412 |
+
del self.modes[uid]
|
| 413 |
+
self._recent[uid] = [kind, time.time(), False]
|
| 414 |
+
return mode
|
| 415 |
+
|
| 416 |
+
def _extra_file_hint(self, uid: int, via: str = "doc") -> Optional[str]:
|
| 417 |
+
"""After a one-file command: ``""`` for a further file to skip silently, a hint for the first one, ``None`` once the grace is over.
|
| 418 |
+
|
| 419 |
+
``via`` is how the file arrived (``doc``, ``photo`` or ``gif``); only files the command itself would have taken are skipped.
|
| 420 |
+
"""
|
| 421 |
+
rec = self._recent.get(uid)
|
| 422 |
+
if rec is None:
|
| 423 |
+
return None
|
| 424 |
+
if time.time() - rec[1] > ONE_SHOT_GRACE:
|
| 425 |
+
del self._recent[uid]
|
| 426 |
+
return None
|
| 427 |
+
if via not in ONE_SHOT_TAKES.get(rec[0], {"doc"}):
|
| 428 |
+
return None
|
| 429 |
+
if rec[2]:
|
| 430 |
+
return ""
|
| 431 |
+
rec[2] = True
|
| 432 |
+
return f"ℹ️ /{rec[0]} handles one file at a time, so I skipped this one. Send /{rec[0]} again for the next file."
|
| 433 |
+
|
| 434 |
# --------------------------------------------------------------- commands
|
| 435 |
async def cmd_start(self, event: Any) -> None:
|
| 436 |
self.pending.pop(event.sender_id, None)
|
| 437 |
text, rows = await self.panel(event.sender_id, "home")
|
| 438 |
+
if not await self._send_start_media(event.chat_id, text, rows):
|
| 439 |
+
await self._reply(event, text, rows)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 440 |
|
| 441 |
async def cmd_help(self, event: Any) -> None:
|
| 442 |
text, _ = panels.help_panel()
|
|
|
|
| 595 |
await self._reply(event, "You have no thumbnail.")
|
| 596 |
|
| 597 |
async def on_photo(self, event: Any) -> None:
|
| 598 |
+
uid = event.sender_id
|
| 599 |
+
p = self.pending.get(uid)
|
| 600 |
+
if not (p is not None and p.kind == "fmt:thumb" and p.expires >= time.time()):
|
| 601 |
+
if await self._image_mode(event):
|
| 602 |
+
return
|
| 603 |
+
hint = self._extra_file_hint(uid, "photo")
|
| 604 |
+
if hint is not None:
|
| 605 |
+
if hint:
|
| 606 |
+
await self._reply(event, hint)
|
| 607 |
+
return
|
| 608 |
raw = await event.download_media(bytes)
|
| 609 |
if not raw:
|
| 610 |
await self._reply(event, "❌ I couldn't download that photo.")
|
|
|
|
| 614 |
except Exception: # noqa: BLE001
|
| 615 |
await self._reply(event, "❌ I couldn't read that image.")
|
| 616 |
return
|
| 617 |
+
if p is not None and p.kind == "fmt:thumb" and p.expires >= time.time():
|
| 618 |
+
await self._format_thumb_saved(event, p, thumb)
|
| 619 |
+
return
|
| 620 |
await self.db.set_thumb(event.sender_id, thumb)
|
| 621 |
await self._reply(event, "✅ Thumbnail saved. It will be used for the files I send back.", [[("🗑 Delete", "th:rm"), ("✖ Close", "x:close")]])
|
| 622 |
|
| 623 |
+
async def on_gif(self, event: Any) -> None:
|
| 624 |
+
if await self._image_mode(event):
|
| 625 |
+
return
|
| 626 |
+
hint = self._extra_file_hint(event.sender_id, "gif")
|
| 627 |
+
if hint:
|
| 628 |
+
await self._reply(event, hint)
|
| 629 |
+
|
| 630 |
# ------------------------------------------------------------ rename flow
|
| 631 |
+
async def cmd_formats(self, event: Any) -> None:
|
| 632 |
+
self.pending.pop(event.sender_id, None)
|
| 633 |
+
text, rows = await self.panel(event.sender_id, "formats")
|
| 634 |
+
await self._reply(event, text, rows)
|
| 635 |
+
|
| 636 |
async def cmd_rename(self, event: Any) -> None:
|
| 637 |
+
uid = event.sender_id
|
| 638 |
+
self.pending.pop(uid, None)
|
| 639 |
+
text, rows = panels.rename_picker(await self.db.list_formats(uid))
|
| 640 |
+
await self._reply(event, text, rows)
|
|
|
|
|
|
|
| 641 |
|
| 642 |
async def on_media(self, event: Any) -> None:
|
| 643 |
+
uid = event.sender_id
|
| 644 |
+
if MEDIAINFO_CAPTION.match(event.raw_text or ""):
|
| 645 |
+
return # cmd_mediainfo has it
|
| 646 |
+
mode = self._consume(uid, "rename")
|
| 647 |
+
if mode is not None:
|
| 648 |
+
await self._media_rename(event, mode)
|
| 649 |
+
return
|
| 650 |
+
mode = self._consume(uid, "ethumb")
|
| 651 |
+
if mode is not None:
|
| 652 |
+
await self._send_thumbnail(event, event.message)
|
| 653 |
+
return
|
| 654 |
+
if await self._image_mode(event):
|
| 655 |
+
return
|
| 656 |
+
mode = self._mode(uid, "sequence")
|
| 657 |
+
if mode is not None:
|
| 658 |
+
notice = self._collect(event, mode)
|
| 659 |
+
if notice:
|
| 660 |
+
await self._reply(event, notice)
|
| 661 |
+
return
|
| 662 |
+
hint = self._extra_file_hint(uid)
|
| 663 |
+
if hint is not None:
|
| 664 |
+
if hint:
|
| 665 |
+
await self._reply(event, hint)
|
| 666 |
+
return
|
| 667 |
await self.enqueue(event, event.message)
|
| 668 |
|
| 669 |
+
async def _media_rename(self, event: Any, mode: Mode) -> None:
|
| 670 |
+
fmt = await self.db.get_format(event.sender_id, mode.data["fid"])
|
| 671 |
+
if fmt is None:
|
| 672 |
+
await self._reply(event, "⚠️ That format was deleted. Use /rename to pick another one, then send the file again.")
|
| 673 |
+
return
|
| 674 |
+
await self.enqueue(event, event.message, fmt=fmt)
|
| 675 |
+
|
| 676 |
+
# --------------------------------------------------------- /sequence, /sort
|
| 677 |
+
async def cmd_sequence(self, event: Any) -> None:
|
| 678 |
+
uid = event.sender_id
|
| 679 |
+
self.pending.pop(uid, None)
|
| 680 |
+
mode = self._mode(uid, "sequence")
|
| 681 |
+
if mode is not None and mode.data.items: # a second /sequence must not throw the collected files away
|
| 682 |
+
mode.expires = time.time() + mode.ttl
|
| 683 |
+
text, rows = panels.sequence_status(len(mode.data.items), SEQUENCE_MAX)
|
| 684 |
+
await self._reply(event, "You're already collecting files for a sequence.\n\n" + text, rows)
|
| 685 |
+
return
|
| 686 |
+
mode = self._start_mode(uid, "sequence", SEQUENCE_WAIT, SequenceState())
|
| 687 |
+
text, rows = panels.sequence_intro(SEQUENCE_MAX)
|
| 688 |
+
await self._reply(event, text + (f"\n\n{mode.dropped}" if mode.dropped else ""), rows)
|
| 689 |
+
|
| 690 |
+
def _collect(self, event: Any, mode: Mode) -> Optional[str]:
|
| 691 |
+
"""Add a file to the user's sequence. Nothing awaits in here, so files that arrive together are all kept."""
|
| 692 |
+
state: SequenceState = mode.data
|
| 693 |
+
mode.expires = time.time() + mode.ttl
|
| 694 |
+
if len(state.items) >= SEQUENCE_MAX:
|
| 695 |
+
if state.full_noted:
|
| 696 |
+
return None
|
| 697 |
+
state.full_noted = True
|
| 698 |
+
return f"⚠️ That's the limit: {SEQUENCE_MAX} files. Send /sort now, or /cancel to start over."
|
| 699 |
+
msg = event.message
|
| 700 |
+
state.items.append(SeqItem(msg_id=msg.id, name=original_name_of(msg), caption=msg.raw_text or ""))
|
| 701 |
+
state.version += 1
|
| 702 |
+
if state.task is None or state.task.done():
|
| 703 |
+
state.task = self._spawn(self._sequence_status(event.chat_id, state))
|
| 704 |
+
return None
|
| 705 |
+
|
| 706 |
+
async def _sequence_status(self, chat_id: int, state: SequenceState) -> None:
|
| 707 |
+
"""Send "N files collected" once the files stop arriving, so it is the newest message in the chat."""
|
| 708 |
+
try:
|
| 709 |
+
while not state.closed:
|
| 710 |
+
seen = state.version
|
| 711 |
+
await asyncio.sleep(SEQUENCE_STATUS_DELAY)
|
| 712 |
+
if state.closed:
|
| 713 |
+
return
|
| 714 |
+
if state.version != seen:
|
| 715 |
+
continue
|
| 716 |
+
text, rows = panels.sequence_status(len(state.items), SEQUENCE_MAX)
|
| 717 |
+
previous = state.status_id
|
| 718 |
+
sent = await self.client.send_message(chat_id, text, buttons=panels.to_buttons(rows), parse_mode="html")
|
| 719 |
+
if state.closed: # /sort or /cancel came while it was being sent
|
| 720 |
+
await self._delete_quietly(chat_id, sent.id)
|
| 721 |
+
return
|
| 722 |
+
state.status_id = sent.id
|
| 723 |
+
if previous is not None:
|
| 724 |
+
await self._delete_quietly(chat_id, previous)
|
| 725 |
+
if state.version == seen:
|
| 726 |
+
return
|
| 727 |
+
except asyncio.CancelledError:
|
| 728 |
+
raise
|
| 729 |
+
except Exception: # noqa: BLE001
|
| 730 |
+
log.exception("could not send the sequence status")
|
| 731 |
+
|
| 732 |
+
async def _delete_quietly(self, chat_id: int, *ids: int) -> None:
|
| 733 |
+
try:
|
| 734 |
+
await self.client.delete_messages(chat_id, list(ids))
|
| 735 |
+
except Exception as exc: # noqa: BLE001
|
| 736 |
+
log.debug("could not delete messages %s: %s", ids, exc)
|
| 737 |
+
|
| 738 |
+
async def cmd_sort(self, event: Any) -> None:
|
| 739 |
+
await self._run_sort(event.sender_id, event.chat_id)
|
| 740 |
+
|
| 741 |
+
@staticmethod
|
| 742 |
+
async def _pause(seconds: float) -> None:
|
| 743 |
+
await asyncio.sleep(seconds)
|
| 744 |
+
|
| 745 |
+
async def _send_back(self, chat_id: int, msg: Any) -> None:
|
| 746 |
+
"""Send one collected file again (no re-upload), keeping its caption and formatting."""
|
| 747 |
+
for attempt in range(3):
|
| 748 |
+
try:
|
| 749 |
+
await self.client.send_file(chat_id, msg, caption=msg.raw_text or "", formatting_entities=msg.entities, parse_mode=None)
|
| 750 |
+
return
|
| 751 |
+
except errors.FloodWaitError as exc:
|
| 752 |
+
if attempt == 2 or exc.seconds > SORT_MAX_WAIT:
|
| 753 |
+
raise
|
| 754 |
+
await self._pause(exc.seconds + 1)
|
| 755 |
+
|
| 756 |
+
async def _run_sort(self, uid: int, chat_id: int) -> None:
|
| 757 |
+
say = lambda text, **kw: self.client.send_message(chat_id, text, parse_mode="html", link_preview=False, **kw) # noqa: E731
|
| 758 |
+
if uid in self._sorting:
|
| 759 |
+
await say("⏳ I'm still sorting your last batch. /cancel to stop it.")
|
| 760 |
+
return
|
| 761 |
+
mode = self._mode(uid, "sequence")
|
| 762 |
+
state: Optional[SequenceState] = mode.data if mode is not None else None
|
| 763 |
+
if state is None or not state.items:
|
| 764 |
+
await say("Nothing to sort yet. Send /sequence, then the files, then /sort.")
|
| 765 |
+
return
|
| 766 |
+
self._end_mode(uid)
|
| 767 |
+
cancel = self._sorting[uid] = asyncio.Event()
|
| 768 |
+
total = len(state.items)
|
| 769 |
+
status = None
|
| 770 |
+
try:
|
| 771 |
+
if state.status_id is not None:
|
| 772 |
+
await self._delete_quietly(chat_id, state.status_id)
|
| 773 |
+
s = await self.db.get_settings(uid)
|
| 774 |
+
plan = analyse(state.items, s["mode"])
|
| 775 |
+
status = await say(f"🔢 <b>Sorting {total} file{'s' if total != 1 else ''}…</b>")
|
| 776 |
+
fetched = list(await self.client.get_messages(chat_id, ids=[i.msg_id for i in plan.ordered]))
|
| 777 |
+
fetched += [None] * (len(plan.ordered) - len(fetched))
|
| 778 |
+
|
| 779 |
+
sent = 0
|
| 780 |
+
failed: List[SeqItem] = []
|
| 781 |
+
note = ""
|
| 782 |
+
stopped = False
|
| 783 |
+
for index, (item, msg) in enumerate(zip(plan.ordered, fetched)):
|
| 784 |
+
if cancel.is_set():
|
| 785 |
+
stopped = True
|
| 786 |
+
break
|
| 787 |
+
if msg is None or getattr(msg, "file", None) is None: # deleted since it was sent
|
| 788 |
+
failed.append(item)
|
| 789 |
+
continue
|
| 790 |
+
try:
|
| 791 |
+
await self._send_back(chat_id, msg)
|
| 792 |
+
except errors.FloodWaitError as exc:
|
| 793 |
+
note = f"⚠️ Telegram asked me to wait {human_uptime(exc.seconds)} before sending more, so I stopped here."
|
| 794 |
+
failed.extend(plan.ordered[index:])
|
| 795 |
+
break
|
| 796 |
+
except Exception as exc: # noqa: BLE001
|
| 797 |
+
log.warning("could not send %s back: %s", item.name, exc)
|
| 798 |
+
failed.append(item)
|
| 799 |
+
continue
|
| 800 |
+
sent += 1
|
| 801 |
+
if sent % SORT_PROGRESS_EVERY == 0 and sent < total:
|
| 802 |
+
try:
|
| 803 |
+
await self.client.edit_message(status, f"🔢 <b>Sorting…</b> {sent} of {total} sent", parse_mode="html")
|
| 804 |
+
except Exception: # noqa: BLE001
|
| 805 |
+
pass
|
| 806 |
+
if SORT_GAP:
|
| 807 |
+
await asyncio.sleep(SORT_GAP)
|
| 808 |
+
if status is not None:
|
| 809 |
+
await self._delete_quietly(chat_id, status.id)
|
| 810 |
+
if stopped:
|
| 811 |
+
await say(f"⏹ Stopped after {sent} of {total} file{'s' if total != 1 else ''}.")
|
| 812 |
+
return
|
| 813 |
+
for chunk in format_report(plan, total, sent, failed, note):
|
| 814 |
+
await say(chunk)
|
| 815 |
+
finally:
|
| 816 |
+
self._sorting.pop(uid, None)
|
| 817 |
+
|
| 818 |
+
async def enqueue(self, event: Any, msg: Any, fmt: Optional[Dict[str, Any]] = None) -> None:
|
| 819 |
uid = event.sender_id
|
| 820 |
size = int(msg.file.size or 0) if msg.file is not None else 0
|
| 821 |
if size > self.cfg.max_file_bytes:
|
|
|
|
| 824 |
s = await self.db.get_settings(uid)
|
| 825 |
orig = original_name_of(msg)
|
| 826 |
ext = source_ext(msg, orig)
|
| 827 |
+
template = fmt["template"] if fmt else s["template"]
|
| 828 |
+
new_name, info = compute_new_name(
|
| 829 |
+
template=template, mode=s["mode"], original_name=orig, original_ext=ext,
|
| 830 |
+
caption=msg.raw_text or "", remove_words=s["remove_words"],
|
| 831 |
+
)
|
| 832 |
notes: List[str] = []
|
| 833 |
+
gaps = missing_variables(template, info)
|
| 834 |
+
if gaps:
|
| 835 |
+
where_from = "caption" if s["mode"] == "caption" else "file name"
|
| 836 |
+
notes.append(
|
| 837 |
+
"Couldn't find " + ", ".join("{%s}" % g for g in gaps) + f" in the {where_from}, so it is left empty. "
|
| 838 |
+
"Cancel and change the template if you don't want that."
|
| 839 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 840 |
job = Job(
|
| 841 |
id=self.queue.new_id(), user_id=uid, chat_id=event.chat_id, msg_id=msg.id,
|
| 842 |
+
file_name=orig, size=size, new_name=new_name, fmt=fmt, notes=notes, order=event.id,
|
| 843 |
)
|
| 844 |
ahead = self.queue.running + self.queue.waiting
|
| 845 |
where = "starting soon" if ahead == 0 else f"{ahead} ahead of it"
|
| 846 |
+
used = f"\n🗂 Format: <b>{esc(clip(fmt['name'], 30))}</b>" if fmt else ""
|
| 847 |
job.status_msg = await event.reply(
|
| 848 |
+
f"📥 <b>Queued</b> ({where}){used}\n<code>{esc(clip(new_name, 120))}</code>" + "".join(f"\n⚠️ {esc(n)}" for n in notes),
|
| 849 |
buttons=panels.to_buttons([[("✖ Cancel", f"job:x:{job.id}")]]),
|
| 850 |
parse_mode="html",
|
| 851 |
)
|
|
|
|
| 879 |
if not arg and self.pending.pop(uid, None) is not None:
|
| 880 |
await self._reply(event, "Cancelled — nothing was changed.")
|
| 881 |
return
|
| 882 |
+
if not arg and uid in self._sorting:
|
| 883 |
+
self._sorting[uid].set()
|
| 884 |
+
await self._reply(event, "⏹ Stopping the sort…")
|
| 885 |
+
return
|
| 886 |
+
if not arg:
|
| 887 |
+
mode = self._end_mode(uid)
|
| 888 |
+
if mode is not None and mode.expires >= time.time():
|
| 889 |
+
if isinstance(mode.data, SequenceState) and mode.data.status_id is not None:
|
| 890 |
+
await self._delete_quietly(event.chat_id, mode.data.status_id) # its buttons are of no use any more
|
| 891 |
+
busy = " Send /cancel again to cancel your queued files." if self.queue.for_user(uid) else ""
|
| 892 |
+
await self._reply(event, self._mode_cancelled_text(mode) + busy)
|
| 893 |
+
return
|
| 894 |
owner_scope = None if self.is_owner(uid) else uid
|
| 895 |
if arg.isdigit():
|
| 896 |
ok = self.queue.cancel(int(arg), user_id=owner_scope)
|
|
|
|
| 899 |
count = self.queue.cancel_all() if (arg == "all" and self.is_owner(uid)) else self.queue.cancel_user(uid)
|
| 900 |
await self._reply(event, f"🛑 Cancelling {count} job(s)…" if count else "You have no active jobs.")
|
| 901 |
|
| 902 |
+
@staticmethod
|
| 903 |
+
def _mode_cancelled_text(mode: Mode) -> str:
|
| 904 |
+
if mode.kind == "sequence":
|
| 905 |
+
count = len(mode.data.items)
|
| 906 |
+
return f"⏹ Sequence cancelled. {count} file{'s' if count != 1 else ''} discarded." if count else "⏹ Sequence cancelled."
|
| 907 |
+
return {
|
| 908 |
+
"rename": "⏹ Cancelled. Send /rename again to pick a format.",
|
| 909 |
+
"smedia": "⏹ Cancelled. Send /smedia again to change the start media.",
|
| 910 |
+
}.get(mode.kind, "Cancelled.")
|
| 911 |
+
|
| 912 |
+
# ------------------------------------------------------------------ check
|
| 913 |
+
async def cmd_check(self, event: Any) -> None:
|
| 914 |
+
reply = await event.get_reply_message()
|
| 915 |
+
if reply is None or not is_renameable(reply):
|
| 916 |
+
await self._reply(event, "Reply to a file with /check to see the details I can read from its name and caption.")
|
| 917 |
+
return
|
| 918 |
+
s = await self.db.get_settings(event.sender_id)
|
| 919 |
+
name = original_name_of(reply)
|
| 920 |
+
caption = reply.raw_text or ""
|
| 921 |
+
new_name, _info = compute_new_name(
|
| 922 |
+
template=s["template"], mode=s["mode"], original_name=name, original_ext=source_ext(reply, name),
|
| 923 |
+
caption=caption, remove_words=s["remove_words"],
|
| 924 |
+
)
|
| 925 |
+
text = panels.check_text(s["mode"], parse_info(split_ext(name)[0]), parse_caption(caption), new_name)
|
| 926 |
+
await event.reply(text, parse_mode="html", link_preview=False)
|
| 927 |
+
|
| 928 |
# ------------------------------------------------------------------ tracks
|
| 929 |
+
async def _with_file_start(
|
| 930 |
+
self, event: Any, msg: Any, tag: str,
|
| 931 |
+
attempt: Callable[[Path], Awaitable[Any]], on_whole: Optional[Callable[[], Awaitable[None]]] = None,
|
| 932 |
+
) -> Any:
|
| 933 |
+
"""Run ``attempt(path)`` on the first part of the file; when it gives nothing (an index at the end of an mp4, say) on the whole file.
|
| 934 |
+
|
| 935 |
+
The files live in a temporary folder that is removed afterwards. ``on_whole`` runs before the big download starts.
|
| 936 |
+
"""
|
| 937 |
+
name = original_name_of(msg)
|
| 938 |
+
size = int(msg.file.size or 0)
|
| 939 |
+
tmp_dir = Path(self.cfg.temp_dir) / f"{tag}{event.chat_id}_{event.id}"
|
| 940 |
+
tmp_dir.mkdir(parents=True, exist_ok=True)
|
| 941 |
+
try:
|
| 942 |
+
path = tmp_dir / f"probe{source_ext(msg, name)}"
|
| 943 |
+
await transfer.download(self.client, msg, path, fast=self.cfg.fast_transfer, connections=2, max_bytes=TRACKS_PROBE_BYTES)
|
| 944 |
+
result = await attempt(path)
|
| 945 |
+
if result is None and TRACKS_PROBE_BYTES < size <= TRACKS_FULL_LIMIT:
|
| 946 |
+
if on_whole is not None:
|
| 947 |
+
await on_whole()
|
| 948 |
+
await transfer.download(self.client, msg, path, fast=self.cfg.fast_transfer, connections=self.cfg.connections)
|
| 949 |
+
result = await attempt(path)
|
| 950 |
+
return result
|
| 951 |
+
finally:
|
| 952 |
+
shutil.rmtree(tmp_dir, ignore_errors=True)
|
| 953 |
+
|
| 954 |
async def cmd_tracks(self, event: Any) -> None:
|
| 955 |
reply = await event.get_reply_message()
|
| 956 |
if reply is None or not is_renameable(reply):
|
| 957 |
await self._reply(event, "Reply to a video or audio file with /tracks to list its audio and subtitle tracks.")
|
| 958 |
return
|
| 959 |
status = await self._reply(event, "🔎 Reading the tracks…")
|
| 960 |
+
|
| 961 |
+
async def read(path: Path) -> Optional[media.MediaInfo]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 962 |
try:
|
| 963 |
info = await media.probe(path)
|
| 964 |
except media.MediaError:
|
| 965 |
+
return None
|
| 966 |
+
return info if info.streams else None
|
| 967 |
+
|
| 968 |
+
async def whole() -> None:
|
| 969 |
+
await self.client.edit_message(status, "🔎 Reading the tracks (this one needs the whole file)…", parse_mode="html")
|
| 970 |
+
|
| 971 |
+
info = await self._with_file_start(event, reply, "tracks", read, whole)
|
| 972 |
+
if info is None:
|
| 973 |
+
await self.client.edit_message(status, "❌ I couldn't read any tracks from that file.", parse_mode="html")
|
| 974 |
+
return
|
| 975 |
+
text = f"<b>{esc(clip(original_name_of(reply), 80))}</b>\n\n{media.format_tracks(info)}"
|
| 976 |
+
await self.client.edit_message(status, text[:3900], parse_mode="html")
|
| 977 |
+
|
| 978 |
+
# --------------------------------------------------------------- /mediainfo
|
| 979 |
+
async def _load_telegraph_token(self) -> Optional[str]:
|
| 980 |
+
return await self.db.kv_get("telegraph_token")
|
| 981 |
+
|
| 982 |
+
async def _save_telegraph_token(self, token: str) -> None:
|
| 983 |
+
if token:
|
| 984 |
+
await self.db.kv_set("telegraph_token", token)
|
| 985 |
+
else:
|
| 986 |
+
await self.db.kv_delete("telegraph_token")
|
| 987 |
+
|
| 988 |
+
async def cmd_mediainfo(self, event: Any) -> None:
|
| 989 |
+
target, own = await event.get_reply_message(), False
|
| 990 |
+
if target is None or getattr(target, "document", None) is None:
|
| 991 |
+
target, own = event.message, True # a file sent with /mediainfo as its caption
|
| 992 |
+
if getattr(target, "document", None) is None:
|
| 993 |
+
await self._reply(event, "Reply to a file or video with /mediainfo and I'll put everything MediaInfo can read from it on a Telegraph page.")
|
| 994 |
+
return
|
| 995 |
+
status = await self._reply(event, "🔎 Reading the file…")
|
| 996 |
+
try:
|
| 997 |
+
await self._mediainfo(event, target, own, status)
|
| 998 |
+
except Exception: # noqa: BLE001
|
| 999 |
+
log.exception("/mediainfo failed")
|
| 1000 |
+
try:
|
| 1001 |
+
await self.client.edit_message(status, "⚠️ Something went wrong while reading that file.", parse_mode="html")
|
| 1002 |
+
except Exception: # noqa: BLE001
|
| 1003 |
+
pass
|
| 1004 |
+
|
| 1005 |
+
async def _mediainfo(self, event: Any, msg: Any, own: bool, status: Any) -> None:
|
| 1006 |
+
name = original_name_of(msg)
|
| 1007 |
+
size = int(msg.file.size or 0)
|
| 1008 |
+
playable = is_playable(msg, name)
|
| 1009 |
+
extra = mediainfo.telegram_section(name, size, **telegram_facts(msg, caption=not own))
|
| 1010 |
+
last: List[mediainfo.Report] = []
|
| 1011 |
+
|
| 1012 |
+
async def read(path: Path) -> Optional[mediainfo.Report]:
|
| 1013 |
+
report = await mediainfo.read_file(path, name, size, extra=extra)
|
| 1014 |
+
last[:] = [report]
|
| 1015 |
+
return report if report.has_streams or not playable else None
|
| 1016 |
+
|
| 1017 |
+
async def whole() -> None:
|
| 1018 |
+
await self.client.edit_message(status, "🔎 Reading the file (this one needs the whole file)…", parse_mode="html")
|
| 1019 |
+
|
| 1020 |
+
try:
|
| 1021 |
+
report = await self._with_file_start(event, msg, "minfo", read, whole) or (last[0] if last else None)
|
| 1022 |
+
except Exception as exc: # noqa: BLE001
|
| 1023 |
+
log.warning("could not read %s for /mediainfo: %s", name, exc)
|
| 1024 |
+
report = None
|
| 1025 |
+
if report is None:
|
| 1026 |
+
await self.client.edit_message(status, "❌ I couldn't read that file.", parse_mode="html")
|
| 1027 |
+
return
|
| 1028 |
+
if playable and not report.has_streams:
|
| 1029 |
+
report.notes.append(
|
| 1030 |
+
"No tracks could be read from this file, and it is too big for me to download completely."
|
| 1031 |
+
if size > TRACKS_FULL_LIMIT else "No tracks could be read from this file. It may be damaged."
|
| 1032 |
+
)
|
| 1033 |
+
try:
|
| 1034 |
+
url = await self.telegraph.create_page(clip(f"MediaInfo – {name}", 256), mediainfo.to_nodes(report))
|
| 1035 |
+
except Exception as exc: # noqa: BLE001
|
| 1036 |
+
log.warning("Telegraph did not take the /mediainfo page: %s", exc)
|
| 1037 |
+
why = str(exc) if isinstance(exc, TelegraphError) else "Telegraph is not available"
|
| 1038 |
+
await self._mediainfo_file(event, status, name, report, why)
|
| 1039 |
+
return
|
| 1040 |
+
summary = report.summary()
|
| 1041 |
+
text = f"🔎 <b>MediaInfo</b> · {esc(clip(name, 80))}\n" + (f"<i>{esc(summary)}</i>\n" if summary else "") + f"\n{esc(url)}"
|
| 1042 |
+
await self.client.edit_message(status, text, buttons=[[Button.url("📄 Open MediaInfo", url)]], parse_mode="html", link_preview=False)
|
| 1043 |
+
|
| 1044 |
+
async def _mediainfo_file(self, event: Any, status: Any, name: str, report: mediainfo.Report, why: str) -> None:
|
| 1045 |
+
"""Telegraph did not work: send the same report as a text file instead."""
|
| 1046 |
+
bio = io.BytesIO(mediainfo.render_text(report).encode("utf-8"))
|
| 1047 |
+
bio.name = re.sub(r'[\\/:*?"<>|]+', "_", clip(split_ext(name)[0], 60)) + " - MediaInfo.txt"
|
| 1048 |
+
caption = f"🔎 <b>MediaInfo</b> · {esc(clip(name, 80))}\n<i>{esc(why)}, so here it is as a text file.</i>"
|
| 1049 |
+
await self.client.send_file(event.chat_id, bio, caption=caption, parse_mode="html", force_document=True)
|
| 1050 |
+
await self._delete_quietly(event.chat_id, status.id)
|
| 1051 |
+
|
| 1052 |
+
# ----------------------------------------------------------- /ethumb, /resize
|
| 1053 |
+
async def cmd_ethumb(self, event: Any) -> None:
|
| 1054 |
+
uid = event.sender_id
|
| 1055 |
+
self.pending.pop(uid, None)
|
| 1056 |
+
reply = await event.get_reply_message()
|
| 1057 |
+
if reply is not None and is_renameable(reply): # sent as a reply to the file: no need to ask for it
|
| 1058 |
+
await self._send_thumbnail(event, reply)
|
| 1059 |
+
return
|
| 1060 |
+
mode = self._start_mode(uid, "ethumb", ONE_FILE_WAIT)
|
| 1061 |
+
text, rows = panels.ethumb_prompt(ONE_FILE_WAIT // 60)
|
| 1062 |
+
await self._reply(event, text + (f"\n\n{mode.dropped}" if mode.dropped else ""), rows)
|
| 1063 |
+
|
| 1064 |
+
async def _embedded_thumb(self, msg: Any, allow_stripped: bool = False) -> Optional[bytes]:
|
| 1065 |
+
"""The preview Telegram keeps with a file. The tiny blurred one (a "stripped" size) only counts when ``allow_stripped``."""
|
| 1066 |
+
thumbs = getattr(getattr(msg, "document", None), "thumbs", None) or []
|
| 1067 |
+
if not any(not isinstance(t, types.PhotoStrippedSize) for t in thumbs) and not (allow_stripped and thumbs):
|
| 1068 |
+
return None
|
| 1069 |
+
try:
|
| 1070 |
+
return await self.client.download_media(msg, file=bytes, thumb=-1) or None
|
| 1071 |
+
except Exception as exc: # noqa: BLE001
|
| 1072 |
+
log.debug("could not download the embedded thumbnail: %s", exc)
|
| 1073 |
+
return None
|
| 1074 |
+
|
| 1075 |
+
async def _extract_thumbnail(self, event: Any, msg: Any) -> Optional[bytes]:
|
| 1076 |
+
data = await self._embedded_thumb(msg)
|
| 1077 |
+
if data:
|
| 1078 |
+
return data
|
| 1079 |
+
name = original_name_of(msg)
|
| 1080 |
+
playable = getattr(msg, "video", None) is not None or getattr(msg, "audio", None) is not None
|
| 1081 |
+
if playable or source_ext(msg, name).lower() in PROBE_EXTS:
|
| 1082 |
+
async def frame(path: Path) -> Optional[bytes]:
|
| 1083 |
+
out = path.with_suffix(".jpg")
|
| 1084 |
try:
|
| 1085 |
+
duration = (await media.probe(path)).duration
|
| 1086 |
except media.MediaError:
|
| 1087 |
+
duration = 0.0 # cut off before the index: ffmpeg may still find a picture
|
| 1088 |
+
ok = await media.extract_frame(path, out, media.frame_seeks(duration), width=ETHUMB_FRAME_WIDTH)
|
| 1089 |
+
return out.read_bytes() if ok else None
|
| 1090 |
+
|
| 1091 |
+
data = await self._with_file_start(event, msg, "ethumb", frame)
|
| 1092 |
+
if data:
|
| 1093 |
+
return data
|
| 1094 |
+
return await self._embedded_thumb(msg, allow_stripped=True)
|
| 1095 |
+
|
| 1096 |
+
async def _send_thumbnail(self, event: Any, msg: Any) -> None:
|
| 1097 |
+
status = await self._reply(event, "🖼 Extracting the thumbnail…")
|
| 1098 |
+
name = original_name_of(msg)
|
| 1099 |
+
try:
|
| 1100 |
+
data = await self._extract_thumbnail(event, msg)
|
| 1101 |
+
except Exception as exc: # noqa: BLE001
|
| 1102 |
+
log.warning("thumbnail extraction failed for %s: %s", name, exc)
|
| 1103 |
+
data = None
|
| 1104 |
+
if not data:
|
| 1105 |
+
await self.client.edit_message(status, "❌ I couldn't find a thumbnail in that file or grab a frame from it.", parse_mode="html")
|
| 1106 |
+
return
|
| 1107 |
+
bio = io.BytesIO(data)
|
| 1108 |
+
bio.name = "thumbnail.jpg"
|
| 1109 |
+
await self.client.send_file(event.chat_id, bio, caption=f"🖼 Thumbnail of <b>{esc(clip(name, 80))}</b>", parse_mode="html")
|
| 1110 |
+
await self._delete_quietly(event.chat_id, status.id)
|
| 1111 |
+
|
| 1112 |
+
async def cmd_resize(self, event: Any) -> None:
|
| 1113 |
+
uid = event.sender_id
|
| 1114 |
+
self.pending.pop(uid, None)
|
| 1115 |
+
arg = self._args(event)
|
| 1116 |
+
box = None
|
| 1117 |
+
if arg:
|
| 1118 |
+
box = media.parse_size(arg)
|
| 1119 |
+
if box is None:
|
| 1120 |
+
await self._reply(event, "❌ Send a size like <code>/resize 320</code> or <code>/resize 320x180</code> (16 to 4096 pixels).")
|
| 1121 |
return
|
| 1122 |
+
reply = await event.get_reply_message()
|
| 1123 |
+
if reply is not None and is_image(reply): # sent as a reply to the image: do it now (thumbnail size unless told otherwise)
|
| 1124 |
+
await self._send_resized(event, reply, box or (media.THUMB_BOX, media.THUMB_BOX))
|
| 1125 |
+
return
|
| 1126 |
+
if box is None:
|
| 1127 |
+
text, rows = panels.resize_menu()
|
| 1128 |
+
await self._reply(event, text, rows)
|
| 1129 |
+
return
|
| 1130 |
+
await self._wait_for_image(event, box)
|
| 1131 |
+
|
| 1132 |
+
async def _wait_for_image(self, event: Any, box: Any) -> None:
|
| 1133 |
+
mode = self._start_mode(event.sender_id, "resize", ONE_FILE_WAIT, {"box": box})
|
| 1134 |
+
text, rows = panels.resize_ready(box, ONE_FILE_WAIT // 60)
|
| 1135 |
+
await self._reply(event, text + (f"\n\n{mode.dropped}" if mode.dropped else ""), rows)
|
| 1136 |
+
|
| 1137 |
+
async def _cb_resize(self, event: Any, uid: int, parts: List[str]) -> None:
|
| 1138 |
+
self.pending.pop(uid, None)
|
| 1139 |
+
action = parts[1] if len(parts) > 1 else ""
|
| 1140 |
+
if action == "s" and len(parts) == 3 and parts[2].isdigit() and int(parts[2]) in dict(panels.RESIZE_PRESETS):
|
| 1141 |
+
box = (int(parts[2]), int(parts[2]))
|
| 1142 |
+
mode = self._start_mode(uid, "resize", ONE_FILE_WAIT, {"box": box})
|
| 1143 |
+
text, rows = panels.resize_ready(box, ONE_FILE_WAIT // 60)
|
| 1144 |
+
await self._edit_panel(event, text + (f"\n\n{mode.dropped}" if mode.dropped else ""), rows)
|
| 1145 |
+
await event.answer(f"Size: {panels.describe_box(box)}")
|
| 1146 |
+
elif action == "custom":
|
| 1147 |
+
self.pending[uid] = Pending(
|
| 1148 |
+
kind="resize:size", chat_id=event.chat_id, msg_id=event.message_id, back="", is_photo=False,
|
| 1149 |
+
expires=time.time() + self.PENDING_TTL,
|
| 1150 |
+
)
|
| 1151 |
+
await self._edit_panel(event, panels.RESIZE_CUSTOM_PROMPT, [[("✖ Cancel", "rz:menu")]])
|
| 1152 |
+
await event.answer()
|
| 1153 |
+
elif action == "menu":
|
| 1154 |
+
if self._mode(uid, "resize") is not None:
|
| 1155 |
+
self._end_mode(uid)
|
| 1156 |
+
text, rows = panels.resize_menu()
|
| 1157 |
+
await self._edit_panel(event, text, rows)
|
| 1158 |
+
await event.answer()
|
| 1159 |
+
elif action == "stop":
|
| 1160 |
+
if self._mode(uid, "resize") is not None:
|
| 1161 |
+
self._end_mode(uid)
|
| 1162 |
+
await self._edit_panel(event, "⏹ Stopped. Send /resize again to resize an image.", [[("✖ Close", "x:close")]])
|
| 1163 |
+
await event.answer()
|
| 1164 |
+
else:
|
| 1165 |
+
await event.answer()
|
| 1166 |
+
|
| 1167 |
+
async def _on_resize_text(self, event: Any, p: Pending) -> None:
|
| 1168 |
+
uid = event.sender_id
|
| 1169 |
+
box = media.parse_size(event.raw_text or "")
|
| 1170 |
+
if box is None:
|
| 1171 |
+
await self._reply(
|
| 1172 |
+
event, "❌ I couldn't read that size. Send it like <code>320x180</code> or <code>320</code> (16 to 4096 pixels).\n\nTry again, or tap Cancel."
|
| 1173 |
+
)
|
| 1174 |
+
return
|
| 1175 |
+
self.pending.pop(uid, None)
|
| 1176 |
+
mode = self._start_mode(uid, "resize", ONE_FILE_WAIT, {"box": box})
|
| 1177 |
+
try:
|
| 1178 |
+
await event.delete()
|
| 1179 |
+
except Exception: # noqa: BLE001
|
| 1180 |
+
pass
|
| 1181 |
+
text, rows = panels.resize_ready(box, ONE_FILE_WAIT // 60)
|
| 1182 |
+
text += f"\n\n{mode.dropped}" if mode.dropped else ""
|
| 1183 |
+
try:
|
| 1184 |
+
await self.client.edit_message(p.chat_id, p.msg_id, text, buttons=panels.to_buttons(rows), parse_mode="html")
|
| 1185 |
+
except Exception: # noqa: BLE001
|
| 1186 |
+
await self._reply(event, text, rows)
|
| 1187 |
+
|
| 1188 |
+
async def _image_mode(self, event: Any) -> bool:
|
| 1189 |
+
"""Give an incoming picture (or GIF) to the command that is waiting for one (/resize, /smedia). True when the message was taken care of."""
|
| 1190 |
+
uid = event.sender_id
|
| 1191 |
+
mode = self._mode(uid, "resize", "smedia")
|
| 1192 |
+
if mode is None:
|
| 1193 |
+
return False
|
| 1194 |
+
if mode.kind == "smedia":
|
| 1195 |
+
await self._take_start_media(event, mode)
|
| 1196 |
+
return True
|
| 1197 |
+
if not is_image(event.message):
|
| 1198 |
+
await self._reply(event, "❌ That isn't an image. Send a photo or an image file, or /cancel to stop.")
|
| 1199 |
+
return True
|
| 1200 |
+
self._consume(uid, "resize")
|
| 1201 |
+
await self._send_resized(event, event.message, mode.data["box"])
|
| 1202 |
+
return True
|
| 1203 |
+
|
| 1204 |
+
async def _send_resized(self, event: Any, msg: Any, box: Any) -> None:
|
| 1205 |
+
size = int(msg.file.size or 0) if getattr(msg, "file", None) is not None else 0
|
| 1206 |
+
if size > RESIZE_MAX_BYTES:
|
| 1207 |
+
await self._reply(event, f"❌ That image is {human_size(size)}; I can resize images up to {human_size(RESIZE_MAX_BYTES)}.")
|
| 1208 |
+
return
|
| 1209 |
+
raw = await self.client.download_media(msg, file=bytes)
|
| 1210 |
+
if not raw:
|
| 1211 |
+
await self._reply(event, "❌ I couldn't download that image.")
|
| 1212 |
+
return
|
| 1213 |
+
try:
|
| 1214 |
+
data, original, new = await asyncio.to_thread(media.resize_image, raw, box[0], box[1])
|
| 1215 |
+
except Exception: # noqa: BLE001
|
| 1216 |
+
await self._reply(event, "❌ I couldn't read that image.")
|
| 1217 |
+
return
|
| 1218 |
+
bio = io.BytesIO(data)
|
| 1219 |
+
bio.name = "resized.jpg"
|
| 1220 |
+
caption = f"🖼 <b>{new[0]}×{new[1]}</b> (was {original[0]}×{original[1]}) · {human_size(len(data))}"
|
| 1221 |
+
await self.client.send_file(event.chat_id, bio, caption=caption, parse_mode="html", force_document=max(new) > 1280)
|
| 1222 |
+
|
| 1223 |
+
# ------------------------------------------------------- /smedia, the /start picture
|
| 1224 |
+
async def _start_media_meta(self) -> Optional[Dict[str, Any]]:
|
| 1225 |
+
"""What /start shows above the menu: ``{"kind": "image" | "gif", "file_id": ..., ...}``, or None."""
|
| 1226 |
+
raw = await self.db.kv_get("start_media")
|
| 1227 |
+
if not raw:
|
| 1228 |
+
return None
|
| 1229 |
+
try:
|
| 1230 |
+
meta = json.loads(raw)
|
| 1231 |
+
except ValueError:
|
| 1232 |
+
return None
|
| 1233 |
+
return meta if isinstance(meta, dict) and meta.get("kind") in ("image", "gif") else None
|
| 1234 |
+
|
| 1235 |
+
async def _store_start_media(self, meta: str, data: str) -> None:
|
| 1236 |
+
"""Keep the /start media (its description and a base64 copy of the file); empty strings forget it."""
|
| 1237 |
+
for key, value in (("start_media", meta), ("start_media_data", data)):
|
| 1238 |
+
if value:
|
| 1239 |
+
await self.db.kv_set(key, value)
|
| 1240 |
+
else:
|
| 1241 |
+
await self.db.kv_delete(key)
|
| 1242 |
+
|
| 1243 |
+
async def _send_start_media(self, chat_id: int, text: str, rows: List[panels.Row]) -> bool:
|
| 1244 |
+
"""Send the menu with the owner's picture or GIF above it. False when there is none or it can't be sent."""
|
| 1245 |
+
meta = await self._start_media_meta()
|
| 1246 |
+
if meta is None:
|
| 1247 |
+
return False
|
| 1248 |
+
kw: Dict[str, Any] = dict(caption=text, buttons=panels.to_buttons(rows), parse_mode="html")
|
| 1249 |
+
sent = None
|
| 1250 |
+
if meta.get("file_id"):
|
| 1251 |
+
try:
|
| 1252 |
+
sent = await self.client.send_file(chat_id, meta["file_id"], **kw)
|
| 1253 |
+
except Exception as exc: # noqa: BLE001 - a file reference that went stale: the stored copy is used instead
|
| 1254 |
+
log.warning("the start media could not be sent by its file id (%s); sending the stored copy", exc)
|
| 1255 |
+
if sent is None:
|
| 1256 |
+
try:
|
| 1257 |
+
data = base64.b64decode(await self.db.kv_get("start_media_data") or "", validate=True)
|
| 1258 |
+
if not data:
|
| 1259 |
+
return False
|
| 1260 |
+
sent = await self._upload_start_media(chat_id, meta, data, **kw)
|
| 1261 |
+
except Exception as exc: # noqa: BLE001
|
| 1262 |
+
log.warning("could not send the start media (%s); /start shows the menu only", exc)
|
| 1263 |
+
return False
|
| 1264 |
+
fresh = getattr(getattr(sent, "file", None), "id", None)
|
| 1265 |
+
if fresh and fresh != meta.get("file_id"): # the next /start can reuse what was just uploaded
|
| 1266 |
+
await self.db.kv_set("start_media", json.dumps({**meta, "file_id": fresh}))
|
| 1267 |
+
return True
|
| 1268 |
+
|
| 1269 |
+
async def _upload_start_media(self, chat_id: int, meta: Dict[str, Any], data: bytes, **kw: Any) -> Any:
|
| 1270 |
+
bio = io.BytesIO(data)
|
| 1271 |
+
if meta["kind"] == "gif":
|
| 1272 |
+
bio.name = "start.mp4"
|
| 1273 |
+
kw.update(
|
| 1274 |
+
mime_type=meta.get("mime") or "video/mp4",
|
| 1275 |
+
attributes=[
|
| 1276 |
+
types.DocumentAttributeVideo(
|
| 1277 |
+
duration=float(meta.get("duration") or 0), w=int(meta.get("width") or 0), h=int(meta.get("height") or 0),
|
| 1278 |
+
supports_streaming=True,
|
| 1279 |
+
),
|
| 1280 |
+
types.DocumentAttributeAnimated(),
|
| 1281 |
+
],
|
| 1282 |
+
)
|
| 1283 |
+
else:
|
| 1284 |
+
bio.name = "start.jpg"
|
| 1285 |
+
return await self.client.send_file(chat_id, bio, **kw)
|
| 1286 |
+
|
| 1287 |
+
async def cmd_smedia(self, event: Any) -> None:
|
| 1288 |
+
self.pending.pop(event.sender_id, None)
|
| 1289 |
+
meta = await self._start_media_meta()
|
| 1290 |
+
text, rows = panels.smedia_menu(meta["kind"] if meta else "")
|
| 1291 |
+
await self._reply(event, text, rows)
|
| 1292 |
+
|
| 1293 |
+
async def _cb_smedia(self, event: Any, uid: int, parts: List[str]) -> None:
|
| 1294 |
+
if not self.is_owner(uid):
|
| 1295 |
+
await event.answer("Owner only.", alert=True)
|
| 1296 |
+
return
|
| 1297 |
+
self.pending.pop(uid, None)
|
| 1298 |
+
action = parts[1] if len(parts) > 1 else ""
|
| 1299 |
+
if action in ("image", "gif"):
|
| 1300 |
+
mode = self._start_mode(uid, "smedia", ONE_FILE_WAIT, {"kind": action})
|
| 1301 |
+
text, rows = panels.smedia_prompt(action, ONE_FILE_WAIT // 60)
|
| 1302 |
+
await self._edit_panel(event, text + (f"\n\n{mode.dropped}" if mode.dropped else ""), rows)
|
| 1303 |
+
await event.answer()
|
| 1304 |
+
elif action == "menu":
|
| 1305 |
+
if self._mode(uid, "smedia") is not None:
|
| 1306 |
+
self._end_mode(uid)
|
| 1307 |
+
meta = await self._start_media_meta()
|
| 1308 |
+
text, rows = panels.smedia_menu(meta["kind"] if meta else "")
|
| 1309 |
+
await self._edit_panel(event, text, rows)
|
| 1310 |
+
await event.answer()
|
| 1311 |
+
elif action == "rm":
|
| 1312 |
+
await self._store_start_media("", "")
|
| 1313 |
+
text, rows = panels.smedia_menu("")
|
| 1314 |
+
await self._edit_panel(event, text, rows)
|
| 1315 |
+
await event.answer("Start media removed.")
|
| 1316 |
+
elif action == "stop":
|
| 1317 |
+
if self._mode(uid, "smedia") is not None:
|
| 1318 |
+
self._end_mode(uid)
|
| 1319 |
+
await self._edit_panel(event, "⏹ Stopped. Send /smedia again to change the start media.", [[("✖ Close", "x:close")]])
|
| 1320 |
+
await event.answer()
|
| 1321 |
+
else:
|
| 1322 |
+
await event.answer()
|
| 1323 |
+
|
| 1324 |
+
async def _take_start_media(self, event: Any, mode: Mode) -> None:
|
| 1325 |
+
"""The owner sent the picture or GIF /smedia asked for: keep it, then show /start with it."""
|
| 1326 |
+
uid, msg, want = event.sender_id, event.message, mode.data["kind"]
|
| 1327 |
+
is_gif = getattr(msg, "gif", None) is not None
|
| 1328 |
+
if want == "gif" and not is_gif:
|
| 1329 |
+
await self._reply(event, "❌ That isn't a GIF. Send a GIF (an animation), or /cancel to stop.")
|
| 1330 |
+
return
|
| 1331 |
+
if want == "image" and (is_gif or not is_image(msg)):
|
| 1332 |
+
await self._reply(event, "❌ That isn't an image. Send a photo or an image file, or /cancel to stop.")
|
| 1333 |
+
return
|
| 1334 |
+
size = int(msg.file.size or 0) if getattr(msg, "file", None) is not None else 0
|
| 1335 |
+
if size > START_MEDIA_MAX_BYTES:
|
| 1336 |
+
await self._reply(
|
| 1337 |
+
event, f"❌ That's {human_size(size)}; the biggest I can keep is {human_size(START_MEDIA_MAX_BYTES)}. Send a smaller one, or /cancel to stop."
|
| 1338 |
+
)
|
| 1339 |
+
return
|
| 1340 |
+
self._consume(uid, "smedia")
|
| 1341 |
+
raw = await self.client.download_media(msg, file=bytes)
|
| 1342 |
+
if not raw:
|
| 1343 |
+
await self._reply(event, "❌ I couldn't download that. Send /smedia to try again.")
|
| 1344 |
+
return
|
| 1345 |
+
try:
|
| 1346 |
+
if want == "gif":
|
| 1347 |
+
data, meta = raw, {
|
| 1348 |
+
"kind": "gif", "mime": msg.file.mime_type or "video/mp4", "width": int(msg.file.width or 0),
|
| 1349 |
+
"height": int(msg.file.height or 0), "duration": float(msg.file.duration or 0),
|
| 1350 |
+
}
|
| 1351 |
+
elif getattr(msg, "photo", None) is not None: # Telegram already made it a JPEG that fits
|
| 1352 |
+
data, meta = raw, {"kind": "image"}
|
| 1353 |
+
else:
|
| 1354 |
+
data, meta = await asyncio.to_thread(media.fit_photo, raw, START_IMAGE_BOX), {"kind": "image"}
|
| 1355 |
+
except Exception: # noqa: BLE001
|
| 1356 |
+
await self._reply(event, "❌ I couldn't read that image. Send /smedia to try again.")
|
| 1357 |
+
return
|
| 1358 |
+
if len(data) > START_MEDIA_MAX_BYTES:
|
| 1359 |
+
await self._reply(event, f"❌ That's {human_size(len(data))}; the biggest I can keep is {human_size(START_MEDIA_MAX_BYTES)}. Send /smedia to try another.")
|
| 1360 |
+
return
|
| 1361 |
+
before = (await self.db.kv_get("start_media") or "", await self.db.kv_get("start_media_data") or "")
|
| 1362 |
+
await self._store_start_media(json.dumps({**meta, "file_id": ""}), base64.b64encode(data).decode("ascii"))
|
| 1363 |
+
text, rows = await self.panel(uid, "home")
|
| 1364 |
+
if not await self._send_start_media(event.chat_id, text, rows): # the preview is the real /start: if it fails, so would /start
|
| 1365 |
+
await self._store_start_media(*before)
|
| 1366 |
+
await self._reply(event, "❌ Telegram wouldn't take that as the start media, so nothing was changed. Send /smedia to try another.")
|
| 1367 |
+
return
|
| 1368 |
+
await self._reply(event, f"✅ Done: /start shows this {'image' if want == 'image' else 'GIF'} from now on.")
|
| 1369 |
+
|
| 1370 |
+
# ------------------------------------------------------------------- speed
|
| 1371 |
+
async def cmd_speed(self, event: Any) -> None:
|
| 1372 |
+
if self._speed_lock.locked():
|
| 1373 |
+
await self._reply(event, "⏳ A speed test is already running. Try again in a minute.")
|
| 1374 |
+
return
|
| 1375 |
+
async with self._speed_lock:
|
| 1376 |
+
busy = self.queue.running > 0
|
| 1377 |
+
status = await self._reply(event, "⚡ Running the speed test… this takes about half a minute.")
|
| 1378 |
+
try:
|
| 1379 |
+
text = speed.format_result(await speed.run_speedtest())
|
| 1380 |
+
if busy:
|
| 1381 |
+
text += "\nℹ️ A rename job was running during the test, so the numbers may be lower than usual."
|
| 1382 |
+
except speed.SpeedError as exc:
|
| 1383 |
+
text = f"❌ I couldn't run the speed test: {esc(exc)}"
|
| 1384 |
+
except Exception: # noqa: BLE001
|
| 1385 |
+
log.exception("speed test failed")
|
| 1386 |
+
text = "❌ The speed test failed unexpectedly."
|
| 1387 |
+
await self.client.edit_message(status, text, parse_mode="html", link_preview=False)
|
| 1388 |
|
| 1389 |
# ----------------------------------------------------------- status/stats
|
| 1390 |
async def cmd_status(self, event: Any) -> None:
|
|
|
|
| 1443 |
if p is None or p.expires < time.time():
|
| 1444 |
self.pending.pop(uid, None)
|
| 1445 |
return
|
| 1446 |
+
if p.kind.startswith("fmt:"):
|
| 1447 |
+
await self._on_format_text(event, p)
|
| 1448 |
+
return
|
| 1449 |
+
if p.kind == "resize:size":
|
| 1450 |
+
await self._on_resize_text(event, p)
|
| 1451 |
+
return
|
| 1452 |
ok, msg = await apply_setting(self.db, uid, p.kind, event.raw_text)
|
| 1453 |
if not ok:
|
| 1454 |
await self._reply(event, msg + "\n\nTry again, or tap Cancel.")
|
|
|
|
| 1493 |
await self._cb_set(event, uid, parts[1], parts[2])
|
| 1494 |
return
|
| 1495 |
|
| 1496 |
+
if head == "fm":
|
| 1497 |
+
await self._cb_format(event, uid, parts)
|
| 1498 |
+
return
|
| 1499 |
+
|
| 1500 |
+
if head == "rn":
|
| 1501 |
+
await self._cb_rename(event, uid, parts)
|
| 1502 |
+
return
|
| 1503 |
+
|
| 1504 |
+
if head == "sq":
|
| 1505 |
+
await self._cb_sequence(event, uid, parts)
|
| 1506 |
+
return
|
| 1507 |
+
|
| 1508 |
+
if head == "rz":
|
| 1509 |
+
await self._cb_resize(event, uid, parts)
|
| 1510 |
+
return
|
| 1511 |
+
|
| 1512 |
+
if head == "sm":
|
| 1513 |
+
await self._cb_smedia(event, uid, parts)
|
| 1514 |
+
return
|
| 1515 |
+
|
| 1516 |
+
if data == "et:stop":
|
| 1517 |
+
if self._mode(uid, "ethumb") is not None:
|
| 1518 |
+
self._end_mode(uid)
|
| 1519 |
+
await self._edit_panel(event, "⏹ Stopped. Send /ethumb again to extract a thumbnail.", [[("✖ Close", "x:close")]])
|
| 1520 |
+
await event.answer()
|
| 1521 |
+
return
|
| 1522 |
+
|
| 1523 |
if head == "in":
|
| 1524 |
await self._cb_input(event, uid, parts)
|
| 1525 |
return
|
|
|
|
| 1622 |
msg_obj = await event.get_message()
|
| 1623 |
self.pending[uid] = Pending(
|
| 1624 |
kind=kind, chat_id=event.chat_id, msg_id=event.message_id, back=BACK[kind],
|
| 1625 |
+
is_photo=bool(msg_obj is not None and (msg_obj.photo is not None or getattr(msg_obj, "media", None) is not None)),
|
| 1626 |
+
expires=time.time() + self.PENDING_TTL,
|
| 1627 |
)
|
| 1628 |
text, rows = panels.input_prompt(kind, BACK[kind])
|
| 1629 |
await self._edit_panel(event, text, rows)
|
| 1630 |
await event.answer()
|
| 1631 |
+
|
| 1632 |
+
# ------------------------------------------------------------ saved formats
|
| 1633 |
+
async def _show_format(self, event: Any, uid: int, fid: int, note: str = "") -> bool:
|
| 1634 |
+
fmt = await self.db.get_format(uid, fid)
|
| 1635 |
+
if fmt is None:
|
| 1636 |
+
return False
|
| 1637 |
+
text, rows = panels.format_view(fmt)
|
| 1638 |
+
await self._edit_panel(event, f"{note}\n\n{text}" if note else text, rows)
|
| 1639 |
+
return True
|
| 1640 |
+
|
| 1641 |
+
async def _cb_format(self, event: Any, uid: int, parts: List[str]) -> None:
|
| 1642 |
+
self.pending.pop(uid, None) # leaving a prompt open by tapping something else abandons it
|
| 1643 |
+
action = parts[1] if len(parts) > 1 else ""
|
| 1644 |
+
fid = int(parts[2]) if len(parts) > 2 and parts[2].isdigit() else 0
|
| 1645 |
+
|
| 1646 |
+
if action == "new":
|
| 1647 |
+
if await self.db.count_formats(uid) >= MAX_FORMATS:
|
| 1648 |
+
await event.answer(f"You already have {MAX_FORMATS} formats. Delete one first.", alert=True)
|
| 1649 |
+
return
|
| 1650 |
+
await self._format_prompt(event, uid, "new", 0)
|
| 1651 |
+
return
|
| 1652 |
+
|
| 1653 |
+
fmt = await self.db.get_format(uid, fid) if fid else None
|
| 1654 |
+
if fmt is None:
|
| 1655 |
+
text, rows = await self.panel(uid, "formats")
|
| 1656 |
+
await self._edit_panel(event, text, rows)
|
| 1657 |
+
await event.answer("That format no longer exists.", alert=True)
|
| 1658 |
+
return
|
| 1659 |
+
|
| 1660 |
+
if action in ("v", "cx"):
|
| 1661 |
+
await self._show_format(event, uid, fid)
|
| 1662 |
+
await event.answer()
|
| 1663 |
+
elif action == "in" and len(parts) == 4 and parts[3] in (*FIELD_KINDS, "name"):
|
| 1664 |
+
await self._format_prompt(event, uid, parts[3], fid)
|
| 1665 |
+
elif action == "th":
|
| 1666 |
+
await self._format_prompt(event, uid, "thumb", fid)
|
| 1667 |
+
elif action == "thd":
|
| 1668 |
+
await self.db.set_format_thumb(uid, fid, None)
|
| 1669 |
+
await self._show_format(event, uid, fid)
|
| 1670 |
+
await event.answer("Thumbnail deleted.")
|
| 1671 |
+
elif action == "del":
|
| 1672 |
+
text, rows = panels.format_delete_confirm(fmt)
|
| 1673 |
+
await self._edit_panel(event, text, rows)
|
| 1674 |
+
await event.answer()
|
| 1675 |
+
elif action == "ok":
|
| 1676 |
+
await self.db.delete_format(uid, fid)
|
| 1677 |
+
text, rows = await self.panel(uid, "formats")
|
| 1678 |
+
await self._edit_panel(event, text, rows)
|
| 1679 |
+
await event.answer(f"Deleted {clip(fmt['name'], 40)}.")
|
| 1680 |
+
else:
|
| 1681 |
+
await event.answer()
|
| 1682 |
+
|
| 1683 |
+
async def _format_prompt(self, event: Any, uid: int, field: str, fid: int) -> None:
|
| 1684 |
+
msg_obj = await event.get_message()
|
| 1685 |
+
self.pending[uid] = Pending(
|
| 1686 |
+
kind=f"fmt:{field}", chat_id=event.chat_id, msg_id=event.message_id, back="formats",
|
| 1687 |
+
is_photo=bool(msg_obj is not None and (msg_obj.photo is not None or getattr(msg_obj, "media", None) is not None)),
|
| 1688 |
+
expires=time.time() + self.PENDING_TTL, fid=fid,
|
| 1689 |
+
)
|
| 1690 |
+
text, rows = panels.format_prompt(field, fid)
|
| 1691 |
+
await self._edit_panel(event, text, rows)
|
| 1692 |
+
await event.answer()
|
| 1693 |
+
|
| 1694 |
+
async def _on_format_text(self, event: Any, p: Pending) -> None:
|
| 1695 |
+
uid = event.sender_id
|
| 1696 |
+
field = p.kind[4:]
|
| 1697 |
+
text = event.raw_text or ""
|
| 1698 |
+
if field == "thumb":
|
| 1699 |
+
await self._reply(event, "Send a photo for the thumbnail, or tap Cancel.")
|
| 1700 |
+
return
|
| 1701 |
+
try:
|
| 1702 |
+
if field in ("new", "name"):
|
| 1703 |
+
ok, value = validate_name(text)
|
| 1704 |
+
if ok and field == "new":
|
| 1705 |
+
fmt = await self.db.create_format(uid, value, template=(await self.db.get_settings(uid))["template"])
|
| 1706 |
+
msg = f"✅ Format <b>{esc(value)}</b> created. Now set its template, thumbnail and metadata."
|
| 1707 |
+
p.fid = fmt["id"]
|
| 1708 |
+
elif ok:
|
| 1709 |
+
if await self.db.update_format(uid, p.fid, name=value) is None:
|
| 1710 |
+
raise LookupError
|
| 1711 |
+
msg = f"✅ Renamed to <b>{esc(value)}</b>."
|
| 1712 |
+
else:
|
| 1713 |
+
msg = value
|
| 1714 |
+
else:
|
| 1715 |
+
ok, msg = await apply_setting(FormatSettings(self.db, p.fid), uid, FIELD_KINDS[field], text)
|
| 1716 |
+
except FormatError as exc:
|
| 1717 |
+
ok = False
|
| 1718 |
+
if exc.code == "limit":
|
| 1719 |
+
self.pending.pop(uid, None)
|
| 1720 |
+
await self._reply(event, f"❌ You already have {MAX_FORMATS} formats. Delete one first.")
|
| 1721 |
+
return
|
| 1722 |
+
msg = "❌ You already have a format with that name."
|
| 1723 |
+
except LookupError:
|
| 1724 |
+
self.pending.pop(uid, None)
|
| 1725 |
+
await self._reply(event, "⚠️ That format no longer exists.")
|
| 1726 |
+
return
|
| 1727 |
+
if not ok:
|
| 1728 |
+
await self._reply(event, msg + "\n\nTry again, or tap Cancel.")
|
| 1729 |
+
return
|
| 1730 |
+
self.pending.pop(uid, None)
|
| 1731 |
+
try:
|
| 1732 |
+
await event.delete()
|
| 1733 |
+
except Exception: # noqa: BLE001
|
| 1734 |
+
pass
|
| 1735 |
+
await self._refresh_format_panel(event, p, msg)
|
| 1736 |
+
|
| 1737 |
+
async def _format_thumb_saved(self, event: Any, p: Pending, thumb: bytes) -> None:
|
| 1738 |
+
uid = event.sender_id
|
| 1739 |
+
self.pending.pop(uid, None)
|
| 1740 |
+
if not await self.db.set_format_thumb(uid, p.fid, thumb):
|
| 1741 |
+
await self._reply(event, "⚠️ That format no longer exists.")
|
| 1742 |
+
return
|
| 1743 |
+
await self._refresh_format_panel(event, p, "✅ Thumbnail saved for this format.")
|
| 1744 |
+
|
| 1745 |
+
async def _refresh_format_panel(self, event: Any, p: Pending, msg: str) -> None:
|
| 1746 |
+
"""Put the format's panel back into the message the prompt was shown in (a new message if that fails)."""
|
| 1747 |
+
fmt = await self.db.get_format(event.sender_id, p.fid)
|
| 1748 |
+
if fmt is None:
|
| 1749 |
+
await self._reply(event, msg)
|
| 1750 |
+
return
|
| 1751 |
+
text, rows = panels.format_view(fmt)
|
| 1752 |
+
limit = 1000 if p.is_photo else 3900
|
| 1753 |
+
full = f"{msg}\n\n{text}" if visible_len(msg) + visible_len(text) < limit else f"{msg}\n\n(Open the format to see the update.)"
|
| 1754 |
+
try:
|
| 1755 |
+
await self.client.edit_message(p.chat_id, p.msg_id, full, buttons=panels.to_buttons(rows), parse_mode="html")
|
| 1756 |
+
except Exception: # noqa: BLE001
|
| 1757 |
+
await self._reply(event, full, rows)
|
| 1758 |
+
|
| 1759 |
+
# ---------------------------------------------------------- /rename picker
|
| 1760 |
+
async def _cb_rename(self, event: Any, uid: int, parts: List[str]) -> None:
|
| 1761 |
+
self.pending.pop(uid, None)
|
| 1762 |
+
action = parts[1] if len(parts) > 1 else ""
|
| 1763 |
+
if action == "p" and len(parts) == 3 and parts[2].isdigit():
|
| 1764 |
+
fmt = await self.db.get_format(uid, int(parts[2]))
|
| 1765 |
+
if fmt is None:
|
| 1766 |
+
text, rows = panels.rename_picker(await self.db.list_formats(uid))
|
| 1767 |
+
await self._edit_panel(event, text, rows)
|
| 1768 |
+
await event.answer("That format no longer exists.", alert=True)
|
| 1769 |
+
return
|
| 1770 |
+
mode = self._start_mode(uid, "rename", ONE_FILE_WAIT, {"fid": fmt["id"]})
|
| 1771 |
+
text, rows = panels.rename_ready(fmt, ONE_FILE_WAIT // 60)
|
| 1772 |
+
await self._edit_panel(event, text + (f"\n\n{mode.dropped}" if mode.dropped else ""), rows)
|
| 1773 |
+
await event.answer(f"Format: {clip(fmt['name'], 40)}")
|
| 1774 |
+
elif action == "menu":
|
| 1775 |
+
if self._mode(uid, "rename") is not None:
|
| 1776 |
+
self._end_mode(uid)
|
| 1777 |
+
text, rows = panels.rename_picker(await self.db.list_formats(uid))
|
| 1778 |
+
await self._edit_panel(event, text, rows)
|
| 1779 |
+
await event.answer()
|
| 1780 |
+
elif action == "stop":
|
| 1781 |
+
if self._mode(uid, "rename") is not None:
|
| 1782 |
+
self._end_mode(uid)
|
| 1783 |
+
await self._edit_panel(event, "⏹ Stopped. Send /rename again to pick a format.", [[("✖ Close", "x:close")]])
|
| 1784 |
+
await event.answer()
|
| 1785 |
+
else:
|
| 1786 |
+
await event.answer()
|
| 1787 |
+
|
| 1788 |
+
# ------------------------------------------------------------ /sequence buttons
|
| 1789 |
+
async def _cb_sequence(self, event: Any, uid: int, parts: List[str]) -> None:
|
| 1790 |
+
action = parts[1] if len(parts) > 1 else ""
|
| 1791 |
+
mode = self._mode(uid, "sequence")
|
| 1792 |
+
if mode is None:
|
| 1793 |
+
await event.answer("That sequence is over.", alert=True)
|
| 1794 |
+
return
|
| 1795 |
+
if action == "sort":
|
| 1796 |
+
await event.answer()
|
| 1797 |
+
await self._run_sort(uid, event.chat_id)
|
| 1798 |
+
elif action == "cx":
|
| 1799 |
+
self._end_mode(uid)
|
| 1800 |
+
await event.answer("Cancelled.")
|
| 1801 |
+
await self._edit_panel(event, self._mode_cancelled_text(mode), [])
|
| 1802 |
+
else:
|
| 1803 |
+
await event.answer()
|
bot/config.py
CHANGED
|
@@ -50,7 +50,6 @@ class Config:
|
|
| 50 |
database_url: str
|
| 51 |
session_string: str
|
| 52 |
log_channel: Optional[int]
|
| 53 |
-
start_image: Optional[str]
|
| 54 |
port: int
|
| 55 |
temp_dir: str
|
| 56 |
max_jobs: int
|
|
@@ -91,7 +90,6 @@ def load_config(env: Optional[Mapping[str, str]] = None) -> Config:
|
|
| 91 |
database_url=env.get("DATABASE_URL", "").strip() or "sqlite+aiosqlite:///bot.db",
|
| 92 |
session_string=env.get("SESSION_STRING", "").strip(),
|
| 93 |
log_channel=log_channel,
|
| 94 |
-
start_image=env.get("START_IMAGE", "").strip() or None,
|
| 95 |
port=_get_int(env, "PORT", 7860),
|
| 96 |
temp_dir=env.get("TEMP_DIR", "").strip() or "/tmp/rename",
|
| 97 |
max_jobs=max_jobs,
|
|
|
|
| 50 |
database_url: str
|
| 51 |
session_string: str
|
| 52 |
log_channel: Optional[int]
|
|
|
|
| 53 |
port: int
|
| 54 |
temp_dir: str
|
| 55 |
max_jobs: int
|
|
|
|
| 90 |
database_url=env.get("DATABASE_URL", "").strip() or "sqlite+aiosqlite:///bot.db",
|
| 91 |
session_string=env.get("SESSION_STRING", "").strip(),
|
| 92 |
log_channel=log_channel,
|
|
|
|
| 93 |
port=_get_int(env, "PORT", 7860),
|
| 94 |
temp_dir=env.get("TEMP_DIR", "").strip() or "/tmp/rename",
|
| 95 |
max_jobs=max_jobs,
|
bot/db.py
CHANGED
|
@@ -18,6 +18,7 @@ from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
|
| 18 |
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine
|
| 19 |
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
| 20 |
|
|
|
|
| 21 |
from .parser import DEFAULT_TEMPLATE
|
| 22 |
|
| 23 |
DEFAULT_SETTINGS: Dict[str, Any] = {
|
|
@@ -34,6 +35,16 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
|
| 34 |
}
|
| 35 |
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
def _utcnow() -> datetime:
|
| 38 |
return datetime.now(timezone.utc)
|
| 39 |
|
|
@@ -61,6 +72,17 @@ class Admin(Base):
|
|
| 61 |
added_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
|
| 62 |
|
| 63 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
class KV(Base):
|
| 65 |
__tablename__ = "kv"
|
| 66 |
|
|
@@ -96,6 +118,28 @@ def _merge_settings(stored: Dict[str, Any]) -> Dict[str, Any]:
|
|
| 96 |
return merged
|
| 97 |
|
| 98 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
class Database:
|
| 100 |
def __init__(self, url: str):
|
| 101 |
self.url, connect_args = normalize_database_url(url)
|
|
@@ -191,6 +235,93 @@ class Database:
|
|
| 191 |
).one()
|
| 192 |
return int(row[0]), int(row[1])
|
| 193 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
# ----- admins ------------------------------------------------------------------
|
| 195 |
async def list_admins(self) -> List[int]:
|
| 196 |
async with self._sessions() as session:
|
|
|
|
| 18 |
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine
|
| 19 |
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
| 20 |
|
| 21 |
+
from .errors import FormatError
|
| 22 |
from .parser import DEFAULT_TEMPLATE
|
| 23 |
|
| 24 |
DEFAULT_SETTINGS: Dict[str, Any] = {
|
|
|
|
| 35 |
}
|
| 36 |
|
| 37 |
|
| 38 |
+
MAX_FORMATS = 12
|
| 39 |
+
# what a saved /rename format holds; everything else (mode, caption, file type, ...) comes from the user's settings
|
| 40 |
+
DEFAULT_FORMAT: Dict[str, Any] = {
|
| 41 |
+
"template": DEFAULT_TEMPLATE,
|
| 42 |
+
"meta": {"title": "", "author": "", "artist": "", "audio": "", "subtitle": "", "video": ""},
|
| 43 |
+
"keep_audio": [],
|
| 44 |
+
"keep_subtitle": [],
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
|
| 48 |
def _utcnow() -> datetime:
|
| 49 |
return datetime.now(timezone.utc)
|
| 50 |
|
|
|
|
| 72 |
added_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
|
| 73 |
|
| 74 |
|
| 75 |
+
class Format(Base):
|
| 76 |
+
__tablename__ = "formats"
|
| 77 |
+
|
| 78 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
| 79 |
+
user_id: Mapped[int] = mapped_column(BigInteger, index=True)
|
| 80 |
+
name: Mapped[str] = mapped_column(String(64))
|
| 81 |
+
data: Mapped[str] = mapped_column(Text, default="{}")
|
| 82 |
+
thumb: Mapped[Optional[bytes]] = mapped_column(LargeBinary, nullable=True)
|
| 83 |
+
created: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
class KV(Base):
|
| 87 |
__tablename__ = "kv"
|
| 88 |
|
|
|
|
| 118 |
return merged
|
| 119 |
|
| 120 |
|
| 121 |
+
def _merge_format(stored: Dict[str, Any]) -> Dict[str, Any]:
|
| 122 |
+
merged = copy.deepcopy(DEFAULT_FORMAT)
|
| 123 |
+
if isinstance(stored.get("template"), str) and stored["template"]:
|
| 124 |
+
merged["template"] = stored["template"]
|
| 125 |
+
if isinstance(stored.get("meta"), dict):
|
| 126 |
+
merged["meta"].update({k: str(v) for k, v in stored["meta"].items() if k in merged["meta"]})
|
| 127 |
+
for key in ("keep_audio", "keep_subtitle"):
|
| 128 |
+
if isinstance(stored.get(key), list):
|
| 129 |
+
merged[key] = [str(t) for t in stored[key]]
|
| 130 |
+
return merged
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def _format_dict(fid: int, name: str, raw: Optional[str], has_thumb: bool) -> Dict[str, Any]:
|
| 134 |
+
try:
|
| 135 |
+
stored = json.loads(raw or "{}")
|
| 136 |
+
except ValueError:
|
| 137 |
+
stored = {}
|
| 138 |
+
out = _merge_format(stored if isinstance(stored, dict) else {})
|
| 139 |
+
out.update(id=int(fid), name=name, has_thumb=bool(has_thumb))
|
| 140 |
+
return out
|
| 141 |
+
|
| 142 |
+
|
| 143 |
class Database:
|
| 144 |
def __init__(self, url: str):
|
| 145 |
self.url, connect_args = normalize_database_url(url)
|
|
|
|
| 235 |
).one()
|
| 236 |
return int(row[0]), int(row[1])
|
| 237 |
|
| 238 |
+
# ----- saved /rename formats ------------------------------------------------------
|
| 239 |
+
async def list_formats(self, uid: int) -> List[Dict[str, Any]]:
|
| 240 |
+
async with self._sessions() as session:
|
| 241 |
+
result = await session.execute(
|
| 242 |
+
select(Format.id, Format.name, Format.data, Format.thumb.isnot(None)).where(Format.user_id == uid).order_by(Format.id)
|
| 243 |
+
)
|
| 244 |
+
return [_format_dict(r[0], r[1], r[2], r[3]) for r in result.all()]
|
| 245 |
+
|
| 246 |
+
async def count_formats(self, uid: int) -> int:
|
| 247 |
+
async with self._sessions() as session:
|
| 248 |
+
return int((await session.execute(select(func.count()).select_from(Format).where(Format.user_id == uid))).scalar() or 0)
|
| 249 |
+
|
| 250 |
+
async def get_format(self, uid: int, fid: int) -> Optional[Dict[str, Any]]:
|
| 251 |
+
async with self._sessions() as session:
|
| 252 |
+
result = await session.execute(
|
| 253 |
+
select(Format.id, Format.name, Format.data, Format.thumb.isnot(None)).where(Format.id == fid, Format.user_id == uid)
|
| 254 |
+
)
|
| 255 |
+
r = result.first()
|
| 256 |
+
return _format_dict(r[0], r[1], r[2], r[3]) if r else None
|
| 257 |
+
|
| 258 |
+
async def create_format(self, uid: int, name: str, template: Optional[str] = None) -> Dict[str, Any]:
|
| 259 |
+
"""Add a format; raises ``FormatError`` when the user already has ``MAX_FORMATS`` or the name is taken."""
|
| 260 |
+
async with self._sessions() as session:
|
| 261 |
+
names = list((await session.execute(select(Format.name).where(Format.user_id == uid))).scalars())
|
| 262 |
+
if len(names) >= MAX_FORMATS:
|
| 263 |
+
raise FormatError("limit")
|
| 264 |
+
if name.casefold() in {n.casefold() for n in names}:
|
| 265 |
+
raise FormatError("taken")
|
| 266 |
+
data = copy.deepcopy(DEFAULT_FORMAT)
|
| 267 |
+
if template:
|
| 268 |
+
data["template"] = template
|
| 269 |
+
row = Format(user_id=uid, name=name, data=json.dumps(data, ensure_ascii=False))
|
| 270 |
+
session.add(row)
|
| 271 |
+
await session.flush()
|
| 272 |
+
fid = row.id
|
| 273 |
+
await session.commit()
|
| 274 |
+
return _format_dict(fid, name, json.dumps(data, ensure_ascii=False), False)
|
| 275 |
+
|
| 276 |
+
async def update_format(self, uid: int, fid: int, **changes: Any) -> Optional[Dict[str, Any]]:
|
| 277 |
+
"""Change ``name``, ``template``, ``keep_audio``, ``keep_subtitle`` or merge a ``meta`` dict; ``None`` if it is gone."""
|
| 278 |
+
async with self._sessions() as session:
|
| 279 |
+
row = await session.get(Format, fid)
|
| 280 |
+
if row is None or row.user_id != uid:
|
| 281 |
+
return None
|
| 282 |
+
current = _format_dict(row.id, row.name, row.data, False)
|
| 283 |
+
for key, value in changes.items():
|
| 284 |
+
if key == "name":
|
| 285 |
+
others = (
|
| 286 |
+
await session.execute(select(Format.name).where(Format.user_id == uid, Format.id != fid))
|
| 287 |
+
).scalars()
|
| 288 |
+
if value.casefold() in {n.casefold() for n in others}:
|
| 289 |
+
raise FormatError("taken")
|
| 290 |
+
row.name = value
|
| 291 |
+
elif key == "meta":
|
| 292 |
+
current["meta"].update(value)
|
| 293 |
+
elif key in DEFAULT_FORMAT:
|
| 294 |
+
current[key] = value
|
| 295 |
+
else:
|
| 296 |
+
raise KeyError(key)
|
| 297 |
+
row.data = json.dumps({k: current[k] for k in DEFAULT_FORMAT}, ensure_ascii=False)
|
| 298 |
+
has_thumb = row.thumb is not None
|
| 299 |
+
await session.commit()
|
| 300 |
+
return _format_dict(row.id, row.name, row.data, has_thumb)
|
| 301 |
+
|
| 302 |
+
async def set_format_thumb(self, uid: int, fid: int, data: Optional[bytes]) -> bool:
|
| 303 |
+
async with self._sessions() as session:
|
| 304 |
+
row = await session.get(Format, fid)
|
| 305 |
+
if row is None or row.user_id != uid:
|
| 306 |
+
return False
|
| 307 |
+
row.thumb = data
|
| 308 |
+
await session.commit()
|
| 309 |
+
return True
|
| 310 |
+
|
| 311 |
+
async def get_format_thumb(self, uid: int, fid: int) -> Optional[bytes]:
|
| 312 |
+
async with self._sessions() as session:
|
| 313 |
+
row = await session.get(Format, fid)
|
| 314 |
+
return bytes(row.thumb) if row is not None and row.user_id == uid and row.thumb else None
|
| 315 |
+
|
| 316 |
+
async def delete_format(self, uid: int, fid: int) -> bool:
|
| 317 |
+
async with self._sessions() as session:
|
| 318 |
+
row = await session.get(Format, fid)
|
| 319 |
+
if row is None or row.user_id != uid:
|
| 320 |
+
return False
|
| 321 |
+
await session.delete(row)
|
| 322 |
+
await session.commit()
|
| 323 |
+
return True
|
| 324 |
+
|
| 325 |
# ----- admins ------------------------------------------------------------------
|
| 326 |
async def list_admins(self) -> List[int]:
|
| 327 |
async with self._sessions() as session:
|
bot/errors.py
CHANGED
|
@@ -4,3 +4,11 @@ class Cancelled(Exception):
|
|
| 4 |
|
| 5 |
class MediaError(RuntimeError):
|
| 6 |
"""ffprobe / ffmpeg failed."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
class MediaError(RuntimeError):
|
| 6 |
"""ffprobe / ffmpeg failed."""
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class FormatError(Exception):
|
| 10 |
+
"""A saved-format operation was refused; ``code`` is ``limit`` (12 formats already) or ``taken`` (name in use)."""
|
| 11 |
+
|
| 12 |
+
def __init__(self, code: str):
|
| 13 |
+
super().__init__(code)
|
| 14 |
+
self.code = code
|
bot/formats.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Saved /rename formats: the pieces shared by the menus, the handlers and the pipeline.
|
| 2 |
+
|
| 3 |
+
A format is a named bundle of a naming template, a thumbnail, the six metadata titles and the Keep Audio /
|
| 4 |
+
Keep Subtitle lists. Every other setting (mode, caption template, file type, remove words, target channel)
|
| 5 |
+
still comes from the user's normal auto-rename settings.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import copy
|
| 10 |
+
from typing import Any, Dict, Optional, Tuple
|
| 11 |
+
|
| 12 |
+
from .db import DEFAULT_SETTINGS, Database
|
| 13 |
+
|
| 14 |
+
NAME_MAX = 30
|
| 15 |
+
# format field -> the kind settings_ops.apply_setting() understands
|
| 16 |
+
FIELD_KINDS: Dict[str, str] = {
|
| 17 |
+
"template": "template",
|
| 18 |
+
"title": "mt_title", "author": "mt_author", "artist": "mt_artist",
|
| 19 |
+
"audio": "mt_audio", "subtitle": "mt_subtitle", "video": "mt_video",
|
| 20 |
+
"keep_audio": "keep_audio", "keep_subtitle": "keep_subtitle",
|
| 21 |
+
}
|
| 22 |
+
_EDITABLE = ("template", "meta", "keep_audio", "keep_subtitle")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def validate_name(text: Optional[str]) -> Tuple[bool, str]:
|
| 26 |
+
"""``(True, cleaned name)`` or ``(False, html error)``."""
|
| 27 |
+
name = " ".join((text or "").split())
|
| 28 |
+
if not name:
|
| 29 |
+
return False, "❌ Send a name for the format."
|
| 30 |
+
if len(name) > NAME_MAX:
|
| 31 |
+
return False, f"❌ That name is too long ({NAME_MAX} characters max)."
|
| 32 |
+
return True, name
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def has_metadata(fmt: Dict[str, Any]) -> bool:
|
| 36 |
+
return any(fmt["meta"].values()) or bool(fmt["keep_audio"] or fmt["keep_subtitle"])
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def apply_format(settings: Dict[str, Any], fmt: Dict[str, Any]) -> Dict[str, Any]:
|
| 40 |
+
"""The user's settings with the format's template, metadata and keep lists laid over them.
|
| 41 |
+
|
| 42 |
+
Metadata is on whenever the format sets anything, whatever the global Metadata switch says: the format
|
| 43 |
+
carries its own metadata, and a format with none leaves the file alone (no ffmpeg pass).
|
| 44 |
+
"""
|
| 45 |
+
out = copy.deepcopy(settings)
|
| 46 |
+
out["template"] = fmt["template"]
|
| 47 |
+
out["meta"] = dict(fmt["meta"])
|
| 48 |
+
out["keep_audio"] = list(fmt["keep_audio"])
|
| 49 |
+
out["keep_subtitle"] = list(fmt["keep_subtitle"])
|
| 50 |
+
out["metadata_on"] = has_metadata(fmt)
|
| 51 |
+
return out
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class FormatSettings:
|
| 55 |
+
"""Edits one saved format through the two methods ``apply_setting`` uses on ``Database``.
|
| 56 |
+
|
| 57 |
+
That keeps a single copy of every validation rule and message for the global settings and the formats.
|
| 58 |
+
Metadata is always "on" here, so the "metadata is off" notes never show for a format.
|
| 59 |
+
"""
|
| 60 |
+
|
| 61 |
+
def __init__(self, db: Database, fid: int):
|
| 62 |
+
self.db = db
|
| 63 |
+
self.fid = fid
|
| 64 |
+
|
| 65 |
+
async def get_settings(self, uid: int) -> Dict[str, Any]:
|
| 66 |
+
fmt = await self.db.get_format(uid, self.fid)
|
| 67 |
+
if fmt is None:
|
| 68 |
+
raise LookupError("that format no longer exists")
|
| 69 |
+
out = copy.deepcopy(DEFAULT_SETTINGS)
|
| 70 |
+
out.update(template=fmt["template"], keep_audio=list(fmt["keep_audio"]), keep_subtitle=list(fmt["keep_subtitle"]), metadata_on=True)
|
| 71 |
+
out["meta"] = dict(fmt["meta"])
|
| 72 |
+
return out
|
| 73 |
+
|
| 74 |
+
async def update_settings(self, uid: int, **changes: Any) -> Dict[str, Any]:
|
| 75 |
+
wanted = {k: v for k, v in changes.items() if k in _EDITABLE}
|
| 76 |
+
if wanted and await self.db.update_format(uid, self.fid, **wanted) is None:
|
| 77 |
+
raise LookupError("that format no longer exists")
|
| 78 |
+
return await self.get_settings(uid)
|
bot/main.py
CHANGED
|
@@ -15,13 +15,15 @@ from .db import Database
|
|
| 15 |
|
| 16 |
log = logging.getLogger("bot")
|
| 17 |
|
|
|
|
|
|
|
| 18 |
|
| 19 |
def clean_temp_dir(path: str) -> None:
|
| 20 |
"""Remove leftovers of a crashed run; only touches our own job folders."""
|
| 21 |
root = Path(path)
|
| 22 |
root.mkdir(parents=True, exist_ok=True)
|
| 23 |
for child in root.iterdir():
|
| 24 |
-
if child.is_dir() and
|
| 25 |
shutil.rmtree(child, ignore_errors=True)
|
| 26 |
|
| 27 |
|
|
@@ -63,6 +65,8 @@ async def amain() -> int:
|
|
| 63 |
client = await build_client(cfg, db)
|
| 64 |
me = await client.get_me()
|
| 65 |
app = App(client, cfg, db)
|
|
|
|
|
|
|
| 66 |
await app.setup()
|
| 67 |
log.info("@%s is online (DC %s, %d connection(s) per transfer, fast transfer %s)",
|
| 68 |
me.username, client.session.dc_id, cfg.connections, "on" if cfg.fast_transfer else "off")
|
|
|
|
| 15 |
|
| 16 |
log = logging.getLogger("bot")
|
| 17 |
|
| 18 |
+
TEMP_PREFIXES = ("job", "tracks", "ethumb", "minfo") # the folders the bot makes in TEMP_DIR
|
| 19 |
+
|
| 20 |
|
| 21 |
def clean_temp_dir(path: str) -> None:
|
| 22 |
"""Remove leftovers of a crashed run; only touches our own job folders."""
|
| 23 |
root = Path(path)
|
| 24 |
root.mkdir(parents=True, exist_ok=True)
|
| 25 |
for child in root.iterdir():
|
| 26 |
+
if child.is_dir() and child.name.startswith(TEMP_PREFIXES):
|
| 27 |
shutil.rmtree(child, ignore_errors=True)
|
| 28 |
|
| 29 |
|
|
|
|
| 65 |
client = await build_client(cfg, db)
|
| 66 |
me = await client.get_me()
|
| 67 |
app = App(client, cfg, db)
|
| 68 |
+
username = getattr(me, "username", "") or ""
|
| 69 |
+
app.telegraph.set_author(getattr(me, "first_name", "") or username, f"https://t.me/{username}" if username else "")
|
| 70 |
await app.setup()
|
| 71 |
log.info("@%s is online (DC %s, %d connection(s) per transfer, fast transfer %s)",
|
| 72 |
me.username, client.session.dc_id, cfg.connections, "on" if cfg.fast_transfer else "off")
|
bot/media.py
CHANGED
|
@@ -208,6 +208,22 @@ async def probe(path: Path | str, cancel: Optional[asyncio.Event] = None) -> Med
|
|
| 208 |
return parse_probe(data)
|
| 209 |
|
| 210 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
# ---------------------------------------------------------------------------
|
| 212 |
# track selection
|
| 213 |
# ---------------------------------------------------------------------------
|
|
@@ -391,6 +407,95 @@ async def make_video_thumbnail(src: Path, dst: Path, duration: float, cancel: Op
|
|
| 391 |
return code == 0 and dst.exists() and dst.stat().st_size > 0
|
| 392 |
|
| 393 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 394 |
def prepare_thumb(data: bytes) -> bytes:
|
| 395 |
"""Resize/re-encode an image to Telegram's thumbnail limits (<=320px, <=200 KB JPEG)."""
|
| 396 |
from PIL import Image
|
|
|
|
| 208 |
return parse_probe(data)
|
| 209 |
|
| 210 |
|
| 211 |
+
async def probe_raw(path: Path | str, cancel: Optional[asyncio.Event] = None) -> Dict[str, Any]:
|
| 212 |
+
"""ffprobe's own JSON (format, streams, chapters) for /mediainfo; an empty dict when it found nothing."""
|
| 213 |
+
code, out, err = await _run(
|
| 214 |
+
["ffprobe", "-v", "error", "-print_format", "json", "-show_format", "-show_streams", "-show_chapters", str(path)], cancel
|
| 215 |
+
)
|
| 216 |
+
try:
|
| 217 |
+
data = json.loads(out.decode("utf-8", "replace") or "{}")
|
| 218 |
+
except ValueError:
|
| 219 |
+
data = {}
|
| 220 |
+
if not isinstance(data, dict):
|
| 221 |
+
data = {}
|
| 222 |
+
if not data.get("streams") and not data.get("format") and code != 0:
|
| 223 |
+
raise MediaError("ffprobe failed: " + err.decode("utf-8", "replace").strip()[-300:])
|
| 224 |
+
return data
|
| 225 |
+
|
| 226 |
+
|
| 227 |
# ---------------------------------------------------------------------------
|
| 228 |
# track selection
|
| 229 |
# ---------------------------------------------------------------------------
|
|
|
|
| 407 |
return code == 0 and dst.exists() and dst.stat().st_size > 0
|
| 408 |
|
| 409 |
|
| 410 |
+
def frame_seeks(duration: float) -> List[float]:
|
| 411 |
+
"""Positions (seconds) to try for a picture, best first: ~10% in, then earlier ones for a file that is cut off."""
|
| 412 |
+
first = 1.0 if duration <= 0 else min(max(duration * 0.1, 0.0), 60.0)
|
| 413 |
+
return [round(first, 2)] + [seek for seek in (20.0, 5.0, 0.0) if seek < first]
|
| 414 |
+
|
| 415 |
+
|
| 416 |
+
async def extract_frame(
|
| 417 |
+
src: Path, dst: Path, seeks: Sequence[float], width: int = 1280, cancel: Optional[asyncio.Event] = None
|
| 418 |
+
) -> bool:
|
| 419 |
+
"""Save one frame of ``src`` as a JPEG (at most ``width`` px wide), trying each seek position until one gives a picture."""
|
| 420 |
+
for seek in seeks:
|
| 421 |
+
dst.unlink(missing_ok=True)
|
| 422 |
+
cmd = [
|
| 423 |
+
"ffmpeg", "-hide_banner", "-nostdin", "-y", "-loglevel", "error", "-ss", f"{seek:.2f}", "-i", str(src),
|
| 424 |
+
"-frames:v", "1", "-vf", f"scale='min({width},iw)':-2", "-q:v", "3", str(dst),
|
| 425 |
+
]
|
| 426 |
+
try:
|
| 427 |
+
code, _o, _e = await _run(cmd, cancel)
|
| 428 |
+
except MediaError:
|
| 429 |
+
return False
|
| 430 |
+
if code == 0 and dst.exists() and dst.stat().st_size > 0:
|
| 431 |
+
return True
|
| 432 |
+
return False
|
| 433 |
+
|
| 434 |
+
|
| 435 |
+
SIZE_MIN, SIZE_MAX = 16, 4096
|
| 436 |
+
THUMB_BOX = 320 # Telegram shows custom thumbnails up to 320 px, as a JPEG under 200 KB
|
| 437 |
+
|
| 438 |
+
|
| 439 |
+
_SIZE_RE = re.compile(r"\s*(\d{1,5})(?:(?:\s*[x×*,:]\s*|\s+)(\d{1,5}))?\s*", re.I)
|
| 440 |
+
|
| 441 |
+
|
| 442 |
+
def parse_size(text: str) -> Optional[Tuple[int, int]]:
|
| 443 |
+
"""``320`` -> (320, 320); ``320x180`` / ``320×180`` / ``320 180`` -> (320, 180); None when it isn't a usable size."""
|
| 444 |
+
m = _SIZE_RE.fullmatch(text or "")
|
| 445 |
+
if not m:
|
| 446 |
+
return None
|
| 447 |
+
width = int(m.group(1))
|
| 448 |
+
height = int(m.group(2)) if m.group(2) else width
|
| 449 |
+
if not (SIZE_MIN <= width <= SIZE_MAX and SIZE_MIN <= height <= SIZE_MAX):
|
| 450 |
+
return None
|
| 451 |
+
return width, height
|
| 452 |
+
|
| 453 |
+
|
| 454 |
+
def _flatten(img: Any) -> Any:
|
| 455 |
+
"""``img`` as RGB; transparent parts become white."""
|
| 456 |
+
from PIL import Image
|
| 457 |
+
|
| 458 |
+
if img.mode in ("RGBA", "LA") or (img.mode == "P" and "transparency" in img.info):
|
| 459 |
+
rgba = img.convert("RGBA")
|
| 460 |
+
flat = Image.new("RGB", rgba.size, (255, 255, 255))
|
| 461 |
+
flat.paste(rgba, mask=rgba.getchannel("A"))
|
| 462 |
+
return flat
|
| 463 |
+
return img.convert("RGB")
|
| 464 |
+
|
| 465 |
+
|
| 466 |
+
def fit_photo(data: bytes, box: int = 1280) -> bytes:
|
| 467 |
+
"""A picture as a JPEG that fits ``box`` x ``box`` (a smaller one is not enlarged): what Telegram takes as a photo."""
|
| 468 |
+
from PIL import Image, ImageOps
|
| 469 |
+
|
| 470 |
+
with Image.open(io.BytesIO(data)) as img:
|
| 471 |
+
img = _flatten(ImageOps.exif_transpose(img))
|
| 472 |
+
img.thumbnail((box, box), Image.LANCZOS)
|
| 473 |
+
buf = io.BytesIO()
|
| 474 |
+
img.save(buf, "JPEG", quality=90, optimize=True)
|
| 475 |
+
return buf.getvalue()
|
| 476 |
+
|
| 477 |
+
|
| 478 |
+
def resize_image(data: bytes, width: int, height: int) -> Tuple[bytes, Tuple[int, int], Tuple[int, int]]:
|
| 479 |
+
"""Scale an image to fit inside ``width`` x ``height``, keeping its shape.
|
| 480 |
+
|
| 481 |
+
Returns ``(jpeg bytes, original size, new size)``. A result that fits Telegram's thumbnail box stays under 200 KB.
|
| 482 |
+
"""
|
| 483 |
+
from PIL import Image, ImageOps
|
| 484 |
+
|
| 485 |
+
with Image.open(io.BytesIO(data)) as img:
|
| 486 |
+
img = ImageOps.exif_transpose(img)
|
| 487 |
+
original = img.size
|
| 488 |
+
img = _flatten(img)
|
| 489 |
+
out = ImageOps.contain(img, (width, height), Image.LANCZOS)
|
| 490 |
+
limit = 200 * 1024 if max(out.size) <= THUMB_BOX else None
|
| 491 |
+
for quality in (92, 85, 78, 70, 60, 50, 40):
|
| 492 |
+
buf = io.BytesIO()
|
| 493 |
+
out.save(buf, "JPEG", quality=quality, optimize=True)
|
| 494 |
+
if limit is None or buf.tell() <= limit:
|
| 495 |
+
break
|
| 496 |
+
return buf.getvalue(), original, out.size
|
| 497 |
+
|
| 498 |
+
|
| 499 |
def prepare_thumb(data: bytes) -> bytes:
|
| 500 |
"""Resize/re-encode an image to Telegram's thumbnail limits (<=320px, <=200 KB JPEG)."""
|
| 501 |
from PIL import Image
|
bot/mediainfo.py
ADDED
|
@@ -0,0 +1,491 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""/mediainfo: everything we can tell about a file, laid out like MediaInfo and ready for a Telegraph page.
|
| 2 |
+
|
| 3 |
+
MediaInfo (the ``pymediainfo`` package) is used when it is available, because it reports the most. Without it the
|
| 4 |
+
report is built from ffprobe, which is always there because the renamer needs it.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import asyncio
|
| 9 |
+
import logging
|
| 10 |
+
import re
|
| 11 |
+
import textwrap
|
| 12 |
+
from dataclasses import dataclass, field
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 15 |
+
|
| 16 |
+
from . import media
|
| 17 |
+
from .errors import MediaError
|
| 18 |
+
from .telegraph import CONTENT_LIMIT, Node, content_size
|
| 19 |
+
from .utils import clip
|
| 20 |
+
|
| 21 |
+
log = logging.getLogger(__name__)
|
| 22 |
+
|
| 23 |
+
WRAP = 64 # columns of the monospace blocks on the page (Telegraph does not wrap them)
|
| 24 |
+
LABEL_ALIGN = 26 # labels longer than this are not used to line up the values
|
| 25 |
+
MIN_VALUE = 40 # columns a wrapped value always gets, even next to a long label
|
| 26 |
+
STREAM_KINDS = {"Video", "Audio", "Text", "Image", "Other"}
|
| 27 |
+
# lines MediaInfo adds when it is given only the start of a file; they describe our download, not the file
|
| 28 |
+
PARTIAL_ARTIFACTS = {"CRC_Error_Pos", "IsTruncated"}
|
| 29 |
+
_CONTROL_RE = re.compile(r"[\x00-\x08\x0b-\x1f\x7f]")
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@dataclass
|
| 33 |
+
class Section:
|
| 34 |
+
title: str
|
| 35 |
+
lines: List[Tuple[str, str]] = field(default_factory=list) # (label, value); an empty label is a plain line
|
| 36 |
+
|
| 37 |
+
@property
|
| 38 |
+
def kind(self) -> str:
|
| 39 |
+
return self.title.split(" #")[0]
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@dataclass
|
| 43 |
+
class Report:
|
| 44 |
+
sections: List[Section]
|
| 45 |
+
engine: str # "MediaInfo", "ffprobe" or "" when nothing could read the file
|
| 46 |
+
truncated: bool = False # only the start of the file was read
|
| 47 |
+
notes: List[str] = field(default_factory=list) # shown under the sections
|
| 48 |
+
|
| 49 |
+
@property
|
| 50 |
+
def has_streams(self) -> bool:
|
| 51 |
+
return any(s.kind in STREAM_KINDS for s in self.sections)
|
| 52 |
+
|
| 53 |
+
def section(self, title: str) -> Optional[Section]:
|
| 54 |
+
return next((s for s in self.sections if s.title == title), None)
|
| 55 |
+
|
| 56 |
+
def summary(self) -> str:
|
| 57 |
+
"""``Matroska · 24 min 1 s · 1 video · 2 audio · 3 subtitle``"""
|
| 58 |
+
parts: List[str] = []
|
| 59 |
+
general = self.section("General")
|
| 60 |
+
for label in ("Format", "Duration"):
|
| 61 |
+
value = next((v for lab, v in general.lines if lab == label), "") if general is not None else ""
|
| 62 |
+
if value:
|
| 63 |
+
parts.append(value)
|
| 64 |
+
for kind, word in (("Video", "video"), ("Audio", "audio"), ("Text", "subtitle")):
|
| 65 |
+
count = sum(1 for s in self.sections if s.kind == kind)
|
| 66 |
+
if count:
|
| 67 |
+
parts.append(f"{count} {word}")
|
| 68 |
+
return " · ".join(parts)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
# ---------------------------------------------------------------------------
|
| 72 |
+
# MediaInfo's number formats
|
| 73 |
+
# ---------------------------------------------------------------------------
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def mi_size(num: float) -> str:
|
| 77 |
+
"""``71.0 KiB``, ``1.46 GiB``: three significant digits, binary units."""
|
| 78 |
+
value = float(num)
|
| 79 |
+
unit = "Bytes"
|
| 80 |
+
for unit in ("Bytes", "KiB", "MiB", "GiB", "TiB"):
|
| 81 |
+
if value < 1023.5 or unit == "TiB":
|
| 82 |
+
break
|
| 83 |
+
value /= 1024
|
| 84 |
+
if unit == "Bytes":
|
| 85 |
+
return f"{int(round(value))} Bytes"
|
| 86 |
+
digits = 0 if value >= 99.95 else 1 if value >= 9.995 else 2
|
| 87 |
+
return f"{value:.{digits}f} {unit}"
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def mi_rate(bits_per_second: float) -> str:
|
| 91 |
+
"""``62 b/s``, ``370 kb/s`` below 10 Mb/s, ``12.3 Mb/s`` above."""
|
| 92 |
+
if bits_per_second < 1000:
|
| 93 |
+
return f"{int(round(bits_per_second))} b/s"
|
| 94 |
+
kbps = bits_per_second / 1000
|
| 95 |
+
if kbps < 10000:
|
| 96 |
+
return f"{kbps:,.0f}".replace(",", " ") + " kb/s"
|
| 97 |
+
mbps = kbps / 1000
|
| 98 |
+
return f"{mbps:.0f} Mb/s" if mbps >= 100 else f"{mbps:.1f} Mb/s"
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def mi_duration(milliseconds: float) -> str:
|
| 102 |
+
"""``1 h 24 min``, ``24 min 1 s``, ``2 s 23 ms``."""
|
| 103 |
+
ms = int(round(milliseconds))
|
| 104 |
+
hours, rest = divmod(ms, 3_600_000)
|
| 105 |
+
minutes, rest = divmod(rest, 60_000)
|
| 106 |
+
seconds, millis = divmod(rest, 1000)
|
| 107 |
+
if hours:
|
| 108 |
+
return f"{hours} h {minutes} min"
|
| 109 |
+
if minutes:
|
| 110 |
+
return f"{minutes} min {seconds} s"
|
| 111 |
+
return f"{seconds} s {millis} ms"
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def clean(value: Any) -> str:
|
| 115 |
+
return _CONTROL_RE.sub("", str(value)).strip()
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
# ---------------------------------------------------------------------------
|
| 119 |
+
# reading MediaInfo's text
|
| 120 |
+
# ---------------------------------------------------------------------------
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def parse_text(text: str) -> List[Section]:
|
| 124 |
+
"""Split MediaInfo's text output into sections of ``label : value`` lines (nested lines keep their indent)."""
|
| 125 |
+
sections: List[Section] = []
|
| 126 |
+
current: Optional[Section] = None
|
| 127 |
+
for raw in text.splitlines():
|
| 128 |
+
line = _CONTROL_RE.sub("", raw).rstrip()
|
| 129 |
+
if not line.strip():
|
| 130 |
+
current = None
|
| 131 |
+
continue
|
| 132 |
+
if current is None:
|
| 133 |
+
current = Section(title=line.strip())
|
| 134 |
+
sections.append(current)
|
| 135 |
+
continue
|
| 136 |
+
label, sep, value = line.partition(" : ")
|
| 137 |
+
if sep:
|
| 138 |
+
current.lines.append((label.rstrip(), value.strip()))
|
| 139 |
+
elif line.endswith(" :"):
|
| 140 |
+
current.lines.append((line[:-2].rstrip(), ""))
|
| 141 |
+
else:
|
| 142 |
+
current.lines.append(("", line.strip()))
|
| 143 |
+
return sections
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def _set(section: Section, label: str, value: str, *, first: bool = False) -> None:
|
| 147 |
+
for i, (existing, _old) in enumerate(section.lines):
|
| 148 |
+
if existing == label:
|
| 149 |
+
section.lines[i] = (label, value)
|
| 150 |
+
return
|
| 151 |
+
section.lines.insert(0 if first else len(section.lines), (label, value))
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def _drop(section: Section, label: str) -> None:
|
| 155 |
+
section.lines = [(lab, val) for lab, val in section.lines if lab != label]
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def _drop_partial_artifacts(section: Section) -> None:
|
| 159 |
+
"""Remove the conformance errors and the like that MediaInfo reports about a file that was cut short on purpose."""
|
| 160 |
+
kept: List[Tuple[str, str]] = []
|
| 161 |
+
skipping = False
|
| 162 |
+
for label, value in section.lines:
|
| 163 |
+
if skipping and label[:1].isspace(): # a line nested under the one being dropped
|
| 164 |
+
continue
|
| 165 |
+
skipping = False
|
| 166 |
+
bare = label.strip()
|
| 167 |
+
if bare.startswith("Conformance"):
|
| 168 |
+
skipping = True
|
| 169 |
+
continue
|
| 170 |
+
if bare in PARTIAL_ARTIFACTS:
|
| 171 |
+
continue
|
| 172 |
+
kept.append((label, value))
|
| 173 |
+
section.lines = kept
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def fix_general(report: Report, name: str, true_size: int, duration_ms: Optional[float]) -> None:
|
| 177 |
+
"""Show the file's own name instead of the temporary path, and its real size when only the start was read."""
|
| 178 |
+
general = report.section("General")
|
| 179 |
+
if general is None:
|
| 180 |
+
general = Section("General")
|
| 181 |
+
report.sections.insert(0, general)
|
| 182 |
+
_set(general, "Complete name", name, first=True)
|
| 183 |
+
if true_size:
|
| 184 |
+
_set(general, "File size", mi_size(true_size))
|
| 185 |
+
if report.truncated:
|
| 186 |
+
_drop_partial_artifacts(general)
|
| 187 |
+
if true_size and duration_ms:
|
| 188 |
+
_set(general, "Overall bit rate", mi_rate(true_size * 8 / (duration_ms / 1000)))
|
| 189 |
+
else:
|
| 190 |
+
_drop(general, "Overall bit rate") # it was worked out from the part we have, so it would be wrong
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def telegram_section(name: str, size: int, *, mime: str = "", sent_as: str = "", sent_on: str = "", dc: str = "", caption: str = "") -> Section:
|
| 194 |
+
"""What Telegram itself says about the file."""
|
| 195 |
+
lines = [("File name", name)]
|
| 196 |
+
if size:
|
| 197 |
+
lines.append(("File size", f"{mi_size(size)} ({size:,} bytes)".replace(",", " ")))
|
| 198 |
+
for label, value in (("MIME type", mime), ("Sent as", sent_as), ("Sent on", sent_on), ("Data center", dc), ("Caption", caption)):
|
| 199 |
+
if value:
|
| 200 |
+
lines.append((label, value))
|
| 201 |
+
return Section("Telegram", lines)
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
# ---------------------------------------------------------------------------
|
| 205 |
+
# the ffprobe fallback
|
| 206 |
+
# ---------------------------------------------------------------------------
|
| 207 |
+
|
| 208 |
+
_TAG_LABELS = {
|
| 209 |
+
"title": "Title", "encoder": "Writing application", "creation_time": "Encoded date", "artist": "Performer",
|
| 210 |
+
"album": "Album", "date": "Recorded date", "comment": "Comment", "genre": "Genre", "copyright": "Copyright",
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def _language(code: str) -> str:
|
| 215 |
+
name = media.canonical_lang(code)
|
| 216 |
+
return name.title() if name else code.strip()
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def _fraction(text: Any) -> float:
|
| 220 |
+
try:
|
| 221 |
+
num, _sep, den = str(text).partition("/")
|
| 222 |
+
return float(num) / float(den) if den else float(num)
|
| 223 |
+
except (ValueError, ZeroDivisionError):
|
| 224 |
+
return 0.0
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def _seconds(*candidates: Any) -> float:
|
| 228 |
+
"""The first usable duration among ``candidates``: plain seconds or ``HH:MM:SS.fff``."""
|
| 229 |
+
for cand in candidates:
|
| 230 |
+
if not cand:
|
| 231 |
+
continue
|
| 232 |
+
text = str(cand)
|
| 233 |
+
try:
|
| 234 |
+
value = media._parse_hms(text) if ":" in text else float(text)
|
| 235 |
+
except ValueError:
|
| 236 |
+
continue
|
| 237 |
+
if value > 0:
|
| 238 |
+
return value
|
| 239 |
+
return 0.0
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def _add(section: Section, label: str, value: Any) -> None:
|
| 243 |
+
text = clean(value) if value not in (None, "") else ""
|
| 244 |
+
if text:
|
| 245 |
+
section.lines.append((label, text))
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
def _flag(value: Any) -> str:
|
| 249 |
+
return "Yes" if int(value or 0) else "No"
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
def _level(stream: Dict[str, Any]) -> str:
|
| 253 |
+
try:
|
| 254 |
+
level = int(stream.get("level"))
|
| 255 |
+
except (TypeError, ValueError):
|
| 256 |
+
return ""
|
| 257 |
+
if level <= 0:
|
| 258 |
+
return ""
|
| 259 |
+
codec = stream.get("codec_name")
|
| 260 |
+
if codec == "h264":
|
| 261 |
+
return f"L{level / 10:g}"
|
| 262 |
+
if codec == "hevc":
|
| 263 |
+
return f"L{level / 30:g}"
|
| 264 |
+
return ""
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
def _stream_section(stream: Dict[str, Any], title: str) -> Section:
|
| 268 |
+
sec = Section(title)
|
| 269 |
+
kind = stream.get("codec_type")
|
| 270 |
+
tags = {k.lower(): v for k, v in (stream.get("tags") or {}).items()}
|
| 271 |
+
disp = stream.get("disposition") or {}
|
| 272 |
+
_add(sec, "ID", int(stream.get("index", 0)) + 1)
|
| 273 |
+
_add(sec, "Format", stream.get("codec_long_name") or stream.get("codec_name"))
|
| 274 |
+
if kind == "video":
|
| 275 |
+
_add(sec, "Format profile", "@".join(p for p in (stream.get("profile"), _level(stream)) if p))
|
| 276 |
+
else:
|
| 277 |
+
_add(sec, "Format profile", stream.get("profile"))
|
| 278 |
+
tag = stream.get("codec_tag_string")
|
| 279 |
+
if tag and not tag.startswith("[0]"):
|
| 280 |
+
_add(sec, "Codec ID", tag)
|
| 281 |
+
seconds = _seconds(stream.get("duration"), tags.get("duration"))
|
| 282 |
+
if seconds:
|
| 283 |
+
_add(sec, "Duration", mi_duration(seconds * 1000))
|
| 284 |
+
try:
|
| 285 |
+
rate = float(stream.get("bit_rate") or tags.get("bps") or tags.get("bps-eng") or 0)
|
| 286 |
+
except ValueError:
|
| 287 |
+
rate = 0.0
|
| 288 |
+
if rate:
|
| 289 |
+
_add(sec, "Bit rate", mi_rate(rate))
|
| 290 |
+
if kind == "video":
|
| 291 |
+
if stream.get("width") and stream.get("height"):
|
| 292 |
+
_add(sec, "Width", f"{stream['width']} pixels")
|
| 293 |
+
_add(sec, "Height", f"{stream['height']} pixels")
|
| 294 |
+
_add(sec, "Display aspect ratio", stream.get("display_aspect_ratio"))
|
| 295 |
+
fps = _fraction(stream.get("avg_frame_rate") or "0") or _fraction(stream.get("r_frame_rate") or "0")
|
| 296 |
+
if fps:
|
| 297 |
+
_add(sec, "Frame rate", f"{fps:.3f} FPS")
|
| 298 |
+
_add(sec, "Pixel format", stream.get("pix_fmt"))
|
| 299 |
+
if stream.get("bits_per_raw_sample"):
|
| 300 |
+
_add(sec, "Bit depth", f"{stream['bits_per_raw_sample']} bits")
|
| 301 |
+
for label, key in (("Color space", "color_space"), ("Color range", "color_range"), ("Transfer characteristics", "color_transfer"),
|
| 302 |
+
("Color primaries", "color_primaries"), ("Scan type", "field_order")):
|
| 303 |
+
_add(sec, label, stream.get(key))
|
| 304 |
+
elif kind == "audio":
|
| 305 |
+
channels = stream.get("channels")
|
| 306 |
+
if channels:
|
| 307 |
+
_add(sec, "Channel(s)", f"{channels} channel" + ("s" if channels != 1 else ""))
|
| 308 |
+
_add(sec, "Channel layout", stream.get("channel_layout"))
|
| 309 |
+
try:
|
| 310 |
+
if stream.get("sample_rate"):
|
| 311 |
+
_add(sec, "Sampling rate", f"{float(stream['sample_rate']) / 1000:g} kHz")
|
| 312 |
+
except ValueError:
|
| 313 |
+
pass
|
| 314 |
+
if stream.get("bits_per_raw_sample"):
|
| 315 |
+
_add(sec, "Bit depth", f"{stream['bits_per_raw_sample']} bits")
|
| 316 |
+
_add(sec, "Title", tags.get("title"))
|
| 317 |
+
if tags.get("language") not in (None, "", "und"):
|
| 318 |
+
_add(sec, "Language", _language(str(tags["language"])))
|
| 319 |
+
sec.lines.append(("Default", _flag(disp.get("default"))))
|
| 320 |
+
sec.lines.append(("Forced", _flag(disp.get("forced"))))
|
| 321 |
+
return sec
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
def ffprobe_sections(data: Dict[str, Any], name: str, true_size: int) -> List[Section]:
|
| 325 |
+
"""MediaInfo-style sections from ffprobe's JSON (``-show_format -show_streams -show_chapters``)."""
|
| 326 |
+
fmt = data.get("format") or {}
|
| 327 |
+
streams = data.get("streams") or []
|
| 328 |
+
general = Section("General")
|
| 329 |
+
_add(general, "Complete name", name)
|
| 330 |
+
_add(general, "Format", fmt.get("format_long_name") or fmt.get("format_name"))
|
| 331 |
+
if true_size:
|
| 332 |
+
_add(general, "File size", mi_size(true_size))
|
| 333 |
+
seconds = _seconds(fmt.get("duration"))
|
| 334 |
+
if seconds:
|
| 335 |
+
_add(general, "Duration", mi_duration(seconds * 1000))
|
| 336 |
+
if true_size:
|
| 337 |
+
_add(general, "Overall bit rate", mi_rate(true_size * 8 / seconds))
|
| 338 |
+
for key, value in (fmt.get("tags") or {}).items():
|
| 339 |
+
_add(general, _TAG_LABELS.get(key.lower(), key.replace("_", " ").title()), value)
|
| 340 |
+
attached = [
|
| 341 |
+
(s.get("tags") or {}).get("filename") or (s.get("tags") or {}).get("FILENAME")
|
| 342 |
+
for s in streams if s.get("codec_type") == "attachment"
|
| 343 |
+
]
|
| 344 |
+
_add(general, "Attachments", " / ".join(a for a in attached if a))
|
| 345 |
+
sections = [general]
|
| 346 |
+
|
| 347 |
+
titles = {"video": "Video", "audio": "Audio", "subtitle": "Text"}
|
| 348 |
+
counts = {kind: sum(1 for s in streams if s.get("codec_type") == kind) for kind in titles}
|
| 349 |
+
seen: Dict[str, int] = {}
|
| 350 |
+
for stream in streams:
|
| 351 |
+
kind = stream.get("codec_type")
|
| 352 |
+
if kind not in titles:
|
| 353 |
+
continue
|
| 354 |
+
seen[kind] = seen.get(kind, 0) + 1
|
| 355 |
+
title = titles[kind] if counts[kind] == 1 else f"{titles[kind]} #{seen[kind]}"
|
| 356 |
+
sections.append(_stream_section(stream, title))
|
| 357 |
+
|
| 358 |
+
chapters = data.get("chapters") or []
|
| 359 |
+
if chapters:
|
| 360 |
+
menu = Section("Menu")
|
| 361 |
+
for chapter in chapters:
|
| 362 |
+
millis = int(_seconds(chapter.get("start_time")) * 1000)
|
| 363 |
+
hours, rest = divmod(millis, 3_600_000)
|
| 364 |
+
minutes, rest = divmod(rest, 60_000)
|
| 365 |
+
stamp = f"{hours:02d}:{minutes:02d}:{rest // 1000:02d}.{rest % 1000:03d}"
|
| 366 |
+
menu.lines.append((stamp, clean((chapter.get("tags") or {}).get("title", ""))))
|
| 367 |
+
sections.append(menu)
|
| 368 |
+
return sections
|
| 369 |
+
|
| 370 |
+
|
| 371 |
+
# ---------------------------------------------------------------------------
|
| 372 |
+
# reading a file
|
| 373 |
+
# ---------------------------------------------------------------------------
|
| 374 |
+
|
| 375 |
+
|
| 376 |
+
def _mediainfo(path: Path) -> Optional[Tuple[str, Optional[float]]]:
|
| 377 |
+
"""Blocking: MediaInfo's text and the duration (ms) of the file, or None when MediaInfo is not usable."""
|
| 378 |
+
try:
|
| 379 |
+
from pymediainfo import MediaInfo
|
| 380 |
+
except ImportError:
|
| 381 |
+
return None
|
| 382 |
+
if not MediaInfo.can_parse():
|
| 383 |
+
return None
|
| 384 |
+
text = MediaInfo.parse(str(path), output="text", full=False)
|
| 385 |
+
duration: Optional[float] = None
|
| 386 |
+
try:
|
| 387 |
+
first = MediaInfo.parse(str(path)).tracks[0]
|
| 388 |
+
duration = float(first.duration) if first.duration else None
|
| 389 |
+
except (IndexError, TypeError, ValueError, AttributeError):
|
| 390 |
+
duration = None
|
| 391 |
+
return str(text), duration
|
| 392 |
+
|
| 393 |
+
|
| 394 |
+
async def read_file(path: Path, name: str, true_size: int, *, extra: Optional[Section] = None) -> Report:
|
| 395 |
+
"""The report for the file at ``path``, which is all of the file or only its start (``true_size`` is the real size).
|
| 396 |
+
|
| 397 |
+
The report may have no streams (``has_streams``): a video whose index is at the end of the file, for one.
|
| 398 |
+
"""
|
| 399 |
+
sections: List[Section] = []
|
| 400 |
+
engine = ""
|
| 401 |
+
duration_ms: Optional[float] = None
|
| 402 |
+
try:
|
| 403 |
+
got = await asyncio.to_thread(_mediainfo, path)
|
| 404 |
+
except Exception as exc: # noqa: BLE001 - the library is native code; whatever goes wrong, ffprobe is the fallback
|
| 405 |
+
log.warning("MediaInfo failed on %s: %s", name, exc)
|
| 406 |
+
got = None
|
| 407 |
+
if got is not None:
|
| 408 |
+
sections, duration_ms = parse_text(got[0]), got[1]
|
| 409 |
+
engine = "MediaInfo" if sections else ""
|
| 410 |
+
if not sections:
|
| 411 |
+
try:
|
| 412 |
+
data = await media.probe_raw(path)
|
| 413 |
+
except MediaError:
|
| 414 |
+
data = {}
|
| 415 |
+
if data.get("format") or data.get("streams"):
|
| 416 |
+
sections, engine = ffprobe_sections(data, name, true_size), "ffprobe"
|
| 417 |
+
duration_ms = _seconds((data.get("format") or {}).get("duration")) * 1000 or None
|
| 418 |
+
report = Report(sections=sections, engine=engine, truncated=bool(true_size and path.stat().st_size < true_size))
|
| 419 |
+
fix_general(report, name, true_size, duration_ms)
|
| 420 |
+
if extra is not None:
|
| 421 |
+
report.sections.append(extra)
|
| 422 |
+
return report
|
| 423 |
+
|
| 424 |
+
|
| 425 |
+
# ---------------------------------------------------------------------------
|
| 426 |
+
# the Telegraph page
|
| 427 |
+
# ---------------------------------------------------------------------------
|
| 428 |
+
|
| 429 |
+
|
| 430 |
+
def render_section(section: Section, width: int = WRAP, max_value: int = 0) -> str:
|
| 431 |
+
"""The section as aligned ``label : value`` lines, long values wrapped under the first one."""
|
| 432 |
+
label_w = max((len(lab) for lab, _ in section.lines if lab and len(lab) <= LABEL_ALIGN), default=0)
|
| 433 |
+
indent = " " * (label_w + 3)
|
| 434 |
+
room = max(MIN_VALUE, width - len(indent))
|
| 435 |
+
out: List[str] = []
|
| 436 |
+
for label, value in section.lines:
|
| 437 |
+
if max_value:
|
| 438 |
+
value = clip(value, max_value)
|
| 439 |
+
if not label:
|
| 440 |
+
out.extend(textwrap.wrap(value, width=width, break_long_words=True, break_on_hyphens=False) or [""])
|
| 441 |
+
continue
|
| 442 |
+
head = f"{clip(label, 60):<{label_w}} : "
|
| 443 |
+
parts = textwrap.wrap(value, width=room, break_long_words=True, break_on_hyphens=False) or [""]
|
| 444 |
+
out.append(head + parts[0])
|
| 445 |
+
out.extend(indent + part for part in parts[1:])
|
| 446 |
+
return "\n".join(line.rstrip() for line in out)
|
| 447 |
+
|
| 448 |
+
|
| 449 |
+
def render_text(report: Report, **kw: Any) -> str:
|
| 450 |
+
"""The whole report as plain text (what is sent as a file when Telegraph cannot be reached)."""
|
| 451 |
+
blocks = [f"{s.title}\n{render_section(s, **kw)}" for s in report.sections]
|
| 452 |
+
blocks.extend(report_notes(report))
|
| 453 |
+
return "\n\n".join(blocks) + "\n"
|
| 454 |
+
|
| 455 |
+
|
| 456 |
+
def report_notes(report: Report) -> List[str]:
|
| 457 |
+
notes = list(report.notes)
|
| 458 |
+
if report.truncated:
|
| 459 |
+
notes.append("Only the start of the file was read; its size and overall bit rate are worked out from the real file size.")
|
| 460 |
+
notes.append(f"Made by the bot with {report.engine}." if report.engine else "Made by the bot.")
|
| 461 |
+
return notes
|
| 462 |
+
|
| 463 |
+
|
| 464 |
+
def _nodes(report: Report, max_value: int, keep: int, notes: List[str]) -> List[Node]:
|
| 465 |
+
nodes: List[Node] = []
|
| 466 |
+
for index, section in enumerate(report.sections):
|
| 467 |
+
if index >= keep and section.title != "Telegram":
|
| 468 |
+
continue
|
| 469 |
+
nodes.append({"tag": "h4", "children": [section.title]})
|
| 470 |
+
nodes.append({"tag": "pre", "children": [render_section(section, max_value=max_value)]})
|
| 471 |
+
for note in notes:
|
| 472 |
+
nodes.append({"tag": "p", "children": [{"tag": "i", "children": [note]}]})
|
| 473 |
+
return nodes
|
| 474 |
+
|
| 475 |
+
|
| 476 |
+
def to_nodes(report: Report, limit: int = CONTENT_LIMIT) -> List[Node]:
|
| 477 |
+
"""Telegraph nodes for the report, shortened step by step until the page fits Telegraph's size limit."""
|
| 478 |
+
notes = report_notes(report)
|
| 479 |
+
total = len(report.sections)
|
| 480 |
+
for max_value in (0, 300, 150, 80):
|
| 481 |
+
nodes = _nodes(report, max_value, total, notes)
|
| 482 |
+
if content_size(nodes) <= limit:
|
| 483 |
+
return nodes
|
| 484 |
+
keep = total
|
| 485 |
+
while keep > 1: # still too big (a file with hundreds of tracks): drop the last sections, keeping General and Telegram
|
| 486 |
+
keep -= 1
|
| 487 |
+
left_out = [f"The page size limit was reached, so {total - keep} more section(s) were left out."]
|
| 488 |
+
nodes = _nodes(report, 80, keep, notes + left_out)
|
| 489 |
+
if content_size(nodes) <= limit:
|
| 490 |
+
return nodes
|
| 491 |
+
return _nodes(report, 80, 1, notes)
|
bot/panels.py
CHANGED
|
@@ -67,7 +67,7 @@ def home(s: Dict[str, Any], *, owner: bool) -> Panel:
|
|
| 67 |
rows: List[Row] = [
|
| 68 |
[("Rename", "m:rename"), ("Caption & Thumbnail", "m:cap")],
|
| 69 |
[("Metadata", "m:meta"), ("File Type", "m:ftype")],
|
| 70 |
-
[("Help", "m:help")] + ([("Admin", "m:admin")] if owner else []),
|
| 71 |
[("✖ Close", "x:close")],
|
| 72 |
]
|
| 73 |
return text, rows
|
|
@@ -183,13 +183,16 @@ def help_panel() -> Panel:
|
|
| 183 |
text = (
|
| 184 |
"<b>How to use</b>\n\n"
|
| 185 |
"1. Send a file — I rename it with your template and send it back.\n"
|
| 186 |
-
"2.
|
|
|
|
| 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. /
|
|
|
|
|
|
|
| 193 |
"Template variables: <code>{title} {season} {episode} {quality} {year} {codec} {filename} {ext}</code>\n"
|
| 194 |
"A [bracket] group is dropped when a variable inside it is empty."
|
| 195 |
)
|
|
@@ -205,7 +208,7 @@ def admin(owner_id: int, admins: Sequence[int], running: int, waiting: int, log_
|
|
| 205 |
f"<b>Allowed users:</b> {shown}{more}\n"
|
| 206 |
f"<b>Queue:</b> {running} running · {waiting} waiting\n"
|
| 207 |
f"<b>Log channel:</b> {('<code>%s</code>' % log_channel) if log_channel else 'not set (LOG_CHANNEL)'}\n\n"
|
| 208 |
-
"Commands: /addadmin <id> · /deladmin <id> · /admins"
|
| 209 |
)
|
| 210 |
rows: List[Row] = [
|
| 211 |
[("📊 Stats", "adm:stats"), ("🟢 Status", "adm:status")],
|
|
@@ -214,6 +217,209 @@ def admin(owner_id: int, admins: Sequence[int], running: int, waiting: int, log_
|
|
| 214 |
return text, rows
|
| 215 |
|
| 216 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
# ---------------------------------------------------------------------------
|
| 218 |
# input prompts
|
| 219 |
# ---------------------------------------------------------------------------
|
|
@@ -261,5 +467,22 @@ def input_prompt(kind: str, back: str) -> Panel:
|
|
| 261 |
return PROMPTS[kind], [[("✖ Cancel", f"in:cancel:{back}")]]
|
| 262 |
|
| 263 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
def known_languages() -> str:
|
| 265 |
return ", ".join(sorted(LANGUAGES))
|
|
|
|
| 67 |
rows: List[Row] = [
|
| 68 |
[("Rename", "m:rename"), ("Caption & Thumbnail", "m:cap")],
|
| 69 |
[("Metadata", "m:meta"), ("File Type", "m:ftype")],
|
| 70 |
+
[("Formats", "m:formats"), ("Help", "m:help")] + ([("Admin", "m:admin")] if owner else []),
|
| 71 |
[("✖ Close", "x:close")],
|
| 72 |
]
|
| 73 |
return text, rows
|
|
|
|
| 183 |
text = (
|
| 184 |
"<b>How to use</b>\n\n"
|
| 185 |
"1. Send a file — I rename it with your template and send it back.\n"
|
| 186 |
+
"2. /formats — save up to 12 named formats (template, thumbnail, metadata, tracks); "
|
| 187 |
+
"/rename — pick one, then send a file (send /rename again for the next one).\n"
|
| 188 |
"3. /template and /caption — set the naming and caption templates.\n"
|
| 189 |
"4. /mode — read details from the file name or the caption.\n"
|
| 190 |
"5. /metadata, /keepaudio, /keepsubtitle — tags and track clean-up.\n"
|
| 191 |
"6. /filetype — send as document, video or audio.\n"
|
| 192 |
"7. Send a photo — it becomes your thumbnail.\n"
|
| 193 |
+
"8. /sequence then /sort — put files in episode order.\n"
|
| 194 |
+
"9. /check, /mediainfo, /ethumb, /resize, /speed — inspect files and images.\n"
|
| 195 |
+
"10. /queue and /cancel — manage running jobs.\n\n"
|
| 196 |
"Template variables: <code>{title} {season} {episode} {quality} {year} {codec} {filename} {ext}</code>\n"
|
| 197 |
"A [bracket] group is dropped when a variable inside it is empty."
|
| 198 |
)
|
|
|
|
| 208 |
f"<b>Allowed users:</b> {shown}{more}\n"
|
| 209 |
f"<b>Queue:</b> {running} running · {waiting} waiting\n"
|
| 210 |
f"<b>Log channel:</b> {('<code>%s</code>' % log_channel) if log_channel else 'not set (LOG_CHANNEL)'}\n\n"
|
| 211 |
+
"Commands: /addadmin <id> · /deladmin <id> · /admins · /smedia"
|
| 212 |
)
|
| 213 |
rows: List[Row] = [
|
| 214 |
[("📊 Stats", "adm:stats"), ("🟢 Status", "adm:status")],
|
|
|
|
| 217 |
return text, rows
|
| 218 |
|
| 219 |
|
| 220 |
+
# ---------------------------------------------------------------------------
|
| 221 |
+
# saved formats
|
| 222 |
+
# ---------------------------------------------------------------------------
|
| 223 |
+
|
| 224 |
+
FORMAT_FIELD_LABELS = (
|
| 225 |
+
("title", "Title"), ("author", "Author"), ("artist", "Artist"),
|
| 226 |
+
("audio", "Audio"), ("subtitle", "Subtitle"), ("video", "Video"),
|
| 227 |
+
)
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def _format_buttons(formats: Sequence[Dict[str, Any]], prefix: str) -> List[Row]:
|
| 231 |
+
rows: List[Row] = []
|
| 232 |
+
row: Row = []
|
| 233 |
+
for f in formats:
|
| 234 |
+
row.append((clip(f["name"], 24), f"{prefix}:{f['id']}"))
|
| 235 |
+
if len(row) == 2:
|
| 236 |
+
rows.append(row)
|
| 237 |
+
row = []
|
| 238 |
+
if row:
|
| 239 |
+
rows.append(row)
|
| 240 |
+
return rows
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
def formats_menu(formats: Sequence[Dict[str, Any]], limit: int) -> Panel:
|
| 244 |
+
text = (
|
| 245 |
+
f"<b>🗂 FORMATS</b> ({len(formats)}/{limit})\n\n"
|
| 246 |
+
"A format saves its own template, thumbnail, metadata titles and Keep Audio / Keep Subtitle lists "
|
| 247 |
+
"under a name, like <i>One Piece</i> or <i>Dragon Ball</i>.\n"
|
| 248 |
+
"Tap one to view or edit it, then use /rename to apply it to your files."
|
| 249 |
+
)
|
| 250 |
+
if not formats:
|
| 251 |
+
text += "\n\nYou have no formats yet."
|
| 252 |
+
rows = _format_buttons(formats, "fm:v")
|
| 253 |
+
if len(formats) < limit:
|
| 254 |
+
rows.append([("➕ New format", "fm:new")])
|
| 255 |
+
else:
|
| 256 |
+
text += f"\n\nYou have all {limit} formats. Delete one to add another."
|
| 257 |
+
return text, rows + nav("home")
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
def format_view(fmt: Dict[str, Any]) -> Panel:
|
| 261 |
+
m = fmt["meta"]
|
| 262 |
+
lines = [
|
| 263 |
+
f"<b>🗂 {esc(clip(fmt['name'], 30))}</b>",
|
| 264 |
+
"",
|
| 265 |
+
f"{DOT} <b>Template</b> ▸ {_code(fmt['template'], 60)}",
|
| 266 |
+
f"{DOT} <b>Thumbnail</b> ▸ " + ("✅ saved" if fmt.get("has_thumb") else "— (your default thumbnail is used)"),
|
| 267 |
+
]
|
| 268 |
+
for key, label in FORMAT_FIELD_LABELS:
|
| 269 |
+
lines.append(f"{DOT} <b>{label}</b> ▸ {_code(m.get(key, ''), 50)}")
|
| 270 |
+
lines.append(f"{DOT} <b>Keep Audio</b> ▸ {esc(clip(fmt_tokens(fmt['keep_audio']), 50))}")
|
| 271 |
+
lines.append(f"{DOT} <b>Keep Subtitle</b> ▸ {esc(clip(fmt_tokens(fmt['keep_subtitle']), 50))}")
|
| 272 |
+
lines += ["", "Use it with /rename. Mode, caption, file type and the rest come from your normal settings."]
|
| 273 |
+
fid = fmt["id"]
|
| 274 |
+
thumb_row: Row = [("Thumbnail", f"fm:th:{fid}")]
|
| 275 |
+
if fmt.get("has_thumb"):
|
| 276 |
+
thumb_row.append(("🗑 Delete Thumb", f"fm:thd:{fid}"))
|
| 277 |
+
rows: List[Row] = [
|
| 278 |
+
[("Name", f"fm:in:{fid}:name"), ("Template", f"fm:in:{fid}:template")],
|
| 279 |
+
thumb_row,
|
| 280 |
+
[("Title", f"fm:in:{fid}:title"), ("Author", f"fm:in:{fid}:author"), ("Artist", f"fm:in:{fid}:artist")],
|
| 281 |
+
[("Audio", f"fm:in:{fid}:audio"), ("Subtitle", f"fm:in:{fid}:subtitle"), ("Video", f"fm:in:{fid}:video")],
|
| 282 |
+
[("Keep Audio", f"fm:in:{fid}:keep_audio"), ("Keep Subtitle", f"fm:in:{fid}:keep_subtitle")],
|
| 283 |
+
[("🗑 Delete format", f"fm:del:{fid}")],
|
| 284 |
+
] + nav("formats")
|
| 285 |
+
return "\n".join(lines), rows
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
def format_delete_confirm(fmt: Dict[str, Any]) -> Panel:
|
| 289 |
+
text = f"<b>Delete the format “{esc(clip(fmt['name'], 30))}”?</b>\n\nIts template, thumbnail and metadata are removed. This can't be undone."
|
| 290 |
+
return text, [[("🗑 Yes, delete", f"fm:ok:{fmt['id']}"), ("◀ Keep it", f"fm:v:{fmt['id']}")]]
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
def rename_picker(formats: Sequence[Dict[str, Any]]) -> Panel:
|
| 294 |
+
if not formats:
|
| 295 |
+
text = (
|
| 296 |
+
"<b>🏷 RENAME</b>\n\nYou have no saved formats yet.\n"
|
| 297 |
+
"A format keeps a template, thumbnail and metadata under a name, like <i>One Piece</i>. Create one, then come back here."
|
| 298 |
+
)
|
| 299 |
+
return text, [[("➕ Create a format", "fm:new")], [("✖ Close", "x:close")]]
|
| 300 |
+
text = "<b>🏷 RENAME</b>\n\nChoose the format to rename with. I'll ask for your files next."
|
| 301 |
+
rows = _format_buttons(formats, "rn:p") + [[("⚙️ Manage formats", "m:formats"), ("✖ Close", "x:close")]]
|
| 302 |
+
return text, rows
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
def rename_ready(fmt: Dict[str, Any], minutes: int) -> Panel:
|
| 306 |
+
text = (
|
| 307 |
+
f"✅ <b>{esc(clip(fmt['name'], 30))}</b> selected.\n\n"
|
| 308 |
+
"Now send me the file to rename. I'll apply this format to it and send it back.\n"
|
| 309 |
+
"It works for one file at a time: for another file, send /rename again.\n"
|
| 310 |
+
f"I'll wait {minutes} minutes. /cancel to stop."
|
| 311 |
+
)
|
| 312 |
+
return text, [[("🔄 Change format", "rn:menu"), ("⏹ Stop", "rn:stop")]]
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
def _detail_lines(info: Any) -> str:
|
| 316 |
+
"""Title, Season, Episode and Quality always (``None`` when not found); Year and Codec only when found."""
|
| 317 |
+
fields = [("Title", info.title), ("Season", info.season), ("Episode", info.episode), ("Quality", info.quality)]
|
| 318 |
+
fields += [(label, value) for label, value in (("Year", info.year), ("Codec", info.codec)) if value]
|
| 319 |
+
return "\n".join(f"➦ {label} ⇛ <code>{esc(clip(value, 60)) if value else 'None'}</code>" for label, value in fields)
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
def check_text(mode: str, name_info: Any, caption_info: Any, new_name: str) -> str:
|
| 323 |
+
"""What /check shows: the details read from the file name and from the caption, and the name they give."""
|
| 324 |
+
return (
|
| 325 |
+
"<b>Yᴏᴜ Cᴀɴ Cʜᴀɴɢᴇ Mᴏᴅᴇ Bʏ</b> /mode\n\n"
|
| 326 |
+
f"<b>Rᴇɴᴀᴍɪɴɢ Mᴏᴅᴇ:</b> <code>{esc(mode.upper())}</code>\n\n"
|
| 327 |
+
f"<b>Fɪʟᴇɴᴀᴍᴇ:</b>\n{_detail_lines(name_info)}\n\n"
|
| 328 |
+
f"<b>Cᴀᴘᴛɪᴏɴ Mᴏᴅᴇ:</b>\n{_detail_lines(caption_info)}\n\n"
|
| 329 |
+
f"<b>Nᴇᴡ Nᴀᴍᴇ:</b>\n<code>{esc(clip(new_name, 150))}</code>"
|
| 330 |
+
)
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
def ethumb_prompt(minutes: int) -> Panel:
|
| 334 |
+
text = (
|
| 335 |
+
"<b>🖼 EXTRACT THUMBNAIL</b>\n\n"
|
| 336 |
+
"Send me a file or video and I'll send back its thumbnail.\n"
|
| 337 |
+
"It works for one file at a time: for another file, send /ethumb again.\n"
|
| 338 |
+
f"I'll wait {minutes} minutes. /cancel to stop."
|
| 339 |
+
)
|
| 340 |
+
return text, [[("⏹ Stop", "et:stop")]]
|
| 341 |
+
|
| 342 |
+
|
| 343 |
+
RESIZE_PRESETS = ((320, "Thumbnail · 320 px"), (640, "640 px"), (1280, "1280 px"))
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
def describe_box(box: Tuple[int, int]) -> str:
|
| 347 |
+
width, height = box
|
| 348 |
+
return f"{width} px" if width == height else f"{width}×{height}"
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
def resize_menu() -> Panel:
|
| 352 |
+
text = (
|
| 353 |
+
"<b>🖼 RESIZE IMAGE</b>\n\n"
|
| 354 |
+
"Choose the size. The picture keeps its shape and fits inside it, and comes back as a JPEG. "
|
| 355 |
+
"Thumbnail size (320 px) stays under 200 KB, which is what Telegram accepts.\n\n"
|
| 356 |
+
"Or send the size with the command, like <code>/resize 320x180</code>."
|
| 357 |
+
)
|
| 358 |
+
rows: List[Row] = [[(label, f"rz:s:{side}")] for side, label in RESIZE_PRESETS]
|
| 359 |
+
rows.append([("✏️ Custom size", "rz:custom")])
|
| 360 |
+
return text, rows + [[("✖ Close", "x:close")]]
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
def resize_ready(box: Tuple[int, int], minutes: int) -> Panel:
|
| 364 |
+
text = (
|
| 365 |
+
f"✅ Size: <b>{describe_box(box)}</b>\n\n"
|
| 366 |
+
"Now send me the image (a photo, or an image sent as a file). I'll resize it and send it back.\n"
|
| 367 |
+
"It works for one image at a time: for another, send /resize again.\n"
|
| 368 |
+
f"I'll wait {minutes} minutes. /cancel to stop."
|
| 369 |
+
)
|
| 370 |
+
return text, [[("🔄 Change size", "rz:menu"), ("⏹ Stop", "rz:stop")]]
|
| 371 |
+
|
| 372 |
+
|
| 373 |
+
RESIZE_CUSTOM_PROMPT = (
|
| 374 |
+
"<b>Send the size</b> as width and height, like <code>320x180</code>, or one number like <code>320</code> "
|
| 375 |
+
"for a square box (16 to 4096 pixels). The picture keeps its shape and fits inside the box."
|
| 376 |
+
)
|
| 377 |
+
|
| 378 |
+
|
| 379 |
+
def smedia_menu(current: str) -> Panel:
|
| 380 |
+
"""/smedia: ``current`` is what /start shows above the menu now: ``image``, ``gif`` or ``""`` (nothing)."""
|
| 381 |
+
shown = {"image": "an image", "gif": "a GIF"}.get(current, "nothing, just the menu")
|
| 382 |
+
text = (
|
| 383 |
+
"<b>🖼 START MEDIA</b>\n\n"
|
| 384 |
+
f"/start shows: <b>{shown}</b>\n\n"
|
| 385 |
+
"Choose what goes above the menu. I keep a copy of it, so it survives restarts."
|
| 386 |
+
)
|
| 387 |
+
rows: List[Row] = [[("🖼 Image", "sm:image"), ("🎞 GIF", "sm:gif")]]
|
| 388 |
+
if current:
|
| 389 |
+
rows.append([("🗑 Remove", "sm:rm")])
|
| 390 |
+
return text, rows + [[("✖ Close", "x:close")]]
|
| 391 |
+
|
| 392 |
+
|
| 393 |
+
def smedia_prompt(kind: str, minutes: int) -> Panel:
|
| 394 |
+
what = "the image (a photo, or an image sent as a file)" if kind == "image" else "the GIF (a Telegram GIF or animation)"
|
| 395 |
+
text = (
|
| 396 |
+
f"<b>{'🖼' if kind == 'image' else '🎞'} Send {what}.</b>\n\n"
|
| 397 |
+
"I'll set it as the /start media and show you the result.\n"
|
| 398 |
+
f"I'll wait {minutes} minutes. /cancel to stop."
|
| 399 |
+
)
|
| 400 |
+
return text, [[("◀ Back", "sm:menu"), ("⏹ Stop", "sm:stop")]]
|
| 401 |
+
|
| 402 |
+
|
| 403 |
+
def sequence_intro(limit: int) -> Panel:
|
| 404 |
+
text = (
|
| 405 |
+
"<b>📑 SEQUENCE</b>\n\n"
|
| 406 |
+
f"Send me the files to put in order (up to {limit}), in any order. I only collect them for now.\n"
|
| 407 |
+
"When they're all here, send /sort: I'll send them back in episode order, then list the missing and repeated episodes.\n"
|
| 408 |
+
"The episode is read from the file name, or from the caption when your /mode is Caption.\n"
|
| 409 |
+
"/cancel to throw them away."
|
| 410 |
+
)
|
| 411 |
+
return text, [[("✖ Cancel", "sq:cx")]]
|
| 412 |
+
|
| 413 |
+
|
| 414 |
+
def sequence_status(count: int, limit: int) -> Panel:
|
| 415 |
+
text = f"📥 <b>{count} file{'s' if count != 1 else ''} collected</b>"
|
| 416 |
+
if count >= limit:
|
| 417 |
+
text += f"\n\nThat's the most I can sort at once ({limit}). Send /sort now."
|
| 418 |
+
else:
|
| 419 |
+
text += "\n\nSend more files, or /sort when you're done."
|
| 420 |
+
return text, [[("🔢 Sort now", "sq:sort"), ("🗑 Discard", "sq:cx")]]
|
| 421 |
+
|
| 422 |
+
|
| 423 |
# ---------------------------------------------------------------------------
|
| 424 |
# input prompts
|
| 425 |
# ---------------------------------------------------------------------------
|
|
|
|
| 467 |
return PROMPTS[kind], [[("✖ Cancel", f"in:cancel:{back}")]]
|
| 468 |
|
| 469 |
|
| 470 |
+
FORMAT_PROMPTS: Dict[str, str] = {
|
| 471 |
+
"new": "<b>Send a name for the new format</b>, like <code>One Piece</code> or <code>Dragon Ball</code> (30 characters max).",
|
| 472 |
+
"name": "<b>Send the new name</b> for this format (30 characters max).",
|
| 473 |
+
"thumb": "<b>Send a photo</b> to use as this format's thumbnail.",
|
| 474 |
+
"template": PROMPTS["template"],
|
| 475 |
+
"title": PROMPTS["mt_title"], "author": PROMPTS["mt_author"], "artist": PROMPTS["mt_artist"],
|
| 476 |
+
"audio": PROMPTS["mt_audio"], "subtitle": PROMPTS["mt_subtitle"], "video": PROMPTS["mt_video"],
|
| 477 |
+
"keep_audio": PROMPTS["keep_audio"], "keep_subtitle": PROMPTS["keep_subtitle"],
|
| 478 |
+
}
|
| 479 |
+
|
| 480 |
+
|
| 481 |
+
def format_prompt(field: str, fid: int) -> Panel:
|
| 482 |
+
"""The "send me the value" screen for one field of a format (``fid`` 0 = a format that is being created)."""
|
| 483 |
+
cancel = f"fm:cx:{fid}" if fid else "m:formats"
|
| 484 |
+
return FORMAT_PROMPTS[field], [[("✖ Cancel", cancel)]]
|
| 485 |
+
|
| 486 |
+
|
| 487 |
def known_languages() -> str:
|
| 488 |
return ", ".join(sorted(LANGUAGES))
|
bot/parser.py
CHANGED
|
@@ -443,14 +443,6 @@ def compute_new_name(
|
|
| 443 |
return finalize_name(out_stem, ext), info
|
| 444 |
|
| 445 |
|
| 446 |
-
def build_manual_name(text: str, original_ext: str) -> str:
|
| 447 |
-
"""Name typed by the user (``/rename``): the original extension is always kept."""
|
| 448 |
-
text = sanitize(text)
|
| 449 |
-
stem, typed_ext = split_ext(text, known_only=True)
|
| 450 |
-
ext = original_ext or typed_ext
|
| 451 |
-
return finalize_name(stem or "file", ext)
|
| 452 |
-
|
| 453 |
-
|
| 454 |
# ---------------------------------------------------------------------------
|
| 455 |
# captions
|
| 456 |
# ---------------------------------------------------------------------------
|
|
|
|
| 443 |
return finalize_name(out_stem, ext), info
|
| 444 |
|
| 445 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 446 |
# ---------------------------------------------------------------------------
|
| 447 |
# captions
|
| 448 |
# ---------------------------------------------------------------------------
|
bot/pipeline.py
CHANGED
|
@@ -16,6 +16,7 @@ from . import media
|
|
| 16 |
from .config import Config
|
| 17 |
from .db import Database
|
| 18 |
from .errors import Cancelled, MediaError
|
|
|
|
| 19 |
from .parser import build_values, parse_info, render_caption, split_ext
|
| 20 |
from .progress import ProgressReporter
|
| 21 |
from .queue import Job
|
|
@@ -95,6 +96,8 @@ class Renamer:
|
|
| 95 |
# ------------------------------------------------------------------
|
| 96 |
async def process(self, job: Job) -> None:
|
| 97 |
s = await self.db.get_settings(job.user_id)
|
|
|
|
|
|
|
| 98 |
work = Path(self.cfg.temp_dir) / f"job{job.id}"
|
| 99 |
work.mkdir(parents=True, exist_ok=True)
|
| 100 |
reporter = ProgressReporter(self.client, job.status_msg, job.id, job.new_name, self.cfg.progress_interval)
|
|
@@ -178,7 +181,9 @@ class Renamer:
|
|
| 178 |
)
|
| 179 |
)
|
| 180 |
|
| 181 |
-
thumb = await self.db.
|
|
|
|
|
|
|
| 182 |
if thumb is None and ext.lower() in media.VIDEO_CONTAINERS and info is not None and info.of("video"):
|
| 183 |
thumb_path = work / "thumb.jpg"
|
| 184 |
if await media.make_video_thumbnail(current, thumb_path, info.duration, job.cancel):
|
|
|
|
| 16 |
from .config import Config
|
| 17 |
from .db import Database
|
| 18 |
from .errors import Cancelled, MediaError
|
| 19 |
+
from .formats import apply_format
|
| 20 |
from .parser import build_values, parse_info, render_caption, split_ext
|
| 21 |
from .progress import ProgressReporter
|
| 22 |
from .queue import Job
|
|
|
|
| 96 |
# ------------------------------------------------------------------
|
| 97 |
async def process(self, job: Job) -> None:
|
| 98 |
s = await self.db.get_settings(job.user_id)
|
| 99 |
+
if job.fmt:
|
| 100 |
+
s = apply_format(s, job.fmt)
|
| 101 |
work = Path(self.cfg.temp_dir) / f"job{job.id}"
|
| 102 |
work.mkdir(parents=True, exist_ok=True)
|
| 103 |
reporter = ProgressReporter(self.client, job.status_msg, job.id, job.new_name, self.cfg.progress_interval)
|
|
|
|
| 181 |
)
|
| 182 |
)
|
| 183 |
|
| 184 |
+
thumb = await self.db.get_format_thumb(job.user_id, job.fmt["id"]) if job.fmt else None
|
| 185 |
+
if thumb is None:
|
| 186 |
+
thumb = await self.db.get_thumb(job.user_id)
|
| 187 |
if thumb is None and ext.lower() in media.VIDEO_CONTAINERS and info is not None and info.of("video"):
|
| 188 |
thumb_path = work / "thumb.jpg"
|
| 189 |
if await media.make_video_thumbnail(current, thumb_path, info.duration, job.cancel):
|
bot/queue.py
CHANGED
|
@@ -20,7 +20,7 @@ class Job:
|
|
| 20 |
file_name: str
|
| 21 |
size: int
|
| 22 |
new_name: str
|
| 23 |
-
|
| 24 |
notes: List[str] = field(default_factory=list) # warnings shown with the queued / finished message
|
| 25 |
order: int = 0 # id of the message that triggered the job: files are processed oldest message first
|
| 26 |
status_msg: Any = None
|
|
|
|
| 20 |
file_name: str
|
| 21 |
size: int
|
| 22 |
new_name: str
|
| 23 |
+
fmt: Optional[Dict[str, Any]] = None # the saved /rename format this job uses (a snapshot taken when it was queued)
|
| 24 |
notes: List[str] = field(default_factory=list) # warnings shown with the queued / finished message
|
| 25 |
order: int = 0 # id of the message that triggered the job: files are processed oldest message first
|
| 26 |
status_msg: Any = None
|
bot/sequence.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""/sequence + /sort: put files in episode order and report which episodes are missing or repeated."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import asyncio
|
| 5 |
+
from dataclasses import dataclass, field
|
| 6 |
+
from typing import Dict, List, Optional, Sequence, Tuple
|
| 7 |
+
|
| 8 |
+
from .parser import parse_caption, parse_info, split_ext
|
| 9 |
+
from .utils import clip, esc
|
| 10 |
+
|
| 11 |
+
MAX_LISTED = 12 # names shown per section of the report
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@dataclass
|
| 15 |
+
class SeqItem:
|
| 16 |
+
msg_id: int
|
| 17 |
+
name: str
|
| 18 |
+
caption: str = ""
|
| 19 |
+
season: Optional[int] = None
|
| 20 |
+
episode: Optional[int] = None
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@dataclass
|
| 24 |
+
class SequenceState:
|
| 25 |
+
"""What /sequence has collected so far for one user."""
|
| 26 |
+
|
| 27 |
+
items: List[SeqItem] = field(default_factory=list)
|
| 28 |
+
status_id: Optional[int] = None # the "N files collected" message, replaced after each burst of files
|
| 29 |
+
task: Optional["asyncio.Future[None]"] = None # the debounced status update that is waiting for the burst to end
|
| 30 |
+
version: int = 0 # bumped for every file, so the status task can tell whether more are still arriving
|
| 31 |
+
closed: bool = False # /sort or /cancel ended the collection
|
| 32 |
+
full_noted: bool = False # the "limit reached" notice was shown
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def detect(name: str, caption: str, mode: str) -> Tuple[Optional[int], Optional[int]]:
|
| 36 |
+
"""``(season, episode)`` read from the file name and the caption, the one the rename mode names first.
|
| 37 |
+
|
| 38 |
+
The season is 1 when only an episode number is found; both are ``None`` when there is no episode number.
|
| 39 |
+
"""
|
| 40 |
+
from_name = lambda: parse_info(split_ext(name)[0]) # noqa: E731
|
| 41 |
+
from_caption = lambda: parse_caption(caption) # noqa: E731
|
| 42 |
+
for read in ((from_caption, from_name) if mode == "caption" else (from_name, from_caption)):
|
| 43 |
+
info = read()
|
| 44 |
+
if info.episode.isdigit():
|
| 45 |
+
return (int(info.season) if info.season.isdigit() else 1), int(info.episode)
|
| 46 |
+
return None, None
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@dataclass
|
| 50 |
+
class Analysis:
|
| 51 |
+
ordered: List[SeqItem] # numbered files by season / episode, then the others in the order they were sent
|
| 52 |
+
unknown: List[SeqItem] # no episode number found
|
| 53 |
+
missing: Dict[int, List[int]] # season -> episode numbers absent between its first and last
|
| 54 |
+
repeated: Dict[Tuple[int, int], List[SeqItem]] # (season, episode) -> the files that share it
|
| 55 |
+
first: Optional[Tuple[int, int]] = None
|
| 56 |
+
last: Optional[Tuple[int, int]] = None
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def analyse(items: Sequence[SeqItem], mode: str) -> Analysis:
|
| 60 |
+
for item in items:
|
| 61 |
+
item.season, item.episode = detect(item.name, item.caption, mode)
|
| 62 |
+
numbered = sorted((i for i in items if i.episode is not None), key=lambda i: (i.season, i.episode, i.msg_id))
|
| 63 |
+
unknown = sorted((i for i in items if i.episode is None), key=lambda i: i.msg_id)
|
| 64 |
+
|
| 65 |
+
by_season: Dict[int, List[int]] = {}
|
| 66 |
+
groups: Dict[Tuple[int, int], List[SeqItem]] = {}
|
| 67 |
+
for item in numbered:
|
| 68 |
+
by_season.setdefault(item.season, []).append(item.episode)
|
| 69 |
+
groups.setdefault((item.season, item.episode), []).append(item)
|
| 70 |
+
missing: Dict[int, List[int]] = {}
|
| 71 |
+
for season, episodes in by_season.items():
|
| 72 |
+
have = set(episodes)
|
| 73 |
+
gaps = [n for n in range(min(have), max(have) + 1) if n not in have]
|
| 74 |
+
if gaps:
|
| 75 |
+
missing[season] = gaps
|
| 76 |
+
return Analysis(
|
| 77 |
+
ordered=numbered + unknown,
|
| 78 |
+
unknown=unknown,
|
| 79 |
+
missing=missing,
|
| 80 |
+
repeated={k: v for k, v in groups.items() if len(v) > 1},
|
| 81 |
+
first=(numbered[0].season, numbered[0].episode) if numbered else None,
|
| 82 |
+
last=(numbered[-1].season, numbered[-1].episode) if numbered else None,
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _ep(n: int) -> str:
|
| 87 |
+
return f"E{n:02d}"
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def ranges(numbers: Sequence[int], limit: int = 0) -> str:
|
| 91 |
+
"""``[3, 7, 8, 9]`` -> ``E03, E07–E09``; with ``limit`` the text is cut after that many characters."""
|
| 92 |
+
spans: List[Tuple[int, int]] = []
|
| 93 |
+
for n in sorted(set(numbers)):
|
| 94 |
+
if spans and n == spans[-1][1] + 1:
|
| 95 |
+
spans[-1] = (spans[-1][0], n)
|
| 96 |
+
else:
|
| 97 |
+
spans.append((n, n))
|
| 98 |
+
texts = [_ep(a) if a == b else f"{_ep(a)}–{_ep(b)}" for a, b in spans]
|
| 99 |
+
if limit and len(", ".join(texts)) > limit:
|
| 100 |
+
kept: List[str] = []
|
| 101 |
+
used = 0
|
| 102 |
+
for i, text in enumerate(texts):
|
| 103 |
+
if used + len(text) + 2 > limit - 24:
|
| 104 |
+
kept.append(f"… and {sum(b - a + 1 for a, b in spans[i:])} more")
|
| 105 |
+
break
|
| 106 |
+
kept.append(text)
|
| 107 |
+
used += len(text) + 2
|
| 108 |
+
return ", ".join(kept)
|
| 109 |
+
return ", ".join(texts)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def _label(season: int, episode: int, multi: bool) -> str:
|
| 113 |
+
return f"S{season:02d}{_ep(episode)}" if multi else _ep(episode)
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def _bullets(lines: Sequence[str]) -> List[str]:
|
| 117 |
+
out = [f"• {line}" for line in lines[:MAX_LISTED]]
|
| 118 |
+
if len(lines) > MAX_LISTED:
|
| 119 |
+
out.append(f"… and {len(lines) - MAX_LISTED} more")
|
| 120 |
+
return out
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def report_sections(a: Analysis, total: int, sent: int, failed: Sequence[SeqItem] = (), note: str = "") -> List[str]:
|
| 124 |
+
"""The pieces of the message shown after the files were sent (HTML), each short enough on its own."""
|
| 125 |
+
seasons = {i.season for i in a.ordered if i.season is not None}
|
| 126 |
+
multi = len(seasons) > 1
|
| 127 |
+
head = f"✅ <b>Sorted {sent} of {total} files</b>" if sent != total else f"✅ <b>Sorted {total} file{'s' if total != 1 else ''}</b>"
|
| 128 |
+
if a.first and a.last:
|
| 129 |
+
head += f" · {_label(*a.first, multi)} → {_label(*a.last, multi)}"
|
| 130 |
+
parts = [head]
|
| 131 |
+
if note:
|
| 132 |
+
parts.append(note)
|
| 133 |
+
|
| 134 |
+
if a.missing:
|
| 135 |
+
lines = []
|
| 136 |
+
for season in sorted(a.missing)[:MAX_LISTED]:
|
| 137 |
+
text = ranges(a.missing[season], limit=300)
|
| 138 |
+
lines.append(f"S{season:02d}: {text}" if multi else text)
|
| 139 |
+
if len(a.missing) > MAX_LISTED:
|
| 140 |
+
lines.append(f"… and {len(a.missing) - MAX_LISTED} more seasons")
|
| 141 |
+
parts.append("⚠️ <b>Missing episodes</b>\n" + "\n".join(lines))
|
| 142 |
+
if a.repeated:
|
| 143 |
+
lines = []
|
| 144 |
+
for (season, episode), files in sorted(a.repeated.items()):
|
| 145 |
+
names = ", ".join(f"<code>{esc(clip(f.name, 40))}</code>" for f in files[:3])
|
| 146 |
+
more = f" +{len(files) - 3} more" if len(files) > 3 else ""
|
| 147 |
+
lines.append(f"{_label(season, episode, multi)} ×{len(files)}: {names}{more}")
|
| 148 |
+
parts.append("🔁 <b>Repeated episodes</b>\n" + "\n".join(_bullets(lines)))
|
| 149 |
+
if a.unknown:
|
| 150 |
+
lines = [f"<code>{esc(clip(i.name, 50))}</code>" for i in a.unknown]
|
| 151 |
+
parts.append("❓ <b>No episode number found</b> (sent last)\n" + "\n".join(_bullets(lines)))
|
| 152 |
+
if failed:
|
| 153 |
+
lines = [f"<code>{esc(clip(i.name, 50))}</code>" for i in failed]
|
| 154 |
+
parts.append("🚫 <b>Couldn't be sent</b>\n" + "\n".join(_bullets(lines)))
|
| 155 |
+
if a.first and not (a.missing or a.repeated):
|
| 156 |
+
parts.append("No missing or repeated episodes.")
|
| 157 |
+
return parts
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def format_report(
|
| 161 |
+
a: Analysis, total: int, sent: int, failed: Sequence[SeqItem] = (), note: str = "", limit: int = 3600
|
| 162 |
+
) -> List[str]:
|
| 163 |
+
"""The report as one message, or a few when the lists are long (Telegram allows about 4000 characters)."""
|
| 164 |
+
messages: List[str] = []
|
| 165 |
+
current = ""
|
| 166 |
+
for part in report_sections(a, total, sent, failed, note):
|
| 167 |
+
if current and len(current) + 2 + len(part) > limit:
|
| 168 |
+
messages.append(current)
|
| 169 |
+
current = part
|
| 170 |
+
else:
|
| 171 |
+
current = f"{current}\n\n{part}" if current else part
|
| 172 |
+
messages.append(current)
|
| 173 |
+
return messages
|
bot/speed.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""/speed: an Ookla Speedtest run from the bot's own network.
|
| 2 |
+
|
| 3 |
+
The official Speedtest CLI is used when it is installed. If it is missing or fails, the ``speedtest-cli`` Python
|
| 4 |
+
package (also an Ookla client) is tried. Both give the same figures; only the parsing differs.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import asyncio
|
| 9 |
+
import html
|
| 10 |
+
import json
|
| 11 |
+
import logging
|
| 12 |
+
import os
|
| 13 |
+
import shutil
|
| 14 |
+
from dataclasses import dataclass
|
| 15 |
+
from typing import Any, Dict, List, Optional
|
| 16 |
+
|
| 17 |
+
from .utils import esc
|
| 18 |
+
|
| 19 |
+
log = logging.getLogger(__name__)
|
| 20 |
+
|
| 21 |
+
CLI_TIMEOUT = 120 # seconds for one run of either client
|
| 22 |
+
CANDIDATES = ("ookla-speedtest", "speedtest") # the Dockerfile installs the official binary as ookla-speedtest
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class SpeedError(RuntimeError):
|
| 26 |
+
"""The speed test could not be run; the message is safe to show to the user."""
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@dataclass
|
| 30 |
+
class SpeedResult:
|
| 31 |
+
download_mbps: float
|
| 32 |
+
upload_mbps: float
|
| 33 |
+
ping_ms: float
|
| 34 |
+
jitter_ms: Optional[float] = None
|
| 35 |
+
packet_loss: Optional[float] = None
|
| 36 |
+
isp: str = ""
|
| 37 |
+
server: str = ""
|
| 38 |
+
location: str = ""
|
| 39 |
+
url: str = "" # the speedtest.net page of this result
|
| 40 |
+
source: str = ""
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _num(value: Any) -> Optional[float]:
|
| 44 |
+
try:
|
| 45 |
+
return float(value)
|
| 46 |
+
except (TypeError, ValueError):
|
| 47 |
+
return None
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def parse_cli(data: Dict[str, Any]) -> SpeedResult:
|
| 51 |
+
"""The JSON the official CLI prints (bandwidth is in bytes per second)."""
|
| 52 |
+
download = _num((data.get("download") or {}).get("bandwidth"))
|
| 53 |
+
upload = _num((data.get("upload") or {}).get("bandwidth"))
|
| 54 |
+
if download is None or upload is None:
|
| 55 |
+
raise SpeedError("the Speedtest CLI gave no download or upload figure")
|
| 56 |
+
ping = data.get("ping") or {}
|
| 57 |
+
server = data.get("server") or {}
|
| 58 |
+
location = ", ".join(part for part in (server.get("location"), server.get("country")) if part)
|
| 59 |
+
return SpeedResult(
|
| 60 |
+
download_mbps=download * 8 / 1e6,
|
| 61 |
+
upload_mbps=upload * 8 / 1e6,
|
| 62 |
+
ping_ms=_num(ping.get("latency")) or 0.0,
|
| 63 |
+
jitter_ms=_num(ping.get("jitter")),
|
| 64 |
+
packet_loss=_num(data.get("packetLoss")),
|
| 65 |
+
isp=str(data.get("isp") or ""),
|
| 66 |
+
server=str(server.get("name") or server.get("host") or ""),
|
| 67 |
+
location=location,
|
| 68 |
+
url=str((data.get("result") or {}).get("url") or ""),
|
| 69 |
+
source="Ookla Speedtest CLI",
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def parse_python(data: Dict[str, Any]) -> SpeedResult:
|
| 74 |
+
"""``Speedtest.results.dict()`` of the speedtest-cli package (speeds are in bits per second)."""
|
| 75 |
+
download, upload = _num(data.get("download")), _num(data.get("upload"))
|
| 76 |
+
if download is None or upload is None:
|
| 77 |
+
raise SpeedError("speedtest-cli gave no download or upload figure")
|
| 78 |
+
server = data.get("server") or {}
|
| 79 |
+
client = data.get("client") or {}
|
| 80 |
+
location = ", ".join(part for part in (server.get("name"), server.get("country")) if part)
|
| 81 |
+
return SpeedResult(
|
| 82 |
+
download_mbps=download / 1e6,
|
| 83 |
+
upload_mbps=upload / 1e6,
|
| 84 |
+
ping_ms=_num(data.get("ping")) or 0.0,
|
| 85 |
+
isp=str(client.get("isp") or ""),
|
| 86 |
+
server=str(server.get("sponsor") or ""),
|
| 87 |
+
location=location,
|
| 88 |
+
url=str(data.get("share") or ""),
|
| 89 |
+
source="speedtest-cli (Ookla)",
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def _last_result(stdout: str) -> Optional[Dict[str, Any]]:
|
| 94 |
+
"""The ``{"type": "result", ...}`` line of the CLI's JSON output."""
|
| 95 |
+
found = None
|
| 96 |
+
for line in stdout.splitlines():
|
| 97 |
+
line = line.strip()
|
| 98 |
+
if not line.startswith("{"):
|
| 99 |
+
continue
|
| 100 |
+
try:
|
| 101 |
+
obj = json.loads(line)
|
| 102 |
+
except ValueError:
|
| 103 |
+
continue
|
| 104 |
+
if isinstance(obj, dict) and obj.get("type") == "result":
|
| 105 |
+
found = obj
|
| 106 |
+
return found
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def _log_message(stdout: str, stderr: str) -> str:
|
| 110 |
+
"""Why the CLI failed, as far as it says."""
|
| 111 |
+
for text in (stdout, stderr):
|
| 112 |
+
for line in reversed(text.splitlines()):
|
| 113 |
+
line = line.strip()
|
| 114 |
+
if line.startswith("{"):
|
| 115 |
+
try:
|
| 116 |
+
obj = json.loads(line)
|
| 117 |
+
except ValueError:
|
| 118 |
+
continue
|
| 119 |
+
if isinstance(obj, dict) and obj.get("message"):
|
| 120 |
+
return str(obj["message"])
|
| 121 |
+
elif line:
|
| 122 |
+
return line
|
| 123 |
+
return "no output"
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
async def _run(cmd: List[str], timeout: float) -> "tuple[int, str, str]":
|
| 127 |
+
try:
|
| 128 |
+
proc = await asyncio.create_subprocess_exec(
|
| 129 |
+
*cmd, stdin=asyncio.subprocess.DEVNULL, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
| 130 |
+
)
|
| 131 |
+
except (FileNotFoundError, PermissionError, OSError) as exc:
|
| 132 |
+
raise SpeedError(f"{cmd[0]} cannot be started ({exc})") from exc
|
| 133 |
+
try:
|
| 134 |
+
out, err = await asyncio.wait_for(proc.communicate(), timeout)
|
| 135 |
+
except asyncio.TimeoutError:
|
| 136 |
+
proc.kill()
|
| 137 |
+
await proc.wait()
|
| 138 |
+
raise SpeedError("the speed test took too long") from None
|
| 139 |
+
return proc.returncode or 0, out.decode("utf-8", "replace"), err.decode("utf-8", "replace")
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
async def find_ookla_cli() -> Optional[str]:
|
| 143 |
+
"""The official CLI, if installed. ``speedtest`` can also be the Python package's script, so ``--version`` decides."""
|
| 144 |
+
names = [os.environ.get("SPEEDTEST_BIN", "")] + list(CANDIDATES)
|
| 145 |
+
for name in dict.fromkeys(n for n in names if n):
|
| 146 |
+
path = shutil.which(name)
|
| 147 |
+
if not path:
|
| 148 |
+
continue
|
| 149 |
+
try:
|
| 150 |
+
code, out, _err = await _run([path, "--version"], 15)
|
| 151 |
+
except SpeedError:
|
| 152 |
+
continue
|
| 153 |
+
if code == 0 and "ookla" in out.lower():
|
| 154 |
+
return path
|
| 155 |
+
return None
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
async def run_cli(path: str, timeout: float = CLI_TIMEOUT) -> SpeedResult:
|
| 159 |
+
code, out, err = await _run([path, "--accept-license", "--accept-gdpr", "--format=json"], timeout)
|
| 160 |
+
result = _last_result(out)
|
| 161 |
+
if result is None:
|
| 162 |
+
raise SpeedError(f"the Speedtest CLI failed: {_log_message(out, err)}")
|
| 163 |
+
return parse_cli(result)
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def _run_python() -> Dict[str, Any]:
|
| 167 |
+
"""Blocking: one test with the speedtest-cli package."""
|
| 168 |
+
try:
|
| 169 |
+
import speedtest # type: ignore
|
| 170 |
+
except ImportError as exc:
|
| 171 |
+
raise SpeedError("the speedtest-cli package is not installed") from exc
|
| 172 |
+
try:
|
| 173 |
+
test = speedtest.Speedtest(secure=True)
|
| 174 |
+
test.get_best_server()
|
| 175 |
+
test.download()
|
| 176 |
+
test.upload()
|
| 177 |
+
return dict(test.results.dict())
|
| 178 |
+
except Exception as exc: # noqa: BLE001 - the package raises its own family of errors
|
| 179 |
+
raise SpeedError(f"speedtest-cli failed: {exc}") from exc
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
async def run_speedtest() -> SpeedResult:
|
| 183 |
+
"""Run a test with the official CLI, or with the Python client when the CLI is missing or fails."""
|
| 184 |
+
problems: List[str] = []
|
| 185 |
+
cli = await find_ookla_cli()
|
| 186 |
+
if cli is not None:
|
| 187 |
+
try:
|
| 188 |
+
return await run_cli(cli)
|
| 189 |
+
except SpeedError as exc:
|
| 190 |
+
log.warning("Speedtest CLI failed: %s", exc)
|
| 191 |
+
problems.append(str(exc))
|
| 192 |
+
else:
|
| 193 |
+
problems.append("the Speedtest CLI is not installed")
|
| 194 |
+
try:
|
| 195 |
+
data = await asyncio.wait_for(asyncio.to_thread(_run_python), CLI_TIMEOUT)
|
| 196 |
+
return parse_python(data)
|
| 197 |
+
except asyncio.TimeoutError:
|
| 198 |
+
problems.append("speedtest-cli took too long")
|
| 199 |
+
except SpeedError as exc:
|
| 200 |
+
problems.append(str(exc))
|
| 201 |
+
raise SpeedError("; ".join(problems))
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def _mbps(value: float) -> str:
|
| 205 |
+
return f"{value:,.2f} Mbps"
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def format_result(r: SpeedResult) -> str:
|
| 209 |
+
lines = [
|
| 210 |
+
"<b>⚡ SPEEDTEST</b> · Ookla",
|
| 211 |
+
"",
|
| 212 |
+
f"◈ <b>Download</b> ▸ <code>{_mbps(r.download_mbps)}</code>",
|
| 213 |
+
f"◈ <b>Upload</b> ▸ <code>{_mbps(r.upload_mbps)}</code>",
|
| 214 |
+
f"◈ <b>Ping</b> ▸ <code>{r.ping_ms:.2f} ms</code>" + (f" · jitter <code>{r.jitter_ms:.2f} ms</code>" if r.jitter_ms is not None else ""),
|
| 215 |
+
]
|
| 216 |
+
if r.packet_loss is not None:
|
| 217 |
+
lines.append(f"◈ <b>Packet loss</b> ▸ <code>{r.packet_loss:g}%</code>")
|
| 218 |
+
if r.isp:
|
| 219 |
+
lines.append(f"◈ <b>ISP</b> ▸ {esc(r.isp)}")
|
| 220 |
+
server = " · ".join(part for part in (esc(r.server), esc(r.location)) if part)
|
| 221 |
+
if server:
|
| 222 |
+
lines.append(f"◈ <b>Server</b> ▸ {server}")
|
| 223 |
+
if r.url.startswith(("http://", "https://")):
|
| 224 |
+
lines.append(f"◈ <b>Result</b> ▸ <a href=\"{html.escape(r.url, quote=True)}\">speedtest.net</a>")
|
| 225 |
+
lines.append(f"\n<i>Measured from the machine the bot runs on · {esc(r.source)}</i>")
|
| 226 |
+
return "\n".join(lines)
|
bot/telegraph.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""A minimal Telegraph (telegra.ph) client: one account for the bot, pages created from node lists."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import asyncio
|
| 5 |
+
import json
|
| 6 |
+
import logging
|
| 7 |
+
from typing import Any, Awaitable, Callable, Dict, List, Optional
|
| 8 |
+
|
| 9 |
+
import aiohttp
|
| 10 |
+
|
| 11 |
+
log = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
API = "https://api.telegra.ph"
|
| 14 |
+
CONTENT_LIMIT = 60_000 # Telegraph refuses a page whose content is over 64 KB; stay clear of it
|
| 15 |
+
TITLE_LIMIT = 256
|
| 16 |
+
|
| 17 |
+
Node = Dict[str, Any]
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class TelegraphError(RuntimeError):
|
| 21 |
+
"""Telegraph refused or could not be reached; the message is safe to show to the user."""
|
| 22 |
+
|
| 23 |
+
def __init__(self, message: str, code: str = ""):
|
| 24 |
+
super().__init__(message)
|
| 25 |
+
self.code = code
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def content_size(nodes: List[Node]) -> int:
|
| 29 |
+
"""Bytes the content takes as it is sent."""
|
| 30 |
+
return len(dump(nodes).encode("utf-8"))
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def dump(nodes: List[Node]) -> str:
|
| 34 |
+
return json.dumps(nodes, ensure_ascii=False, separators=(",", ":"))
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class Telegraph:
|
| 38 |
+
"""Creates pages under one account. The access token comes from ``load_token`` and is saved with ``save_token``."""
|
| 39 |
+
|
| 40 |
+
def __init__(
|
| 41 |
+
self,
|
| 42 |
+
load_token: Callable[[], Awaitable[Optional[str]]],
|
| 43 |
+
save_token: Callable[[str], Awaitable[None]],
|
| 44 |
+
*,
|
| 45 |
+
api: str = API,
|
| 46 |
+
short_name: str = "AutoRename",
|
| 47 |
+
author: str = "Auto Rename Bot",
|
| 48 |
+
timeout: float = 30,
|
| 49 |
+
):
|
| 50 |
+
self._load = load_token
|
| 51 |
+
self._save = save_token
|
| 52 |
+
self.api = api.rstrip("/")
|
| 53 |
+
self.short_name = short_name
|
| 54 |
+
self.author = author
|
| 55 |
+
self.author_url = ""
|
| 56 |
+
self.timeout = timeout
|
| 57 |
+
self._token: Optional[str] = None
|
| 58 |
+
self._lock = asyncio.Lock()
|
| 59 |
+
|
| 60 |
+
def set_author(self, name: str, url: str = "") -> None:
|
| 61 |
+
"""The name (and link) shown under the title of the pages; the bot's own, once it is known."""
|
| 62 |
+
if name.strip():
|
| 63 |
+
self.author = name.strip()[:128]
|
| 64 |
+
self.author_url = url.strip()[:512]
|
| 65 |
+
|
| 66 |
+
async def _call(self, method: str, **params: str) -> Dict[str, Any]:
|
| 67 |
+
try:
|
| 68 |
+
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=self.timeout)) as session:
|
| 69 |
+
async with session.post(f"{self.api}/{method}", data=params) as resp:
|
| 70 |
+
data = await resp.json(content_type=None)
|
| 71 |
+
except asyncio.TimeoutError:
|
| 72 |
+
raise TelegraphError("Telegraph took too long to answer") from None
|
| 73 |
+
except (aiohttp.ClientError, ValueError) as exc:
|
| 74 |
+
raise TelegraphError(f"I couldn't reach Telegraph ({type(exc).__name__})") from exc
|
| 75 |
+
if not isinstance(data, dict) or not data.get("ok"):
|
| 76 |
+
code = str(data.get("error") if isinstance(data, dict) else "") or "UNKNOWN"
|
| 77 |
+
if code.startswith("FLOOD_WAIT_"):
|
| 78 |
+
raise TelegraphError(f"Telegraph asks to wait {code.rsplit('_', 1)[-1]} seconds", code)
|
| 79 |
+
raise TelegraphError(f"Telegraph said {code}", code)
|
| 80 |
+
result = data.get("result")
|
| 81 |
+
if not isinstance(result, dict):
|
| 82 |
+
raise TelegraphError("Telegraph sent an answer I don't understand")
|
| 83 |
+
return result
|
| 84 |
+
|
| 85 |
+
async def token(self) -> str:
|
| 86 |
+
"""The account's access token: remembered, else loaded, else a new account is made."""
|
| 87 |
+
async with self._lock:
|
| 88 |
+
if self._token:
|
| 89 |
+
return self._token
|
| 90 |
+
saved = await self._load()
|
| 91 |
+
if saved:
|
| 92 |
+
self._token = saved
|
| 93 |
+
return saved
|
| 94 |
+
result = await self._call("createAccount", short_name=self.short_name, author_name=self.author)
|
| 95 |
+
token = str(result.get("access_token") or "")
|
| 96 |
+
if not token:
|
| 97 |
+
raise TelegraphError("Telegraph did not give an access token")
|
| 98 |
+
self._token = token
|
| 99 |
+
await self._save(token)
|
| 100 |
+
return token
|
| 101 |
+
|
| 102 |
+
async def _forget_token(self) -> None:
|
| 103 |
+
async with self._lock:
|
| 104 |
+
self._token = None
|
| 105 |
+
await self._save("")
|
| 106 |
+
|
| 107 |
+
async def create_page(self, title: str, nodes: List[Node]) -> str:
|
| 108 |
+
"""Create a page and return its URL."""
|
| 109 |
+
content = dump(nodes)
|
| 110 |
+
if len(content.encode("utf-8")) > 64 * 1024:
|
| 111 |
+
raise TelegraphError("The page is too big for Telegraph", "CONTENT_TOO_BIG")
|
| 112 |
+
for attempt in range(2):
|
| 113 |
+
token = await self.token()
|
| 114 |
+
params = {"access_token": token, "title": (title.strip() or "MediaInfo")[:TITLE_LIMIT],
|
| 115 |
+
"author_name": self.author, "content": content, "return_content": "false"}
|
| 116 |
+
if self.author_url:
|
| 117 |
+
params["author_url"] = self.author_url
|
| 118 |
+
try:
|
| 119 |
+
result = await self._call("createPage", **params)
|
| 120 |
+
except TelegraphError as exc:
|
| 121 |
+
if exc.code == "ACCESS_TOKEN_INVALID" and attempt == 0:
|
| 122 |
+
log.warning("the saved Telegraph token was rejected; making a new account")
|
| 123 |
+
await self._forget_token()
|
| 124 |
+
continue
|
| 125 |
+
raise
|
| 126 |
+
url = str(result.get("url") or "")
|
| 127 |
+
if not url:
|
| 128 |
+
raise TelegraphError("Telegraph did not return the page address")
|
| 129 |
+
return url
|
| 130 |
+
raise TelegraphError("Telegraph rejected the account twice") # pragma: no cover - the loop returns or raises
|
requirements.txt
CHANGED
|
@@ -6,4 +6,6 @@ asyncpg>=0.29
|
|
| 6 |
aiohttp>=3.9
|
| 7 |
psutil>=5.9
|
| 8 |
Pillow>=10.0
|
|
|
|
|
|
|
| 9 |
uvloop>=0.19; sys_platform != "win32"
|
|
|
|
| 6 |
aiohttp>=3.9
|
| 7 |
psutil>=5.9
|
| 8 |
Pillow>=10.0
|
| 9 |
+
pymediainfo>=6.1
|
| 10 |
+
speedtest-cli>=2.1.3
|
| 11 |
uvloop>=0.19; sys_platform != "win32"
|
tests/appkit.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared fixture and helpers for the handler-level tests (real Telethon event builders, fake events and client)."""
|
| 2 |
+
import io
|
| 3 |
+
from types import SimpleNamespace
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
from PIL import Image
|
| 7 |
+
|
| 8 |
+
from bot.app import App
|
| 9 |
+
from bot.config import load_config
|
| 10 |
+
from bot.db import Database
|
| 11 |
+
|
| 12 |
+
from .tgfakes import FakeCallback, FakeClient, FakeEvent, dispatch, document_message, flat_buttons
|
| 13 |
+
|
| 14 |
+
OWNER, ADMIN_CFG, ADMIN_DB, STRANGER = 1000001, 1000002, 1000003, 1999999
|
| 15 |
+
LOG = -1001111111111
|
| 16 |
+
EPISODE = "[SubsPlease] Tokyo Revengers - 05 (1080p) [ABCD1234].mkv"
|
| 17 |
+
EPISODE_NEW = "[S01E05] Tokyo Revengers [1080p].mkv"
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def env_for(tmp_path, **extra):
|
| 21 |
+
return {
|
| 22 |
+
"API_ID": "1", "API_HASH": "h", "BOT_TOKEN": "1:t", "OWNER_ID": str(OWNER), "ADMIN_IDS": str(ADMIN_CFG),
|
| 23 |
+
"LOG_CHANNEL": str(LOG), "TEMP_DIR": str(tmp_path / "work"), "FAST_TRANSFER": "0", "MAX_FILE_MB": "100",
|
| 24 |
+
**extra,
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@pytest.fixture
|
| 29 |
+
async def ctx(tmp_path):
|
| 30 |
+
cfg = load_config(env_for(tmp_path))
|
| 31 |
+
db = Database(f"sqlite+aiosqlite:///{tmp_path}/app.db")
|
| 32 |
+
await db.init()
|
| 33 |
+
client = FakeClient()
|
| 34 |
+
app = App(client, cfg, db)
|
| 35 |
+
app._register() # setup() minus the queue workers: jobs stay queued so they can be inspected
|
| 36 |
+
yield SimpleNamespace(app=app, client=client, db=db, cfg=cfg, tmp=tmp_path)
|
| 37 |
+
await app.shutdown()
|
| 38 |
+
await db.close()
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
async def say(ctx, sender, text="", **kw):
|
| 42 |
+
ev = FakeEvent(sender, text, **kw)
|
| 43 |
+
await dispatch(ctx.client, ev)
|
| 44 |
+
return ev
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
async def send_doc(ctx, sender, name=EPISODE, size=50 * 1024 * 1024, **kw):
|
| 48 |
+
message = document_message(name, size, **kw)
|
| 49 |
+
ctx.client.messages[message.id] = message # so get_messages() can find it again
|
| 50 |
+
ev = FakeEvent(sender, message=message)
|
| 51 |
+
await dispatch(ctx.client, ev)
|
| 52 |
+
return ev
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
async def click(ctx, sender, data, **kw):
|
| 56 |
+
cb = FakeCallback(sender, data, **kw)
|
| 57 |
+
await dispatch(ctx.client, cb)
|
| 58 |
+
return cb
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
async def prefs(ctx, uid=OWNER):
|
| 62 |
+
return await ctx.db.get_settings(uid)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def jpeg(size=(640, 480)):
|
| 66 |
+
buf = io.BytesIO()
|
| 67 |
+
Image.new("RGB", size, (200, 30, 30)).save(buf, "JPEG")
|
| 68 |
+
return buf.getvalue()
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def data_of(buttons):
|
| 72 |
+
return [d for _l, d in flat_buttons(buttons)]
|
tests/conftest.py
CHANGED
|
@@ -9,6 +9,10 @@ import pytest
|
|
| 9 |
|
| 10 |
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
HAVE_FFMPEG = shutil.which("ffmpeg") is not None and shutil.which("ffprobe") is not None
|
| 13 |
needs_ffmpeg = pytest.mark.skipif(not HAVE_FFMPEG, reason="ffmpeg/ffprobe not installed")
|
| 14 |
|
|
|
|
| 9 |
|
| 10 |
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 11 |
|
| 12 |
+
from .appkit import ctx # noqa: E402 the handler-level fixture, shared by every test module
|
| 13 |
+
|
| 14 |
+
__all__ = ["ctx", "needs_ffmpeg"]
|
| 15 |
+
|
| 16 |
HAVE_FFMPEG = shutil.which("ffmpeg") is not None and shutil.which("ffprobe") is not None
|
| 17 |
needs_ffmpeg = pytest.mark.skipif(not HAVE_FFMPEG, reason="ffmpeg/ffprobe not installed")
|
| 18 |
|
tests/faketelegraph.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""A local stand-in for api.telegra.ph (createAccount and createPage), used by the Telegraph and /mediainfo tests."""
|
| 2 |
+
import asyncio
|
| 3 |
+
|
| 4 |
+
from aiohttp import web
|
| 5 |
+
from aiohttp.test_utils import TestServer
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class FakeTelegraph:
|
| 9 |
+
"""api.telegra.ph for tests: createAccount and createPage, with switches for the ways it can go wrong."""
|
| 10 |
+
|
| 11 |
+
def __init__(self):
|
| 12 |
+
self.accounts = 0
|
| 13 |
+
self.valid = set()
|
| 14 |
+
self.attempts = [] # every createPage form, accepted or not
|
| 15 |
+
self.pages = [] # the accepted ones
|
| 16 |
+
self.error = None # answer every createPage with this error
|
| 17 |
+
self.reject_all = False # a token is never good enough
|
| 18 |
+
self.delay = 0.0
|
| 19 |
+
self.raw = None # (status, body) to answer createPage with instead of JSON
|
| 20 |
+
|
| 21 |
+
async def create_account(self, request):
|
| 22 |
+
form = await request.post()
|
| 23 |
+
self.accounts += 1
|
| 24 |
+
token = f"token{self.accounts}"
|
| 25 |
+
self.valid.add(token)
|
| 26 |
+
return web.json_response({"ok": True, "result": {"short_name": form["short_name"], "access_token": token}})
|
| 27 |
+
|
| 28 |
+
async def create_page(self, request):
|
| 29 |
+
form = dict(await request.post())
|
| 30 |
+
self.attempts.append(form)
|
| 31 |
+
if self.delay:
|
| 32 |
+
await asyncio.sleep(self.delay)
|
| 33 |
+
if self.raw is not None:
|
| 34 |
+
return web.Response(status=self.raw[0], text=self.raw[1], content_type="text/html")
|
| 35 |
+
if self.error:
|
| 36 |
+
return web.json_response({"ok": False, "error": self.error})
|
| 37 |
+
if self.reject_all or form.get("access_token") not in self.valid:
|
| 38 |
+
return web.json_response({"ok": False, "error": "ACCESS_TOKEN_INVALID"})
|
| 39 |
+
self.pages.append(form)
|
| 40 |
+
n = len(self.pages)
|
| 41 |
+
return web.json_response({"ok": True, "result": {"path": f"Page-{n}", "url": f"https://telegra.ph/Page-{n}", "title": form["title"]}})
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
async def serve_fake_telegraph() -> "FakeTelegraph":
|
| 45 |
+
"""Start the fake server; ``fake.url`` is the API address and ``await fake.server.close()`` stops it."""
|
| 46 |
+
fake = FakeTelegraph()
|
| 47 |
+
app = web.Application()
|
| 48 |
+
app.router.add_post("/createAccount", fake.create_account)
|
| 49 |
+
app.router.add_post("/createPage", fake.create_page)
|
| 50 |
+
fake.server = TestServer(app)
|
| 51 |
+
await fake.server.start_server()
|
| 52 |
+
fake.url = str(fake.server.make_url("")).rstrip("/")
|
| 53 |
+
return fake
|
tests/test_app.py
CHANGED
|
@@ -1,14 +1,10 @@
|
|
| 1 |
"""Handler-level tests: real Telethon event builders, fake events and client."""
|
| 2 |
import asyncio
|
| 3 |
-
import dataclasses
|
| 4 |
-
import io
|
| 5 |
import shutil
|
| 6 |
import time
|
| 7 |
from pathlib import Path
|
| 8 |
-
from types import SimpleNamespace
|
| 9 |
|
| 10 |
import pytest
|
| 11 |
-
from PIL import Image
|
| 12 |
from telethon import functions
|
| 13 |
|
| 14 |
from bot import app as appmod
|
|
@@ -19,69 +15,11 @@ from bot.db import Database
|
|
| 19 |
from bot.queue import JobQueue
|
| 20 |
|
| 21 |
from .conftest import needs_ffmpeg
|
|
|
|
| 22 |
from .tgfakes import (
|
| 23 |
FakeCallback, FakeClient, FakeEvent, FakeMessage, dispatch, document_message, flat_buttons, make_file,
|
| 24 |
)
|
| 25 |
|
| 26 |
-
OWNER, ADMIN_CFG, ADMIN_DB, STRANGER = 1000001, 1000002, 1000003, 1999999
|
| 27 |
-
LOG = -1001111111111
|
| 28 |
-
EPISODE = "[SubsPlease] Tokyo Revengers - 05 (1080p) [ABCD1234].mkv"
|
| 29 |
-
EPISODE_NEW = "[S01E05] Tokyo Revengers [1080p].mkv"
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
def env_for(tmp_path, **extra):
|
| 33 |
-
return {
|
| 34 |
-
"API_ID": "1", "API_HASH": "h", "BOT_TOKEN": "1:t", "OWNER_ID": str(OWNER), "ADMIN_IDS": str(ADMIN_CFG),
|
| 35 |
-
"LOG_CHANNEL": str(LOG), "TEMP_DIR": str(tmp_path / "work"), "FAST_TRANSFER": "0", "MAX_FILE_MB": "100",
|
| 36 |
-
**extra,
|
| 37 |
-
}
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
@pytest.fixture
|
| 41 |
-
async def ctx(tmp_path):
|
| 42 |
-
cfg = load_config(env_for(tmp_path))
|
| 43 |
-
db = Database(f"sqlite+aiosqlite:///{tmp_path}/app.db")
|
| 44 |
-
await db.init()
|
| 45 |
-
client = FakeClient()
|
| 46 |
-
app = App(client, cfg, db)
|
| 47 |
-
app._register() # setup() minus the queue workers: jobs stay queued so they can be inspected
|
| 48 |
-
yield SimpleNamespace(app=app, client=client, db=db, cfg=cfg, tmp=tmp_path)
|
| 49 |
-
await app.queue.stop()
|
| 50 |
-
await db.close()
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
async def say(ctx, sender, text="", **kw):
|
| 54 |
-
ev = FakeEvent(sender, text, **kw)
|
| 55 |
-
await dispatch(ctx.client, ev)
|
| 56 |
-
return ev
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
async def send_doc(ctx, sender, name=EPISODE, size=50 * 1024 * 1024, **kw):
|
| 60 |
-
ev = FakeEvent(sender, message=document_message(name, size, **kw))
|
| 61 |
-
await dispatch(ctx.client, ev)
|
| 62 |
-
return ev
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
async def click(ctx, sender, data, **kw):
|
| 66 |
-
cb = FakeCallback(sender, data, **kw)
|
| 67 |
-
await dispatch(ctx.client, cb)
|
| 68 |
-
return cb
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
async def prefs(ctx, uid=OWNER):
|
| 72 |
-
return await ctx.db.get_settings(uid)
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
def jpeg(size=(640, 480)):
|
| 76 |
-
buf = io.BytesIO()
|
| 77 |
-
Image.new("RGB", size, (200, 30, 30)).save(buf, "JPEG")
|
| 78 |
-
return buf.getvalue()
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
def data_of(buttons):
|
| 82 |
-
return [d for _l, d in flat_buttons(buttons)]
|
| 83 |
-
|
| 84 |
-
|
| 85 |
# ---------------------------------------------------------------------------
|
| 86 |
# access control & routing
|
| 87 |
# ---------------------------------------------------------------------------
|
|
@@ -186,18 +124,6 @@ async def test_start_variants(ctx, text):
|
|
| 186 |
assert (await say(ctx, OWNER, text)).out
|
| 187 |
|
| 188 |
|
| 189 |
-
async def test_start_image_is_sent_as_a_photo_with_the_panel_as_caption(ctx):
|
| 190 |
-
ctx.app.cfg = dataclasses.replace(ctx.cfg, start_image="https://example.com/banner.jpg")
|
| 191 |
-
ev = await say(ctx, OWNER, "/start")
|
| 192 |
-
assert ev.out == []
|
| 193 |
-
(chat, file, kw), = ctx.client.files
|
| 194 |
-
assert (chat, file) == (OWNER, "https://example.com/banner.jpg")
|
| 195 |
-
assert "Auto Rename Bot" in kw["caption"] and kw["buttons"] and kw["parse_mode"] == "html"
|
| 196 |
-
ctx.client.fail_send_file = True # a broken banner must not break /start
|
| 197 |
-
ev = await say(ctx, OWNER, "/start")
|
| 198 |
-
assert "Auto Rename Bot" in ev.texts[0]
|
| 199 |
-
|
| 200 |
-
|
| 201 |
async def test_start_and_help(ctx):
|
| 202 |
ctx.app.pending[OWNER] = Pending("template", OWNER, 1, "rename", False, time.time() + 100)
|
| 203 |
await say(ctx, OWNER, "/start")
|
|
@@ -370,7 +296,7 @@ async def test_a_file_is_queued_with_the_template_name_and_a_cancel_button(ctx):
|
|
| 370 |
ev = await send_doc(ctx, OWNER, mid=10)
|
| 371 |
(job,) = ctx.app.queue.jobs.values()
|
| 372 |
assert job.new_name == EPISODE_NEW and (job.user_id, job.chat_id, job.msg_id) == (OWNER, OWNER, 10)
|
| 373 |
-
assert job.file_name == EPISODE and job.size == 50 * 1024 * 1024 and
|
| 374 |
text, buttons, kw = ev.out[0]
|
| 375 |
assert "Queued" in text and "starting soon" in text and EPISODE_NEW in text
|
| 376 |
assert flat_buttons(buttons) == [("✖ Cancel", f"job:x:{job.id}")]
|
|
@@ -407,7 +333,7 @@ async def test_a_template_variable_that_cannot_be_filled_is_flagged(ctx):
|
|
| 407 |
ev = await send_doc(ctx, OWNER, "Plain Movie.mkv")
|
| 408 |
(job,) = ctx.app.queue.jobs.values()
|
| 409 |
assert job.new_name == "One Piece - E.mkv"
|
| 410 |
-
assert "⚠️" in ev.texts[0] and "{episode}" in ev.texts[0] and "file name" in ev.texts[0] and "
|
| 411 |
assert job.notes and "{episode}" in job.notes[0]
|
| 412 |
|
| 413 |
await say(ctx, OWNER, "/mode caption")
|
|
@@ -415,12 +341,9 @@ async def test_a_template_variable_that_cannot_be_filled_is_flagged(ctx):
|
|
| 415 |
assert "in the caption" in ev.texts[0]
|
| 416 |
|
| 417 |
|
| 418 |
-
async def
|
| 419 |
ev = await send_doc(ctx, OWNER, "Plain Movie.mkv")
|
| 420 |
assert "⚠️" not in ev.texts[0] # bracket groups vanish on purpose
|
| 421 |
-
await say(ctx, OWNER, "/template One Piece - E{episode}")
|
| 422 |
-
ev = await say(ctx, OWNER, "/rename My Name", reply=document_message("Plain Movie.mkv", mid=40), mid=41)
|
| 423 |
-
assert "⚠️" not in ev.texts[0]
|
| 424 |
|
| 425 |
|
| 426 |
class SlowEvent(FakeEvent):
|
|
@@ -438,7 +361,7 @@ class SlowEvent(FakeEvent):
|
|
| 438 |
async def test_jobs_are_ordered_by_the_message_that_triggered_them(ctx):
|
| 439 |
await send_doc(ctx, OWNER, "B.mkv", mid=31)
|
| 440 |
await send_doc(ctx, OWNER, "A.mkv", mid=30)
|
| 441 |
-
await
|
| 442 |
assert sorted(j.order for j in ctx.app.queue.jobs.values()) == [30, 31, 34]
|
| 443 |
assert [j.file_name for j in ctx.app.queue.for_user(OWNER)] == ["A.mkv", "B.mkv", "old.mkv"]
|
| 444 |
|
|
@@ -467,30 +390,6 @@ async def test_a_forwarded_batch_comes_out_in_the_order_it_was_sent(ctx):
|
|
| 467 |
assert ran == [names[1145], names[1146], names[1147]]
|
| 468 |
|
| 469 |
|
| 470 |
-
async def test_rename_command_uses_the_given_name_and_keeps_the_extension(ctx):
|
| 471 |
-
reply = document_message("old.name.mkv", 10 * 1024 * 1024, mid=33)
|
| 472 |
-
ev = await say(ctx, OWNER, "/rename My Custom Name", reply=reply, mid=34)
|
| 473 |
-
(job,) = ctx.app.queue.jobs.values()
|
| 474 |
-
assert job.new_name == "My Custom Name.mkv" and job.manual and job.msg_id == 33
|
| 475 |
-
assert "My Custom Name.mkv" in ev.texts[0]
|
| 476 |
-
|
| 477 |
-
ev = await say(ctx, OWNER, "/rename Movie.2024.mp4", reply=reply, mid=35) # a typed extension never wins
|
| 478 |
-
assert sorted(j.new_name for j in ctx.app.queue.jobs.values())[1].endswith(".mkv")
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
async def test_rename_usage_messages(ctx):
|
| 482 |
-
for kwargs in (
|
| 483 |
-
{"reply": None}, # no reply
|
| 484 |
-
{"reply": FakeMessage("just text", mid=5)}, # reply to text
|
| 485 |
-
{"reply": FakeMessage("", mid=6, file=make_file("s.webp"), sticker=True)},
|
| 486 |
-
):
|
| 487 |
-
ev = await say(ctx, OWNER, "/rename New Name", **kwargs)
|
| 488 |
-
assert "Reply to a file" in ev.texts[0], kwargs
|
| 489 |
-
ev = await say(ctx, OWNER, "/rename", reply=document_message()) # reply but no name
|
| 490 |
-
assert "Reply to a file" in ev.texts[0]
|
| 491 |
-
assert not ctx.app.queue.jobs
|
| 492 |
-
|
| 493 |
-
|
| 494 |
async def test_oversized_files_are_refused(ctx):
|
| 495 |
ev = await send_doc(ctx, OWNER, size=101 * 1024 * 1024)
|
| 496 |
assert "limit is 100.00 MB" in ev.texts[0] and not ctx.app.queue.jobs
|
|
|
|
| 1 |
"""Handler-level tests: real Telethon event builders, fake events and client."""
|
| 2 |
import asyncio
|
|
|
|
|
|
|
| 3 |
import shutil
|
| 4 |
import time
|
| 5 |
from pathlib import Path
|
|
|
|
| 6 |
|
| 7 |
import pytest
|
|
|
|
| 8 |
from telethon import functions
|
| 9 |
|
| 10 |
from bot import app as appmod
|
|
|
|
| 15 |
from bot.queue import JobQueue
|
| 16 |
|
| 17 |
from .conftest import needs_ffmpeg
|
| 18 |
+
from .appkit import ADMIN_CFG, ADMIN_DB, EPISODE, EPISODE_NEW, LOG, OWNER, STRANGER, click, data_of, env_for, jpeg, prefs, say, send_doc
|
| 19 |
from .tgfakes import (
|
| 20 |
FakeCallback, FakeClient, FakeEvent, FakeMessage, dispatch, document_message, flat_buttons, make_file,
|
| 21 |
)
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
# ---------------------------------------------------------------------------
|
| 24 |
# access control & routing
|
| 25 |
# ---------------------------------------------------------------------------
|
|
|
|
| 124 |
assert (await say(ctx, OWNER, text)).out
|
| 125 |
|
| 126 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
async def test_start_and_help(ctx):
|
| 128 |
ctx.app.pending[OWNER] = Pending("template", OWNER, 1, "rename", False, time.time() + 100)
|
| 129 |
await say(ctx, OWNER, "/start")
|
|
|
|
| 296 |
ev = await send_doc(ctx, OWNER, mid=10)
|
| 297 |
(job,) = ctx.app.queue.jobs.values()
|
| 298 |
assert job.new_name == EPISODE_NEW and (job.user_id, job.chat_id, job.msg_id) == (OWNER, OWNER, 10)
|
| 299 |
+
assert job.file_name == EPISODE and job.size == 50 * 1024 * 1024 and job.fmt is None and job.state == "queued"
|
| 300 |
text, buttons, kw = ev.out[0]
|
| 301 |
assert "Queued" in text and "starting soon" in text and EPISODE_NEW in text
|
| 302 |
assert flat_buttons(buttons) == [("✖ Cancel", f"job:x:{job.id}")]
|
|
|
|
| 333 |
ev = await send_doc(ctx, OWNER, "Plain Movie.mkv")
|
| 334 |
(job,) = ctx.app.queue.jobs.values()
|
| 335 |
assert job.new_name == "One Piece - E.mkv"
|
| 336 |
+
assert "⚠️" in ev.texts[0] and "{episode}" in ev.texts[0] and "file name" in ev.texts[0] and "change the template" in ev.texts[0]
|
| 337 |
assert job.notes and "{episode}" in job.notes[0]
|
| 338 |
|
| 339 |
await say(ctx, OWNER, "/mode caption")
|
|
|
|
| 341 |
assert "in the caption" in ev.texts[0]
|
| 342 |
|
| 343 |
|
| 344 |
+
async def test_the_default_template_never_warns(ctx):
|
| 345 |
ev = await send_doc(ctx, OWNER, "Plain Movie.mkv")
|
| 346 |
assert "⚠️" not in ev.texts[0] # bracket groups vanish on purpose
|
|
|
|
|
|
|
|
|
|
| 347 |
|
| 348 |
|
| 349 |
class SlowEvent(FakeEvent):
|
|
|
|
| 361 |
async def test_jobs_are_ordered_by_the_message_that_triggered_them(ctx):
|
| 362 |
await send_doc(ctx, OWNER, "B.mkv", mid=31)
|
| 363 |
await send_doc(ctx, OWNER, "A.mkv", mid=30)
|
| 364 |
+
await send_doc(ctx, OWNER, "old.mkv", 1024, mid=34)
|
| 365 |
assert sorted(j.order for j in ctx.app.queue.jobs.values()) == [30, 31, 34]
|
| 366 |
assert [j.file_name for j in ctx.app.queue.for_user(OWNER)] == ["A.mkv", "B.mkv", "old.mkv"]
|
| 367 |
|
|
|
|
| 390 |
assert ran == [names[1145], names[1146], names[1147]]
|
| 391 |
|
| 392 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 393 |
async def test_oversized_files_are_refused(ctx):
|
| 394 |
ev = await send_doc(ctx, OWNER, size=101 * 1024 * 1024)
|
| 395 |
assert "limit is 100.00 MB" in ev.texts[0] and not ctx.app.queue.jobs
|
tests/test_check.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""/check: what the bot can read from a file's name and caption."""
|
| 2 |
+
from .appkit import OWNER, say
|
| 3 |
+
from .tgfakes import FakeMessage, document_message
|
| 4 |
+
|
| 5 |
+
NAME = "[S01 E02] Dragon Ball Super Beerus [1080p].mkv"
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def detail(text, block, label):
|
| 9 |
+
"""The value shown for ``label`` inside the ``block`` ("Fɪʟᴇɴᴀᴍᴇ" / "Cᴀᴘᴛɪᴏɴ Mᴏᴅᴇ") of a /check reply."""
|
| 10 |
+
section = text.split(f"<b>{block}:</b>\n", 1)[1].split("\n\n", 1)[0]
|
| 11 |
+
line = next(ln for ln in section.splitlines() if ln.startswith(f"➦ {label} ⇛"))
|
| 12 |
+
return line.split("<code>", 1)[1].split("</code>", 1)[0]
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
async def test_check_lists_what_it_reads_from_the_name_and_the_caption(ctx):
|
| 16 |
+
reply = document_message(NAME, caption="Dragon Ball Super Beerus - S01E02 [720p]")
|
| 17 |
+
ev = await say(ctx, OWNER, "/check", reply=reply)
|
| 18 |
+
text = ev.texts[0]
|
| 19 |
+
assert text.startswith("<b>Yᴏᴜ Cᴀɴ Cʜᴀɴɢᴇ Mᴏᴅᴇ Bʏ</b> /mode")
|
| 20 |
+
assert "<b>Rᴇɴᴀᴍɪɴɢ Mᴏᴅᴇ:</b> <code>FILENAME</code>" in text
|
| 21 |
+
for label, expected in (("Title", "Dragon Ball Super Beerus"), ("Season", "01"), ("Episode", "02"), ("Quality", "1080p")):
|
| 22 |
+
assert detail(text, "Fɪʟᴇɴᴀᴍᴇ", label) == expected
|
| 23 |
+
for label, expected in (("Title", "Dragon Ball Super Beerus"), ("Season", "01"), ("Episode", "02"), ("Quality", "720p")):
|
| 24 |
+
assert detail(text, "Cᴀᴘᴛɪᴏɴ Mᴏᴅᴇ", label) == expected
|
| 25 |
+
assert ev.out[0][2]["parse_mode"] == "html"
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
async def test_there_is_no_chapter_field(ctx):
|
| 29 |
+
ev = await say(ctx, OWNER, "/check", reply=document_message(NAME))
|
| 30 |
+
assert "Chapter" not in ev.texts[0]
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
async def test_what_is_not_found_shows_as_none(ctx):
|
| 34 |
+
ev = await say(ctx, OWNER, "/check", reply=document_message("holiday video.mp4"))
|
| 35 |
+
text = ev.texts[0]
|
| 36 |
+
assert detail(text, "Fɪʟᴇɴᴀᴍᴇ", "Season") == "None" and detail(text, "Fɪʟᴇɴᴀᴍᴇ", "Episode") == "None"
|
| 37 |
+
assert all(detail(text, "Cᴀᴘᴛɪᴏɴ Mᴏᴅᴇ", f) == "None" for f in ("Title", "Season", "Episode", "Quality"))
|
| 38 |
+
assert "Year" not in text and "Codec" not in text # only listed when there is one
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
async def test_year_and_codec_show_up_when_the_file_has_them(ctx):
|
| 42 |
+
ev = await say(ctx, OWNER, "/check", reply=document_message("Some Movie (2023) 1080p x265.mkv"))
|
| 43 |
+
assert detail(ev.texts[0], "Fɪʟᴇɴᴀᴍᴇ", "Year") == "2023" and detail(ev.texts[0], "Fɪʟᴇɴᴀᴍᴇ", "Codec") != "None"
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
async def test_the_current_mode_is_shown_and_decides_the_new_name(ctx):
|
| 47 |
+
await say(ctx, OWNER, "/template [S{season}E{episode}] {title} [{quality}]")
|
| 48 |
+
reply = document_message("random.mkv", caption="One Piece - S01E1147 [720p]")
|
| 49 |
+
ev = await say(ctx, OWNER, "/check", reply=reply)
|
| 50 |
+
assert "<code>FILENAME</code>" in ev.texts[0]
|
| 51 |
+
assert "<b>Nᴇᴡ Nᴀᴍᴇ:</b>\n<code>random.mkv</code>" in ev.texts[0] # nothing to read in the name, so it is left as it is
|
| 52 |
+
|
| 53 |
+
await say(ctx, OWNER, "/mode caption")
|
| 54 |
+
ev = await say(ctx, OWNER, "/check", reply=reply)
|
| 55 |
+
assert "<b>Rᴇɴᴀᴍɪɴɢ Mᴏᴅᴇ:</b> <code>CAPTION</code>" in ev.texts[0]
|
| 56 |
+
assert "<code>[S01E1147] One Piece [720p].mkv</code>" in ev.texts[0]
|
| 57 |
+
assert detail(ev.texts[0], "Fɪʟᴇɴᴀᴍᴇ", "Episode") == "None" and detail(ev.texts[0], "Cᴀᴘᴛɪᴏɴ Mᴏᴅᴇ", "Episode") == "1147"
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
async def test_the_new_name_follows_the_users_template_and_remove_words(ctx):
|
| 61 |
+
await say(ctx, OWNER, "/template {title} - E{episode}")
|
| 62 |
+
await say(ctx, OWNER, "/removewords @Anime_X_Universe")
|
| 63 |
+
reply = document_message("1147 - One Piece [Dual] 720p @Anime_X_Universe.mkv")
|
| 64 |
+
ev = await say(ctx, OWNER, "/check", reply=reply)
|
| 65 |
+
assert "<code>One Piece - E1147.mkv</code>" in ev.texts[0]
|
| 66 |
+
assert detail(ev.texts[0], "Fɪʟᴇɴᴀᴍᴇ", "Episode") == "1147" and detail(ev.texts[0], "Fɪʟᴇɴᴀᴍᴇ", "Title") == "One Piece"
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
async def test_values_are_escaped(ctx):
|
| 70 |
+
ev = await say(ctx, OWNER, "/check", reply=document_message("Fate <b>Zero</b> & Co - 03.mkv"))
|
| 71 |
+
assert "<b>Zero</b>" not in ev.texts[0].split("Fɪʟᴇɴᴀᴍᴇ")[1] and "<b>" in ev.texts[0]
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
async def test_check_without_a_file_explains_itself(ctx):
|
| 75 |
+
for kwargs in ({}, {"reply": FakeMessage("just text")}, {"reply": FakeMessage("", photo=True)}):
|
| 76 |
+
ev = await say(ctx, OWNER, "/check", **kwargs)
|
| 77 |
+
assert "Reply to a file with /check" in ev.texts[0] and "Fɪʟᴇɴᴀᴍᴇ" not in ev.texts[0]
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
async def test_check_is_in_the_command_menu(ctx):
|
| 81 |
+
from bot.app import COMMANDS
|
| 82 |
+
|
| 83 |
+
assert "check" in dict(COMMANDS)
|
tests/test_ethumb.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""/ethumb: ask for a file or video, send back its thumbnail."""
|
| 2 |
+
import io
|
| 3 |
+
import shutil
|
| 4 |
+
import time
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from types import SimpleNamespace
|
| 7 |
+
|
| 8 |
+
from PIL import Image
|
| 9 |
+
from telethon import types
|
| 10 |
+
|
| 11 |
+
from bot import app as appmod
|
| 12 |
+
|
| 13 |
+
from .appkit import OWNER, STRANGER, click, data_of, jpeg, say, send_doc
|
| 14 |
+
from .conftest import needs_ffmpeg
|
| 15 |
+
from .tgfakes import FakeEvent, FakeMessage, dispatch, document_message
|
| 16 |
+
|
| 17 |
+
REAL_THUMB = [SimpleNamespace(type="m")] # stands in for a PhotoSize
|
| 18 |
+
STRIPPED = [types.PhotoStrippedSize(type="i", bytes=b"\x01\x14\x28abc")]
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def photos_sent(ctx):
|
| 22 |
+
return [(entity, file, kw) for entity, file, kw in ctx.client.files if isinstance(file, io.BytesIO)]
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def temp_leftovers(ctx):
|
| 26 |
+
root = Path(ctx.cfg.temp_dir)
|
| 27 |
+
return list(root.glob("ethumb*")) if root.exists() else []
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def serve(monkeypatch, media_dir, name="sample.mkv"):
|
| 31 |
+
"""Make transfer.download copy a real sample file; returns the list of calls."""
|
| 32 |
+
calls = []
|
| 33 |
+
|
| 34 |
+
async def fake_download(client, message, dest, **kw):
|
| 35 |
+
calls.append(kw)
|
| 36 |
+
shutil.copy(media_dir / name, dest)
|
| 37 |
+
|
| 38 |
+
monkeypatch.setattr(appmod.transfer, "download", fake_download)
|
| 39 |
+
return calls
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
# ---------------------------------------------------------------------------
|
| 43 |
+
# the flow
|
| 44 |
+
# ---------------------------------------------------------------------------
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
async def test_ethumb_asks_for_a_file(ctx):
|
| 48 |
+
ev = await say(ctx, OWNER, "/ethumb")
|
| 49 |
+
assert "EXTRACT THUMBNAIL" in ev.texts[0] and "one file at a time" in ev.texts[0] and "/cancel" in ev.texts[0]
|
| 50 |
+
assert data_of(ev.out[0][1]) == ["et:stop"]
|
| 51 |
+
mode = ctx.app.modes[OWNER]
|
| 52 |
+
assert mode.kind == "ethumb" and mode.expires > time.time() + 500
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
async def test_the_files_own_telegram_thumbnail_is_sent_when_it_has_one(ctx, monkeypatch):
|
| 56 |
+
async def no_download(*a, **kw):
|
| 57 |
+
raise AssertionError("nothing needs downloading when Telegram already has a preview")
|
| 58 |
+
|
| 59 |
+
monkeypatch.setattr(appmod.transfer, "download", no_download)
|
| 60 |
+
await say(ctx, OWNER, "/ethumb")
|
| 61 |
+
ctx.client.thumb_data[60] = jpeg((320, 180))
|
| 62 |
+
ev = await send_doc(ctx, OWNER, "Episode 01.mp4", thumbs=REAL_THUMB, mid=60)
|
| 63 |
+
assert ev.texts == ["🖼 Extracting the thumbnail…"]
|
| 64 |
+
(entity, file, kw), = photos_sent(ctx)
|
| 65 |
+
assert entity == OWNER and file.name == "thumbnail.jpg" and Image.open(io.BytesIO(file.getvalue())).size == (320, 180)
|
| 66 |
+
assert "Thumbnail of <b>Episode 01.mp4</b>" in kw["caption"] and kw["parse_mode"] == "html"
|
| 67 |
+
assert (OWNER, [1001]) in ctx.client.deleted # the "Extracting…" message goes
|
| 68 |
+
assert OWNER not in ctx.app.modes and ctx.client.downloads == [(60, bytes, -1)]
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
@needs_ffmpeg
|
| 72 |
+
async def test_without_a_telegram_thumbnail_a_frame_is_grabbed_from_the_video(ctx, media_dir, monkeypatch):
|
| 73 |
+
calls = serve(monkeypatch, media_dir)
|
| 74 |
+
await say(ctx, OWNER, "/ethumb")
|
| 75 |
+
await send_doc(ctx, OWNER, "Show - 05.mkv", size=(media_dir / "sample.mkv").stat().st_size, mid=61)
|
| 76 |
+
(_e, file, kw), = photos_sent(ctx)
|
| 77 |
+
img = Image.open(io.BytesIO(file.getvalue()))
|
| 78 |
+
assert img.format == "JPEG" and img.size == (320, 240)
|
| 79 |
+
assert len(calls) == 1 and calls[0]["max_bytes"] == appmod.TRACKS_PROBE_BYTES
|
| 80 |
+
assert "Show - 05.mkv" in kw["caption"] and (OWNER, [1001]) in ctx.client.deleted
|
| 81 |
+
assert temp_leftovers(ctx) == []
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
@needs_ffmpeg
|
| 85 |
+
async def test_the_whole_file_is_fetched_when_the_start_has_no_picture(ctx, media_dir, monkeypatch):
|
| 86 |
+
calls = []
|
| 87 |
+
|
| 88 |
+
async def fake_download(client, message, dest, **kw):
|
| 89 |
+
calls.append(kw)
|
| 90 |
+
if "max_bytes" in kw:
|
| 91 |
+
Path(dest).write_bytes(b"\x00" * 4096) # an mp4 whose index is at the end
|
| 92 |
+
else:
|
| 93 |
+
shutil.copy(media_dir / "sample.mp4", dest)
|
| 94 |
+
|
| 95 |
+
monkeypatch.setattr(appmod.transfer, "download", fake_download)
|
| 96 |
+
await say(ctx, OWNER, "/ethumb")
|
| 97 |
+
await send_doc(ctx, OWNER, "Movie.mp4", size=40 * 1024 * 1024, mid=62)
|
| 98 |
+
assert len(calls) == 2 and "max_bytes" not in calls[1]
|
| 99 |
+
assert len(photos_sent(ctx)) == 1 and temp_leftovers(ctx) == []
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
@needs_ffmpeg
|
| 103 |
+
async def test_a_file_with_no_picture_anywhere_gets_an_honest_answer(ctx, monkeypatch):
|
| 104 |
+
async def junk(client, message, dest, **kw):
|
| 105 |
+
Path(dest).write_bytes(b"\x00" * 4096)
|
| 106 |
+
|
| 107 |
+
monkeypatch.setattr(appmod.transfer, "download", junk)
|
| 108 |
+
for size in (2 * 1024 * 1024, 900 * 1024 * 1024): # small / too big to fetch whole
|
| 109 |
+
ctx.client.edited.clear()
|
| 110 |
+
await say(ctx, OWNER, "/ethumb")
|
| 111 |
+
await send_doc(ctx, OWNER, "Broken.mkv", size=size, mid=63)
|
| 112 |
+
assert "couldn't find a thumbnail" in ctx.client.edited[-1][1], size
|
| 113 |
+
assert photos_sent(ctx) == [] and temp_leftovers(ctx) == []
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
async def test_files_that_cannot_have_a_frame_are_not_downloaded(ctx, monkeypatch):
|
| 117 |
+
async def no_download(*a, **kw):
|
| 118 |
+
raise AssertionError("a zip has no picture to grab")
|
| 119 |
+
|
| 120 |
+
monkeypatch.setattr(appmod.transfer, "download", no_download)
|
| 121 |
+
await say(ctx, OWNER, "/ethumb")
|
| 122 |
+
await send_doc(ctx, OWNER, "archive.zip", size=5 * 1024 * 1024, video=False, mime="application/zip", mid=64)
|
| 123 |
+
assert "couldn't find a thumbnail" in ctx.client.edited[-1][1] and photos_sent(ctx) == []
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
@needs_ffmpeg
|
| 127 |
+
async def test_the_tiny_blurred_preview_is_only_the_last_resort(ctx, media_dir, monkeypatch):
|
| 128 |
+
serve(monkeypatch, media_dir)
|
| 129 |
+
await say(ctx, OWNER, "/ethumb")
|
| 130 |
+
ctx.client.thumb_data[65] = b"BLURRY-PREVIEW"
|
| 131 |
+
await send_doc(ctx, OWNER, "Show.mkv", size=(media_dir / "sample.mkv").stat().st_size, thumbs=STRIPPED, mid=65)
|
| 132 |
+
(_e, file, _kw), = photos_sent(ctx)
|
| 133 |
+
assert file.getvalue() != b"BLURRY-PREVIEW" and Image.open(io.BytesIO(file.getvalue())).size == (320, 240) # a real frame wins
|
| 134 |
+
|
| 135 |
+
async def junk(client, message, dest, **kw):
|
| 136 |
+
Path(dest).write_bytes(b"\x00" * 4096)
|
| 137 |
+
|
| 138 |
+
monkeypatch.setattr(appmod.transfer, "download", junk)
|
| 139 |
+
await say(ctx, OWNER, "/ethumb")
|
| 140 |
+
await send_doc(ctx, OWNER, "Show2.mkv", size=2 * 1024 * 1024, thumbs=STRIPPED, mid=65)
|
| 141 |
+
assert photos_sent(ctx)[-1][1].getvalue() == b"BLURRY-PREVIEW" # nothing better could be made
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
async def test_ethumb_as_a_reply_works_at_once_without_asking(ctx):
|
| 145 |
+
ctx.client.thumb_data[66] = jpeg((320, 180))
|
| 146 |
+
reply = document_message("Episode.mp4", mid=66, thumbs=REAL_THUMB)
|
| 147 |
+
await say(ctx, OWNER, "/ethumb", reply=reply, mid=67)
|
| 148 |
+
assert len(photos_sent(ctx)) == 1 and OWNER not in ctx.app.modes
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
async def test_it_handles_one_file_per_command(ctx):
|
| 152 |
+
ctx.client.thumb_data[70] = jpeg()
|
| 153 |
+
await say(ctx, OWNER, "/ethumb")
|
| 154 |
+
await send_doc(ctx, OWNER, "One.mp4", thumbs=REAL_THUMB, mid=70)
|
| 155 |
+
extra = await send_doc(ctx, OWNER, "Two.mp4", thumbs=REAL_THUMB, mid=71) # right after: a hint, not an auto-rename
|
| 156 |
+
assert "/ethumb handles one file at a time" in extra.texts[0] and ctx.app.queue.waiting == 0
|
| 157 |
+
assert (await send_doc(ctx, OWNER, "Three.mp4", thumbs=REAL_THUMB, mid=72)).out == [] # further ones: silence
|
| 158 |
+
assert len(photos_sent(ctx)) == 1
|
| 159 |
+
ctx.app._recent[OWNER][1] -= appmod.ONE_SHOT_GRACE + 1
|
| 160 |
+
later = await send_doc(ctx, OWNER, "Four.mp4", mid=73)
|
| 161 |
+
assert "Queued" in later.texts[0] # normal auto-rename is back
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
async def test_a_photo_while_waiting_still_sets_the_thumbnail_as_usual(ctx):
|
| 165 |
+
await say(ctx, OWNER, "/ethumb")
|
| 166 |
+
await dispatch(ctx.client, FakeEvent(OWNER, message=FakeMessage("", photo=True, mid=80), photo_bytes=jpeg()))
|
| 167 |
+
assert await ctx.db.has_thumb(OWNER) and ctx.app.modes[OWNER].kind == "ethumb"
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
async def test_cancel_and_the_stop_button_end_it(ctx):
|
| 171 |
+
await say(ctx, OWNER, "/ethumb")
|
| 172 |
+
ev = await say(ctx, OWNER, "/cancel")
|
| 173 |
+
assert "Cancelled" in ev.texts[0] and OWNER not in ctx.app.modes
|
| 174 |
+
ev = await send_doc(ctx, OWNER, "A.mkv", mid=90)
|
| 175 |
+
assert "Queued" in ev.texts[0]
|
| 176 |
+
|
| 177 |
+
await say(ctx, OWNER, "/ethumb")
|
| 178 |
+
cb = await click(ctx, OWNER, "et:stop")
|
| 179 |
+
assert "Stopped" in cb.edits[-1][0] and OWNER not in ctx.app.modes
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
async def test_it_expires(ctx):
|
| 183 |
+
await say(ctx, OWNER, "/ethumb")
|
| 184 |
+
ctx.app.modes[OWNER].expires = time.time() - 1
|
| 185 |
+
ev = await send_doc(ctx, OWNER, "A.mkv", mid=91)
|
| 186 |
+
assert "Queued" in ev.texts[0]
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
async def test_strangers_cannot_use_it(ctx):
|
| 190 |
+
ev = await say(ctx, STRANGER, "/ethumb")
|
| 191 |
+
assert ev.out == [] and STRANGER not in ctx.app.modes
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
async def test_starting_it_ends_a_sequence_and_says_so(ctx):
|
| 195 |
+
await say(ctx, OWNER, "/sequence")
|
| 196 |
+
await send_doc(ctx, OWNER, "Show - 01.mkv", mid=92)
|
| 197 |
+
ev = await say(ctx, OWNER, "/ethumb")
|
| 198 |
+
assert "1 file(s) was discarded" in ev.texts[0] and ctx.app.modes[OWNER].kind == "ethumb"
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
async def test_a_failing_extraction_is_reported_not_raised(ctx, monkeypatch):
|
| 202 |
+
async def boom(*a, **kw):
|
| 203 |
+
raise RuntimeError("network down")
|
| 204 |
+
|
| 205 |
+
monkeypatch.setattr(appmod.transfer, "download", boom)
|
| 206 |
+
await say(ctx, OWNER, "/ethumb")
|
| 207 |
+
ev = await send_doc(ctx, OWNER, "A.mkv", mid=93)
|
| 208 |
+
assert ev.texts == ["🖼 Extracting the thumbnail…"] and "couldn't find a thumbnail" in ctx.client.edited[-1][1]
|
| 209 |
+
assert temp_leftovers(ctx) == []
|
tests/test_formats.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
|
| 3 |
+
from bot.db import DEFAULT_FORMAT, DEFAULT_SETTINGS, MAX_FORMATS, Database
|
| 4 |
+
from bot.errors import FormatError
|
| 5 |
+
from bot.formats import NAME_MAX, FormatSettings, apply_format, has_metadata, validate_name
|
| 6 |
+
from bot.settings_ops import apply_setting
|
| 7 |
+
|
| 8 |
+
U, V = 111, 222
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
@pytest.fixture
|
| 12 |
+
async def db(tmp_path):
|
| 13 |
+
database = Database(f"sqlite+aiosqlite:///{tmp_path}/f.db")
|
| 14 |
+
await database.init()
|
| 15 |
+
yield database
|
| 16 |
+
await database.close()
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
# ---------------------------------------------------------------------------
|
| 20 |
+
# storage
|
| 21 |
+
# ---------------------------------------------------------------------------
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
async def test_a_new_format_has_defaults_and_shows_up_in_the_list(db):
|
| 25 |
+
fmt = await db.create_format(U, "One Piece")
|
| 26 |
+
assert fmt["name"] == "One Piece" and fmt["id"] > 0 and fmt["has_thumb"] is False
|
| 27 |
+
assert {k: fmt[k] for k in DEFAULT_FORMAT} == DEFAULT_FORMAT
|
| 28 |
+
await db.create_format(U, "Dragon Ball", template="DB - E{episode}")
|
| 29 |
+
listed = await db.list_formats(U)
|
| 30 |
+
assert [f["name"] for f in listed] == ["One Piece", "Dragon Ball"] # creation order
|
| 31 |
+
assert listed[1]["template"] == "DB - E{episode}"
|
| 32 |
+
assert await db.count_formats(U) == 2 and await db.count_formats(V) == 0
|
| 33 |
+
assert (await db.get_format(U, fmt["id"]))["name"] == "One Piece"
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
async def test_names_are_unique_per_user_ignoring_case(db):
|
| 37 |
+
await db.create_format(U, "One Piece")
|
| 38 |
+
with pytest.raises(FormatError) as exc:
|
| 39 |
+
await db.create_format(U, "one piece")
|
| 40 |
+
assert exc.value.code == "taken"
|
| 41 |
+
await db.create_format(V, "One Piece") # someone else may use the same name
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
async def test_a_user_can_save_twelve_formats_and_no_more(db):
|
| 45 |
+
for i in range(MAX_FORMATS):
|
| 46 |
+
await db.create_format(U, f"Format {i}")
|
| 47 |
+
with pytest.raises(FormatError) as exc:
|
| 48 |
+
await db.create_format(U, "One too many")
|
| 49 |
+
assert exc.value.code == "limit"
|
| 50 |
+
first = (await db.list_formats(U))[0]
|
| 51 |
+
assert await db.delete_format(U, first["id"])
|
| 52 |
+
await db.create_format(U, "One too many") # a slot is free again
|
| 53 |
+
assert await db.count_formats(U) == MAX_FORMATS
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
async def test_update_changes_fields_and_merges_meta(db):
|
| 57 |
+
fmt = await db.create_format(U, "One Piece")
|
| 58 |
+
out = await db.update_format(U, fmt["id"], template="One Piece - E{episode}", keep_audio=["japanese", "english"])
|
| 59 |
+
assert out["template"] == "One Piece - E{episode}" and out["keep_audio"] == ["japanese", "english"]
|
| 60 |
+
await db.update_format(U, fmt["id"], meta={"title": "One Piece"})
|
| 61 |
+
out = await db.update_format(U, fmt["id"], meta={"author": "@Me"}, keep_subtitle=["english"])
|
| 62 |
+
assert out["meta"]["title"] == "One Piece" and out["meta"]["author"] == "@Me" and out["meta"]["artist"] == ""
|
| 63 |
+
assert out["keep_subtitle"] == ["english"]
|
| 64 |
+
with pytest.raises(KeyError):
|
| 65 |
+
await db.update_format(U, fmt["id"], bogus=1)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
async def test_formats_persist_across_database_instances(db):
|
| 69 |
+
fmt = await db.create_format(U, "One Piece", template="X {title}")
|
| 70 |
+
await db.update_format(U, fmt["id"], meta={"video": "V"}, keep_audio=["2", "1"])
|
| 71 |
+
await db.set_format_thumb(U, fmt["id"], b"jpegbytes")
|
| 72 |
+
other = Database(db.url)
|
| 73 |
+
got = await other.get_format(U, fmt["id"])
|
| 74 |
+
assert got["template"] == "X {title}" and got["meta"]["video"] == "V" and got["keep_audio"] == ["2", "1"] and got["has_thumb"]
|
| 75 |
+
assert await other.get_format_thumb(U, fmt["id"]) == b"jpegbytes"
|
| 76 |
+
await other.close()
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
async def test_renaming_a_format_checks_the_other_names_only(db):
|
| 80 |
+
a = await db.create_format(U, "One Piece")
|
| 81 |
+
await db.create_format(U, "Dragon Ball")
|
| 82 |
+
assert (await db.update_format(U, a["id"], name="ONE PIECE"))["name"] == "ONE PIECE" # its own name, new case
|
| 83 |
+
with pytest.raises(FormatError):
|
| 84 |
+
await db.update_format(U, a["id"], name="dragon ball")
|
| 85 |
+
assert (await db.get_format(U, a["id"]))["name"] == "ONE PIECE"
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
async def test_thumbnails_belong_to_one_format(db):
|
| 89 |
+
a = await db.create_format(U, "A")
|
| 90 |
+
b = await db.create_format(U, "B")
|
| 91 |
+
assert await db.get_format_thumb(U, a["id"]) is None
|
| 92 |
+
assert await db.set_format_thumb(U, a["id"], b"A-thumb")
|
| 93 |
+
assert [f["has_thumb"] for f in await db.list_formats(U)] == [True, False]
|
| 94 |
+
assert await db.get_format_thumb(U, b["id"]) is None
|
| 95 |
+
assert await db.set_format_thumb(U, a["id"], None)
|
| 96 |
+
assert (await db.get_format(U, a["id"]))["has_thumb"] is False
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
async def test_nobody_can_touch_another_users_format(db):
|
| 100 |
+
fmt = await db.create_format(U, "Mine")
|
| 101 |
+
assert await db.get_format(V, fmt["id"]) is None
|
| 102 |
+
assert await db.update_format(V, fmt["id"], template="x") is None
|
| 103 |
+
assert await db.set_format_thumb(V, fmt["id"], b"x") is False
|
| 104 |
+
assert await db.get_format_thumb(V, fmt["id"]) is None
|
| 105 |
+
assert await db.delete_format(V, fmt["id"]) is False
|
| 106 |
+
assert (await db.get_format(U, fmt["id"]))["name"] == "Mine"
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
async def test_delete_and_missing_ids(db):
|
| 110 |
+
fmt = await db.create_format(U, "Gone")
|
| 111 |
+
assert await db.delete_format(U, fmt["id"]) is True
|
| 112 |
+
assert await db.delete_format(U, fmt["id"]) is False
|
| 113 |
+
assert await db.get_format(U, fmt["id"]) is None and await db.update_format(U, fmt["id"], template="x") is None
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
# ---------------------------------------------------------------------------
|
| 117 |
+
# helpers
|
| 118 |
+
# ---------------------------------------------------------------------------
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def test_validate_name():
|
| 122 |
+
assert validate_name(" One Piece ") == (True, "One Piece")
|
| 123 |
+
assert validate_name("")[0] is False and validate_name(" ")[0] is False and validate_name(None)[0] is False
|
| 124 |
+
ok, msg = validate_name("x" * (NAME_MAX + 1))
|
| 125 |
+
assert not ok and str(NAME_MAX) in msg
|
| 126 |
+
assert validate_name("x" * NAME_MAX)[0] is True
|
| 127 |
+
assert validate_name("one\npiece") == (True, "one piece")
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def fmt_with(**kw):
|
| 131 |
+
base = {"id": 1, "name": "F", "has_thumb": False, "template": "T {title}",
|
| 132 |
+
"meta": dict(DEFAULT_FORMAT["meta"]), "keep_audio": [], "keep_subtitle": []}
|
| 133 |
+
base.update(kw)
|
| 134 |
+
return base
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def test_apply_format_lays_the_format_over_the_settings_without_mutating_them():
|
| 138 |
+
settings = dict(DEFAULT_SETTINGS, mode="caption", caption="<b>{filename}</b>", file_type="document", remove_words=["x"],
|
| 139 |
+
target_channel=-100123, metadata_on=False, keep_audio=["french"])
|
| 140 |
+
settings["meta"] = dict(DEFAULT_SETTINGS["meta"], title="GLOBAL TITLE")
|
| 141 |
+
fmt = fmt_with(template="One Piece - E{episode}", meta=dict(DEFAULT_FORMAT["meta"], author="@Me"), keep_subtitle=["english"])
|
| 142 |
+
out = apply_format(settings, fmt)
|
| 143 |
+
assert out["template"] == "One Piece - E{episode}"
|
| 144 |
+
assert out["meta"]["author"] == "@Me" and out["meta"]["title"] == "" # the format's own metadata, not the global one
|
| 145 |
+
assert out["keep_audio"] == [] and out["keep_subtitle"] == ["english"]
|
| 146 |
+
assert out["metadata_on"] is True
|
| 147 |
+
# everything else is still the user's
|
| 148 |
+
assert (out["mode"], out["caption"], out["file_type"], out["remove_words"], out["target_channel"]) == (
|
| 149 |
+
"caption", "<b>{filename}</b>", "document", ["x"], -100123)
|
| 150 |
+
assert settings["template"] == DEFAULT_SETTINGS["template"] and settings["meta"]["title"] == "GLOBAL TITLE"
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def test_a_format_without_metadata_leaves_the_file_alone():
|
| 154 |
+
assert not has_metadata(fmt_with())
|
| 155 |
+
settings = dict(DEFAULT_SETTINGS, metadata_on=True) # global metadata on, but the format sets nothing
|
| 156 |
+
assert apply_format(settings, fmt_with())["metadata_on"] is False
|
| 157 |
+
assert has_metadata(fmt_with(keep_audio=["1"])) and has_metadata(fmt_with(meta=dict(DEFAULT_FORMAT["meta"], video="v")))
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
# ---------------------------------------------------------------------------
|
| 161 |
+
# editing a format through the shared validation
|
| 162 |
+
# ---------------------------------------------------------------------------
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
async def test_settings_ops_edit_a_format_with_the_same_rules_as_the_global_settings(db):
|
| 166 |
+
fmt = await db.create_format(U, "One Piece")
|
| 167 |
+
store = FormatSettings(db, fmt["id"])
|
| 168 |
+
|
| 169 |
+
ok, msg = await apply_setting(store, U, "template", "One Piece - E{episode}")
|
| 170 |
+
assert ok and "Template saved" in msg and "One Piece - E" in msg
|
| 171 |
+
ok, msg = await apply_setting(store, U, "mt_title", "One Piece")
|
| 172 |
+
assert ok and "Metadata is Off" not in msg
|
| 173 |
+
ok, _ = await apply_setting(store, U, "mt_audio", "{lang} Audio")
|
| 174 |
+
assert ok
|
| 175 |
+
ok, msg = await apply_setting(store, U, "keep_audio", "Japanese English")
|
| 176 |
+
assert ok and "turned it On" not in msg
|
| 177 |
+
ok, _ = await apply_setting(store, U, "keep_subtitle", "2 1")
|
| 178 |
+
assert ok
|
| 179 |
+
|
| 180 |
+
got = await db.get_format(U, fmt["id"])
|
| 181 |
+
assert got["template"] == "One Piece - E{episode}"
|
| 182 |
+
assert got["meta"]["title"] == "One Piece" and got["meta"]["audio"] == "{lang} Audio"
|
| 183 |
+
assert got["keep_audio"] == ["japanese", "english"] and got["keep_subtitle"] == ["2", "1"]
|
| 184 |
+
# the user's own settings were not touched
|
| 185 |
+
assert await db.get_settings(U) == DEFAULT_SETTINGS
|
| 186 |
+
|
| 187 |
+
ok, msg = await apply_setting(store, U, "keep_audio", "off")
|
| 188 |
+
assert ok and "cleared" in msg and (await db.get_format(U, fmt["id"]))["keep_audio"] == []
|
| 189 |
+
ok, msg = await apply_setting(store, U, "mt_title", "off")
|
| 190 |
+
assert ok and (await db.get_format(U, fmt["id"]))["meta"]["title"] == ""
|
| 191 |
+
ok, msg = await apply_setting(store, U, "keep_audio", "bad!word")
|
| 192 |
+
assert not ok and "❌" in msg
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
async def test_editing_a_deleted_format_raises_lookup_error(db):
|
| 196 |
+
fmt = await db.create_format(U, "Gone")
|
| 197 |
+
store = FormatSettings(db, fmt["id"])
|
| 198 |
+
await db.delete_format(U, fmt["id"])
|
| 199 |
+
with pytest.raises(LookupError):
|
| 200 |
+
await apply_setting(store, U, "template", "x {title}")
|
| 201 |
+
with pytest.raises(LookupError):
|
| 202 |
+
await store.get_settings(U)
|
tests/test_formats_app.py
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""/formats (the manager) and /rename (pick a format, send one file)."""
|
| 2 |
+
import asyncio
|
| 3 |
+
import time
|
| 4 |
+
|
| 5 |
+
from bot import app as appmod
|
| 6 |
+
from bot import panels
|
| 7 |
+
from bot.db import MAX_FORMATS
|
| 8 |
+
|
| 9 |
+
from .appkit import ADMIN_CFG, EPISODE, OWNER, click, data_of, jpeg, say, send_doc
|
| 10 |
+
from .tgfakes import FakeEvent, FakeMessage, dispatch, document_message
|
| 11 |
+
|
| 12 |
+
ONE_PIECE = "1147 - One Piece [Dual] 720p @Anime_X_Universe.mkv"
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
async def make_format(ctx, name="One Piece", uid=OWNER, **changes):
|
| 16 |
+
fmt = await ctx.db.create_format(uid, name, template="One Piece - E{episode}")
|
| 17 |
+
if changes:
|
| 18 |
+
fmt = await ctx.db.update_format(uid, fmt["id"], **changes)
|
| 19 |
+
return fmt
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
# ---------------------------------------------------------------------------
|
| 23 |
+
# the manager
|
| 24 |
+
# ---------------------------------------------------------------------------
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
async def test_formats_command_lists_the_formats_and_offers_a_new_one(ctx):
|
| 28 |
+
ev = await say(ctx, OWNER, "/formats")
|
| 29 |
+
assert "FORMATS" in ev.texts[0] and "(0/12)" in ev.texts[0] and "no formats yet" in ev.texts[0]
|
| 30 |
+
assert "fm:new" in data_of(ev.out[0][1])
|
| 31 |
+
|
| 32 |
+
a, b = await make_format(ctx, "One Piece"), await make_format(ctx, "Dragon Ball")
|
| 33 |
+
ev = await say(ctx, OWNER, "/formats")
|
| 34 |
+
data = data_of(ev.out[0][1])
|
| 35 |
+
assert "(2/12)" in ev.texts[0] and f"fm:v:{a['id']}" in data and f"fm:v:{b['id']}" in data and "fm:new" in data
|
| 36 |
+
labels = [label for row in ev.out[0][1] for label in [b.text for b in row]]
|
| 37 |
+
assert "One Piece" in labels and "Dragon Ball" in labels
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
async def test_formats_are_reachable_from_the_home_menu(ctx):
|
| 41 |
+
ev = await say(ctx, OWNER, "/start")
|
| 42 |
+
assert "m:formats" in data_of(ev.out[0][1])
|
| 43 |
+
cb = await click(ctx, OWNER, "m:formats")
|
| 44 |
+
assert "FORMATS" in cb.edits[-1][0] and cb.answers
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
async def test_creating_a_format_asks_for_a_name_and_opens_it(ctx):
|
| 48 |
+
await say(ctx, OWNER, "/template Mine - {title}")
|
| 49 |
+
cb = await click(ctx, OWNER, "fm:new", msg_id=500)
|
| 50 |
+
p = ctx.app.pending[OWNER]
|
| 51 |
+
assert p.kind == "fmt:new" and p.fid == 0 and p.msg_id == 500
|
| 52 |
+
assert cb.edits[-1][0] == panels.FORMAT_PROMPTS["new"] and data_of(cb.edits[-1][1]) == ["m:formats"]
|
| 53 |
+
|
| 54 |
+
ev = await say(ctx, OWNER, " One Piece ", mid=60)
|
| 55 |
+
assert ev.deleted and OWNER not in ctx.app.pending
|
| 56 |
+
(fmt,) = await ctx.db.list_formats(OWNER)
|
| 57 |
+
assert fmt["name"] == "One Piece" and fmt["template"] == "Mine - {title}" # starts from the user's current template
|
| 58 |
+
(chat, text, kw), = ctx.client.edited
|
| 59 |
+
assert kw["message"] == 500 and "created" in text and "🗂 One Piece" in text and f"fm:in:{fmt['id']}:template" in data_of(kw["buttons"])
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
async def test_a_bad_or_taken_name_keeps_the_prompt_open(ctx):
|
| 63 |
+
await make_format(ctx, "One Piece")
|
| 64 |
+
await click(ctx, OWNER, "fm:new")
|
| 65 |
+
for text, expected in (("x" * 31, "too long"), ("one piece", "already have a format")):
|
| 66 |
+
ev = await say(ctx, OWNER, text)
|
| 67 |
+
assert expected in ev.texts[0] and "Try again" in ev.texts[0]
|
| 68 |
+
assert OWNER in ctx.app.pending and await ctx.db.count_formats(OWNER) == 1
|
| 69 |
+
cb = await click(ctx, OWNER, "m:formats") # Cancel
|
| 70 |
+
assert OWNER not in ctx.app.pending and "FORMATS" in cb.edits[-1][0]
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
async def test_the_twelve_format_limit(ctx):
|
| 74 |
+
for i in range(MAX_FORMATS):
|
| 75 |
+
await make_format(ctx, f"F{i}")
|
| 76 |
+
ev = await say(ctx, OWNER, "/formats")
|
| 77 |
+
assert "all 12 formats" in ev.texts[0] and "fm:new" not in data_of(ev.out[0][1])
|
| 78 |
+
cb = await click(ctx, OWNER, "fm:new")
|
| 79 |
+
assert "already have 12" in cb.alerts[0] and OWNER not in ctx.app.pending
|
| 80 |
+
|
| 81 |
+
# the limit is also enforced when it is reached while the name prompt is open
|
| 82 |
+
await ctx.db.delete_format(OWNER, (await ctx.db.list_formats(OWNER))[0]["id"])
|
| 83 |
+
await click(ctx, OWNER, "fm:new")
|
| 84 |
+
await make_format(ctx, "Sneaked in")
|
| 85 |
+
ev = await say(ctx, OWNER, "Late one")
|
| 86 |
+
assert "already have 12" in ev.texts[0] and OWNER not in ctx.app.pending
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
async def test_the_format_screen_shows_every_field_and_has_a_button_for_each(ctx):
|
| 90 |
+
fmt = await make_format(ctx, meta={"title": "One Piece", "video": "HD"}, keep_audio=["japanese", "2"])
|
| 91 |
+
cb = await click(ctx, OWNER, f"fm:v:{fmt['id']}")
|
| 92 |
+
text, buttons = cb.edits[-1]
|
| 93 |
+
for label in ("Template", "Thumbnail", "Title", "Author", "Artist", "Audio", "Subtitle", "Video", "Keep Audio", "Keep Subtitle"):
|
| 94 |
+
assert label in text, label
|
| 95 |
+
assert "One Piece - E{episode}" in text and "Japanese, 2" in text and "your default thumbnail" in text
|
| 96 |
+
assert "<b>Title</b> ▸ <code>One Piece</code>" in text and "<b>Video</b> ▸ <code>HD</code>" in text # values land on the right line
|
| 97 |
+
assert "<b>Author</b> ▸ —" in text
|
| 98 |
+
data = data_of(buttons)
|
| 99 |
+
i = fmt["id"]
|
| 100 |
+
for field in ("name", "template", "title", "author", "artist", "audio", "subtitle", "video", "keep_audio", "keep_subtitle"):
|
| 101 |
+
assert f"fm:in:{i}:{field}" in data, field
|
| 102 |
+
assert {f"fm:th:{i}", f"fm:del:{i}", "m:formats", "x:close"} <= set(data)
|
| 103 |
+
assert f"fm:thd:{i}" not in data # nothing to delete yet
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
async def test_editing_each_field_goes_through_the_same_checks_as_the_settings(ctx):
|
| 107 |
+
fmt = await make_format(ctx)
|
| 108 |
+
i = fmt["id"]
|
| 109 |
+
edits = {
|
| 110 |
+
"template": ("[S{season}E{episode}] {title}", lambda f: f["template"] == "[S{season}E{episode}] {title}"),
|
| 111 |
+
"title": ("One Piece", lambda f: f["meta"]["title"] == "One Piece"),
|
| 112 |
+
"author": ("@Me", lambda f: f["meta"]["author"] == "@Me"),
|
| 113 |
+
"artist": ("@Me", lambda f: f["meta"]["artist"] == "@Me"),
|
| 114 |
+
"audio": ("{lang} Audio", lambda f: f["meta"]["audio"] == "{lang} Audio"),
|
| 115 |
+
"subtitle": ("{lang}", lambda f: f["meta"]["subtitle"] == "{lang}"),
|
| 116 |
+
"video": ("HD", lambda f: f["meta"]["video"] == "HD"),
|
| 117 |
+
"keep_audio": ("Japanese English", lambda f: f["keep_audio"] == ["japanese", "english"]),
|
| 118 |
+
"keep_subtitle": ("2 1", lambda f: f["keep_subtitle"] == ["2", "1"]),
|
| 119 |
+
}
|
| 120 |
+
for n, (field, (value, check)) in enumerate(edits.items()):
|
| 121 |
+
cb = await click(ctx, OWNER, f"fm:in:{i}:{field}", msg_id=700 + n)
|
| 122 |
+
p = ctx.app.pending[OWNER]
|
| 123 |
+
assert p.kind == f"fmt:{field}" and p.fid == i and p.msg_id == 700 + n
|
| 124 |
+
assert cb.edits[-1][0] == panels.FORMAT_PROMPTS[field] and data_of(cb.edits[-1][1]) == [f"fm:cx:{i}"]
|
| 125 |
+
ev = await say(ctx, OWNER, value)
|
| 126 |
+
assert ev.deleted and OWNER not in ctx.app.pending, field
|
| 127 |
+
assert check(await ctx.db.get_format(OWNER, i)), field
|
| 128 |
+
chat, text, kw = ctx.client.edited[-1]
|
| 129 |
+
assert kw["message"] == 700 + n and "🗂 One Piece" in text, field # the format screen is back in the same message
|
| 130 |
+
# none of this touched the user's own settings
|
| 131 |
+
assert (await ctx.db.get_settings(OWNER))["meta"]["title"] == "" and (await ctx.db.get_settings(OWNER))["keep_audio"] == []
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
async def test_invalid_values_keep_the_prompt_open_and_cancel_returns_to_the_format(ctx):
|
| 135 |
+
fmt = await make_format(ctx)
|
| 136 |
+
i = fmt["id"]
|
| 137 |
+
await click(ctx, OWNER, f"fm:in:{i}:keep_audio")
|
| 138 |
+
ev = await say(ctx, OWNER, "bad!word")
|
| 139 |
+
assert "❌" in ev.texts[0] and "Try again" in ev.texts[0] and OWNER in ctx.app.pending
|
| 140 |
+
cb = await click(ctx, OWNER, f"fm:cx:{i}")
|
| 141 |
+
assert OWNER not in ctx.app.pending and "🗂 One Piece" in cb.edits[-1][0]
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
async def test_clearing_a_field_with_off(ctx):
|
| 145 |
+
fmt = await make_format(ctx, meta={"title": "X"}, keep_audio=["english"])
|
| 146 |
+
await click(ctx, OWNER, f"fm:in:{fmt['id']}:title")
|
| 147 |
+
await say(ctx, OWNER, "off")
|
| 148 |
+
await click(ctx, OWNER, f"fm:in:{fmt['id']}:keep_audio")
|
| 149 |
+
await say(ctx, OWNER, "off")
|
| 150 |
+
got = await ctx.db.get_format(OWNER, fmt["id"])
|
| 151 |
+
assert got["meta"]["title"] == "" and got["keep_audio"] == []
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
async def test_renaming_a_format(ctx):
|
| 155 |
+
fmt = await make_format(ctx, "One Piece")
|
| 156 |
+
await make_format(ctx, "Dragon Ball")
|
| 157 |
+
await click(ctx, OWNER, f"fm:in:{fmt['id']}:name")
|
| 158 |
+
ev = await say(ctx, OWNER, "dragon ball")
|
| 159 |
+
assert "already have a format" in ev.texts[0] and OWNER in ctx.app.pending
|
| 160 |
+
await say(ctx, OWNER, "Straw Hats")
|
| 161 |
+
assert (await ctx.db.get_format(OWNER, fmt["id"]))["name"] == "Straw Hats"
|
| 162 |
+
assert "🗂 Straw Hats" in ctx.client.edited[-1][1]
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
async def test_a_format_thumbnail_is_saved_shown_and_deleted(ctx):
|
| 166 |
+
fmt = await make_format(ctx)
|
| 167 |
+
i = fmt["id"]
|
| 168 |
+
cb = await click(ctx, OWNER, f"fm:th:{i}", msg_id=800)
|
| 169 |
+
assert ctx.app.pending[OWNER].kind == "fmt:thumb"
|
| 170 |
+
assert cb.edits[-1][0] == panels.FORMAT_PROMPTS["thumb"]
|
| 171 |
+
ev = await say(ctx, OWNER, "not a photo") # text while a photo is expected
|
| 172 |
+
assert "Send a photo" in ev.texts[0] and OWNER in ctx.app.pending
|
| 173 |
+
|
| 174 |
+
ev = FakeEvent(OWNER, message=FakeMessage("", mid=801, photo=True), photo_bytes=jpeg((900, 600)))
|
| 175 |
+
await dispatch(ctx.client, ev)
|
| 176 |
+
assert OWNER not in ctx.app.pending and ev.out == [] # no "thumbnail saved" for the global one
|
| 177 |
+
stored = await ctx.db.get_format_thumb(OWNER, i)
|
| 178 |
+
assert stored[:2] == b"\xff\xd8" and await ctx.db.get_thumb(OWNER) is None # the format's, not the user's default
|
| 179 |
+
chat, text, kw = ctx.client.edited[-1]
|
| 180 |
+
assert kw["message"] == 800 and "Thumbnail saved" in text and "✅ saved" in text and f"fm:thd:{i}" in data_of(kw["buttons"])
|
| 181 |
+
|
| 182 |
+
cb = await click(ctx, OWNER, f"fm:thd:{i}")
|
| 183 |
+
assert cb.toasts == ["Thumbnail deleted."] and await ctx.db.get_format_thumb(OWNER, i) is None
|
| 184 |
+
assert "your default thumbnail" in cb.edits[-1][0]
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
async def test_a_photo_without_a_pending_format_prompt_is_still_the_global_thumbnail(ctx):
|
| 188 |
+
await make_format(ctx)
|
| 189 |
+
ev = FakeEvent(OWNER, message=FakeMessage("", mid=900, photo=True), photo_bytes=jpeg())
|
| 190 |
+
await dispatch(ctx.client, ev)
|
| 191 |
+
assert "Thumbnail saved" in ev.texts[0] and (await ctx.db.get_thumb(OWNER)) is not None
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
async def test_deleting_a_format_asks_first(ctx):
|
| 195 |
+
fmt = await make_format(ctx)
|
| 196 |
+
i = fmt["id"]
|
| 197 |
+
cb = await click(ctx, OWNER, f"fm:del:{i}")
|
| 198 |
+
assert "Delete the format" in cb.edits[-1][0] and set(data_of(cb.edits[-1][1])) == {f"fm:ok:{i}", f"fm:v:{i}"}
|
| 199 |
+
assert await ctx.db.get_format(OWNER, i) is not None
|
| 200 |
+
cb = await click(ctx, OWNER, f"fm:v:{i}") # "Keep it"
|
| 201 |
+
assert "🗂 One Piece" in cb.edits[-1][0]
|
| 202 |
+
cb = await click(ctx, OWNER, f"fm:ok:{i}")
|
| 203 |
+
assert await ctx.db.get_format(OWNER, i) is None
|
| 204 |
+
assert "FORMATS" in cb.edits[-1][0] and cb.toasts == ["Deleted One Piece."]
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
async def test_buttons_of_a_deleted_format_say_so_and_show_the_list(ctx):
|
| 208 |
+
fmt = await make_format(ctx)
|
| 209 |
+
await ctx.db.delete_format(OWNER, fmt["id"])
|
| 210 |
+
for data in (f"fm:v:{fmt['id']}", f"fm:in:{fmt['id']}:title", f"fm:th:{fmt['id']}", f"fm:del:{fmt['id']}", f"fm:ok:{fmt['id']}"):
|
| 211 |
+
cb = await click(ctx, OWNER, data)
|
| 212 |
+
assert cb.alerts == ["That format no longer exists."] and "FORMATS" in cb.edits[-1][0], data
|
| 213 |
+
assert OWNER not in ctx.app.pending
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
async def test_typing_into_a_prompt_for_a_deleted_format_is_handled(ctx):
|
| 217 |
+
fmt = await make_format(ctx)
|
| 218 |
+
await click(ctx, OWNER, f"fm:in:{fmt['id']}:title")
|
| 219 |
+
await ctx.db.delete_format(OWNER, fmt["id"])
|
| 220 |
+
ev = await say(ctx, OWNER, "Title")
|
| 221 |
+
assert "no longer exists" in ev.texts[0] and OWNER not in ctx.app.pending
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
async def test_formats_belong_to_one_user(ctx):
|
| 225 |
+
mine = await make_format(ctx, "Mine")
|
| 226 |
+
cb = await click(ctx, ADMIN_CFG, f"fm:v:{mine['id']}")
|
| 227 |
+
assert cb.alerts == ["That format no longer exists."]
|
| 228 |
+
cb = await click(ctx, ADMIN_CFG, f"fm:ok:{mine['id']}")
|
| 229 |
+
assert await ctx.db.get_format(OWNER, mine["id"]) is not None
|
| 230 |
+
ev = await say(ctx, ADMIN_CFG, "/formats")
|
| 231 |
+
assert "no formats yet" in ev.texts[0]
|
| 232 |
+
ev = await say(ctx, ADMIN_CFG, "/rename")
|
| 233 |
+
assert "no saved formats" in ev.texts[0]
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
async def test_any_other_tap_abandons_an_open_prompt(ctx):
|
| 237 |
+
fmt = await make_format(ctx)
|
| 238 |
+
await click(ctx, OWNER, f"fm:in:{fmt['id']}:title")
|
| 239 |
+
await click(ctx, OWNER, "fm:new")
|
| 240 |
+
assert ctx.app.pending[OWNER].kind == "fmt:new"
|
| 241 |
+
await click(ctx, OWNER, f"fm:v:{fmt['id']}")
|
| 242 |
+
assert OWNER not in ctx.app.pending
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
# ---------------------------------------------------------------------------
|
| 246 |
+
# /rename: pick a format, send one file
|
| 247 |
+
# ---------------------------------------------------------------------------
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
async def test_rename_without_formats_points_to_creating_one(ctx):
|
| 251 |
+
ev = await say(ctx, OWNER, "/rename")
|
| 252 |
+
assert "no saved formats" in ev.texts[0] and "fm:new" in data_of(ev.out[0][1])
|
| 253 |
+
ev = await say(ctx, OWNER, "/rename New Name", reply=FakeMessage("x", mid=3)) # the old reply-with-a-name form is gone
|
| 254 |
+
assert "no saved formats" in ev.texts[0] and not ctx.app.queue.jobs
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
async def test_rename_offers_the_saved_formats_by_name(ctx):
|
| 258 |
+
a, b = await make_format(ctx, "One Piece"), await make_format(ctx, "Dragon Ball")
|
| 259 |
+
ev = await say(ctx, OWNER, "/rename")
|
| 260 |
+
assert "Choose the format" in ev.texts[0]
|
| 261 |
+
data = data_of(ev.out[0][1])
|
| 262 |
+
assert f"rn:p:{a['id']}" in data and f"rn:p:{b['id']}" in data and "m:formats" in data
|
| 263 |
+
labels = [btn.text for row in ev.out[0][1] for btn in row]
|
| 264 |
+
assert "One Piece" in labels and "Dragon Ball" in labels
|
| 265 |
+
assert not ctx.app.queue.jobs and ctx.app._mode(OWNER) is None # nothing is armed until a format is chosen
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
async def test_choosing_a_format_then_sending_a_file_renames_it_with_that_format(ctx):
|
| 269 |
+
await ctx.db.update_settings(OWNER, template="DEFAULT {title}")
|
| 270 |
+
fmt = await make_format(ctx, meta={"title": "One Piece"})
|
| 271 |
+
cb = await click(ctx, OWNER, f"rn:p:{fmt['id']}")
|
| 272 |
+
assert cb.toasts == ["Format: One Piece"] and ctx.app._mode(OWNER, "rename") is not None
|
| 273 |
+
text, buttons = cb.edits[-1]
|
| 274 |
+
assert "One Piece" in text and "one file at a time" in text and "/rename again" in text
|
| 275 |
+
assert set(data_of(buttons)) == {"rn:menu", "rn:stop"}
|
| 276 |
+
|
| 277 |
+
ev = await send_doc(ctx, OWNER, ONE_PIECE, mid=50)
|
| 278 |
+
(job,) = ctx.app.queue.jobs.values()
|
| 279 |
+
assert job.new_name == "One Piece - E1147.mkv" # the format's template, not the default one
|
| 280 |
+
assert job.fmt["id"] == fmt["id"] and job.fmt["meta"]["title"] == "One Piece" and job.order == 50
|
| 281 |
+
assert "Queued" in ev.texts[0] and "🗂 Format: <b>One Piece</b>" in ev.texts[0] and "One Piece - E1147.mkv" in ev.texts[0]
|
| 282 |
+
assert ctx.app._mode(OWNER) is None # it was for one file
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
async def test_other_settings_still_come_from_the_users_own_settings(ctx):
|
| 286 |
+
await ctx.db.update_settings(OWNER, mode="caption", remove_words=["@Junk"])
|
| 287 |
+
fmt = await make_format(ctx)
|
| 288 |
+
await click(ctx, OWNER, f"rn:p:{fmt['id']}")
|
| 289 |
+
await send_doc(ctx, OWNER, "xyz.mkv", caption="1148 - One Piece 720p @Junk", mid=51)
|
| 290 |
+
(job,) = ctx.app.queue.jobs.values()
|
| 291 |
+
assert job.new_name == "One Piece - E1148.mkv" # read from the caption (the user's mode)
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
async def test_the_next_file_needs_rename_again(ctx):
|
| 295 |
+
fmt = await make_format(ctx)
|
| 296 |
+
await click(ctx, OWNER, f"rn:p:{fmt['id']}")
|
| 297 |
+
await send_doc(ctx, OWNER, ONE_PIECE, mid=50)
|
| 298 |
+
# a second file straight after is not auto-renamed behind the user's back: one hint, then silence
|
| 299 |
+
ev = await send_doc(ctx, OWNER, "1148 - One Piece [Dual] 720p.mkv", mid=51)
|
| 300 |
+
assert "one file at a time" in ev.texts[0] and "/rename again" in ev.texts[0]
|
| 301 |
+
ev = await send_doc(ctx, OWNER, "1149 - One Piece [Dual] 720p.mkv", mid=52)
|
| 302 |
+
assert ev.out == []
|
| 303 |
+
assert [j.file_name for j in ctx.app.queue.jobs.values()] == [ONE_PIECE]
|
| 304 |
+
|
| 305 |
+
# once the short grace period is over, files are renamed with the normal settings as usual
|
| 306 |
+
ctx.app._recent[OWNER][1] -= appmod.ONE_SHOT_GRACE + 1
|
| 307 |
+
ev = await send_doc(ctx, OWNER, "1150 - One Piece [Dual] 720p.mkv", mid=53)
|
| 308 |
+
assert "Queued" in ev.texts[0] and "Format" not in ev.texts[0]
|
| 309 |
+
assert len(ctx.app.queue.jobs) == 2
|
| 310 |
+
|
| 311 |
+
# and choosing a format again renames the next one with it
|
| 312 |
+
await click(ctx, OWNER, f"rn:p:{fmt['id']}")
|
| 313 |
+
ev = await send_doc(ctx, OWNER, "1151 - One Piece [Dual] 720p.mkv", mid=54)
|
| 314 |
+
assert "🗂 Format: <b>One Piece</b>" in ev.texts[0] and len(ctx.app.queue.jobs) == 3
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
async def test_a_forwarded_batch_gives_the_format_to_exactly_one_file(ctx):
|
| 318 |
+
fmt = await make_format(ctx)
|
| 319 |
+
await click(ctx, OWNER, f"rn:p:{fmt['id']}")
|
| 320 |
+
events = [FakeEvent(OWNER, message=document_message(f"114{n} - One Piece 720p.mkv", 1024, mid=60 + n)) for n in range(5, 9)]
|
| 321 |
+
await asyncio.gather(*(dispatch(ctx.client, ev) for ev in events))
|
| 322 |
+
jobs = list(ctx.app.queue.jobs.values())
|
| 323 |
+
assert len(jobs) == 1 and jobs[0].fmt["id"] == fmt["id"]
|
| 324 |
+
hints = [t for ev in events for t in ev.texts if "one file at a time" in t]
|
| 325 |
+
assert len(hints) == 1 # one hint for the whole batch, not one per file
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
async def test_the_chosen_format_is_forgotten_after_the_wait(ctx):
|
| 329 |
+
fmt = await make_format(ctx)
|
| 330 |
+
await click(ctx, OWNER, f"rn:p:{fmt['id']}")
|
| 331 |
+
ctx.app.modes[OWNER].expires = time.time() - 1
|
| 332 |
+
ev = await send_doc(ctx, OWNER, ONE_PIECE, mid=50)
|
| 333 |
+
(job,) = ctx.app.queue.jobs.values()
|
| 334 |
+
assert job.fmt is None and "Format" not in ev.texts[0] # a normal auto-rename
|
| 335 |
+
assert ctx.app._mode(OWNER) is None
|
| 336 |
+
|
| 337 |
+
|
| 338 |
+
async def test_a_format_deleted_after_choosing_it_is_reported_and_nothing_is_queued(ctx):
|
| 339 |
+
fmt = await make_format(ctx)
|
| 340 |
+
await click(ctx, OWNER, f"rn:p:{fmt['id']}")
|
| 341 |
+
await ctx.db.delete_format(OWNER, fmt["id"])
|
| 342 |
+
ev = await send_doc(ctx, OWNER, ONE_PIECE, mid=50)
|
| 343 |
+
assert "format was deleted" in ev.texts[0] and not ctx.app.queue.jobs and ctx.app._mode(OWNER) is None
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
async def test_a_template_variable_missing_from_the_file_is_flagged_for_the_format_too(ctx):
|
| 347 |
+
fmt = await make_format(ctx)
|
| 348 |
+
await click(ctx, OWNER, f"rn:p:{fmt['id']}")
|
| 349 |
+
ev = await send_doc(ctx, OWNER, "Plain Movie.mkv", mid=50)
|
| 350 |
+
(job,) = ctx.app.queue.jobs.values()
|
| 351 |
+
assert job.new_name == "One Piece - E.mkv" and "⚠️" in ev.texts[0] and "{episode}" in ev.texts[0]
|
| 352 |
+
|
| 353 |
+
|
| 354 |
+
async def test_choosing_a_format_that_is_gone_or_not_yours(ctx):
|
| 355 |
+
fmt = await make_format(ctx)
|
| 356 |
+
cb = await click(ctx, ADMIN_CFG, f"rn:p:{fmt['id']}") # somebody else's
|
| 357 |
+
assert cb.alerts == ["That format no longer exists."] and ctx.app._mode(ADMIN_CFG) is None
|
| 358 |
+
await ctx.db.delete_format(OWNER, fmt["id"])
|
| 359 |
+
cb = await click(ctx, OWNER, f"rn:p:{fmt['id']}")
|
| 360 |
+
assert cb.alerts == ["That format no longer exists."] and "no saved formats" in cb.edits[-1][0]
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
async def test_one_users_chosen_format_does_not_affect_another(ctx):
|
| 364 |
+
fmt = await make_format(ctx)
|
| 365 |
+
await click(ctx, OWNER, f"rn:p:{fmt['id']}")
|
| 366 |
+
await send_doc(ctx, ADMIN_CFG, EPISODE, mid=70)
|
| 367 |
+
(job,) = ctx.app.queue.jobs.values()
|
| 368 |
+
assert job.user_id == ADMIN_CFG and job.fmt is None
|
| 369 |
+
assert ctx.app._mode(OWNER, "rename") is not None # still waiting for the owner's file
|
| 370 |
+
|
| 371 |
+
|
| 372 |
+
async def test_change_format_and_stop_buttons(ctx):
|
| 373 |
+
a, b = await make_format(ctx, "One Piece"), await make_format(ctx, "Dragon Ball")
|
| 374 |
+
await click(ctx, OWNER, f"rn:p:{a['id']}")
|
| 375 |
+
cb = await click(ctx, OWNER, "rn:menu")
|
| 376 |
+
assert "Choose the format" in cb.edits[-1][0] and ctx.app._mode(OWNER) is None
|
| 377 |
+
await click(ctx, OWNER, f"rn:p:{b['id']}")
|
| 378 |
+
assert ctx.app.modes[OWNER].data["fid"] == b["id"]
|
| 379 |
+
cb = await click(ctx, OWNER, "rn:stop")
|
| 380 |
+
assert "Stopped" in cb.edits[-1][0] and ctx.app._mode(OWNER) is None
|
| 381 |
+
ev = await send_doc(ctx, OWNER, ONE_PIECE, mid=50)
|
| 382 |
+
(job,) = ctx.app.queue.jobs.values()
|
| 383 |
+
assert job.fmt is None and "Format" not in ev.texts[0]
|
| 384 |
+
assert not ctx.app._recent # stopping is not a finished one-file command: no hint for later files
|
| 385 |
+
|
| 386 |
+
|
| 387 |
+
async def test_cancel_ends_the_chosen_format_before_it_touches_queued_files(ctx):
|
| 388 |
+
fmt = await make_format(ctx)
|
| 389 |
+
await send_doc(ctx, OWNER, EPISODE, mid=40) # something is already queued
|
| 390 |
+
await click(ctx, OWNER, f"rn:p:{fmt['id']}")
|
| 391 |
+
ev = await say(ctx, OWNER, "/cancel")
|
| 392 |
+
assert "Cancelled" in ev.texts[0] and "/rename again" in ev.texts[0] and "/cancel again" in ev.texts[0]
|
| 393 |
+
assert ctx.app._mode(OWNER) is None and len(ctx.app.queue.jobs) == 1
|
| 394 |
+
ev = await say(ctx, OWNER, "/cancel")
|
| 395 |
+
assert "Cancelling 1 job" in ev.texts[0] and not ctx.app.queue.jobs
|
| 396 |
+
|
| 397 |
+
|
| 398 |
+
async def test_cancel_with_nothing_armed_still_cancels_jobs(ctx):
|
| 399 |
+
await send_doc(ctx, OWNER, EPISODE, mid=40)
|
| 400 |
+
ev = await say(ctx, OWNER, "/cancel")
|
| 401 |
+
assert "Cancelling 1 job" in ev.texts[0]
|
| 402 |
+
ev = await say(ctx, OWNER, "/cancel")
|
| 403 |
+
assert "no active jobs" in ev.texts[0]
|
| 404 |
+
|
| 405 |
+
|
| 406 |
+
async def test_start_and_the_rename_picker_close_an_open_text_prompt(ctx):
|
| 407 |
+
fmt = await make_format(ctx)
|
| 408 |
+
await click(ctx, OWNER, f"fm:in:{fmt['id']}:title")
|
| 409 |
+
await say(ctx, OWNER, "/rename")
|
| 410 |
+
assert OWNER not in ctx.app.pending
|
| 411 |
+
await click(ctx, OWNER, f"fm:in:{fmt['id']}:title")
|
| 412 |
+
await say(ctx, OWNER, "/formats")
|
| 413 |
+
assert OWNER not in ctx.app.pending
|
| 414 |
+
|
| 415 |
+
|
| 416 |
+
def test_the_format_screens_fit_in_a_caption_even_with_the_longest_values():
|
| 417 |
+
from .test_panels import CAPTION_LIMIT, visible
|
| 418 |
+
|
| 419 |
+
longest = ["klingon-language-tag-x"] * 12
|
| 420 |
+
fmt = {
|
| 421 |
+
"id": 12, "name": "N" * 30, "has_thumb": True, "template": "T" * 200,
|
| 422 |
+
"meta": {k: "M" * 200 for k in ("title", "author", "artist", "audio", "subtitle", "video")},
|
| 423 |
+
"keep_audio": longest, "keep_subtitle": longest,
|
| 424 |
+
}
|
| 425 |
+
formats = [dict(fmt, id=i, name="F" * 30) for i in range(12)]
|
| 426 |
+
for name, (text, rows) in {
|
| 427 |
+
"view": panels.format_view(fmt), "menu": panels.formats_menu(formats, 12), "delete": panels.format_delete_confirm(fmt),
|
| 428 |
+
"ready": panels.rename_ready(fmt, 10), "picker": panels.rename_picker(formats),
|
| 429 |
+
**{f"prompt_{k}": panels.format_prompt(k, 12) for k in panels.FORMAT_PROMPTS},
|
| 430 |
+
}.items():
|
| 431 |
+
assert len(visible(text)) <= CAPTION_LIMIT, (name, len(visible(text)))
|
| 432 |
+
assert text.count("<b>") == text.count("</b>") and text.count("<code>") == text.count("</code>"), name
|
| 433 |
+
for row in rows:
|
| 434 |
+
assert 1 <= len(row) <= 3, name
|
| 435 |
+
for label, data in row:
|
| 436 |
+
assert label.strip() and len(label) <= 32 and 0 < len(data.encode()) <= 64, (name, label, data)
|
tests/test_media.py
CHANGED
|
@@ -9,12 +9,16 @@ from bot.media import (
|
|
| 9 |
apply_plan,
|
| 10 |
build_plan,
|
| 11 |
canonical_lang,
|
|
|
|
| 12 |
format_tracks,
|
|
|
|
| 13 |
make_video_thumbnail,
|
| 14 |
parse_probe,
|
|
|
|
| 15 |
parse_track_tokens,
|
| 16 |
prepare_thumb,
|
| 17 |
probe,
|
|
|
|
| 18 |
resolve_tokens,
|
| 19 |
validate_track_tokens,
|
| 20 |
)
|
|
@@ -319,3 +323,118 @@ async def test_thumbnail_and_prepare_thumb(media_dir, tmp_path):
|
|
| 319 |
img = Image.open(io.BytesIO(jpg))
|
| 320 |
assert max(img.size) <= 320 and len(jpg) <= 200 * 1024 and img.format == "JPEG"
|
| 321 |
assert not await make_video_thumbnail(tmp_path / "missing.mkv", tmp_path / "x.jpg", 1.0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
apply_plan,
|
| 10 |
build_plan,
|
| 11 |
canonical_lang,
|
| 12 |
+
extract_frame,
|
| 13 |
format_tracks,
|
| 14 |
+
frame_seeks,
|
| 15 |
make_video_thumbnail,
|
| 16 |
parse_probe,
|
| 17 |
+
parse_size,
|
| 18 |
parse_track_tokens,
|
| 19 |
prepare_thumb,
|
| 20 |
probe,
|
| 21 |
+
resize_image,
|
| 22 |
resolve_tokens,
|
| 23 |
validate_track_tokens,
|
| 24 |
)
|
|
|
|
| 323 |
img = Image.open(io.BytesIO(jpg))
|
| 324 |
assert max(img.size) <= 320 and len(jpg) <= 200 * 1024 and img.format == "JPEG"
|
| 325 |
assert not await make_video_thumbnail(tmp_path / "missing.mkv", tmp_path / "x.jpg", 1.0)
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
# ---------------------------------------------------------------------------
|
| 329 |
+
# /ethumb and /resize helpers
|
| 330 |
+
# ---------------------------------------------------------------------------
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
def test_frame_seeks_start_at_about_a_tenth_and_fall_back_to_earlier_positions():
|
| 334 |
+
assert frame_seeks(1400) == [60.0, 20.0, 5.0, 0.0]
|
| 335 |
+
assert frame_seeks(100) == [10.0, 5.0, 0.0]
|
| 336 |
+
assert frame_seeks(2) == [0.2, 0.0]
|
| 337 |
+
assert frame_seeks(0) == [1.0, 0.0] # duration unknown (a cut-off file)
|
| 338 |
+
assert frame_seeks(-5) == [1.0, 0.0]
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
@pytest.mark.parametrize("text,expected", [
|
| 342 |
+
("320", (320, 320)), (" 320 ", (320, 320)), ("320x180", (320, 180)), ("320X180", (320, 180)), ("320×180", (320, 180)),
|
| 343 |
+
("320 x 180", (320, 180)), ("320*180", (320, 180)), ("320 180", (320, 180)), ("320,180", (320, 180)),
|
| 344 |
+
("16x16", (16, 16)), ("4096x4096", (4096, 4096)),
|
| 345 |
+
("15x300", None), ("5000", None), ("99999x99999", None), ("320x", None), ("x180", None), ("320x180x90", None),
|
| 346 |
+
("320 px", None), ("-5", None), ("abc", None), ("", None),
|
| 347 |
+
])
|
| 348 |
+
def test_parse_size(text, expected):
|
| 349 |
+
assert parse_size(text) == expected
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
def _png(size, color=(10, 120, 200), mode="RGB"):
|
| 353 |
+
import io
|
| 354 |
+
from PIL import Image
|
| 355 |
+
|
| 356 |
+
buf = io.BytesIO()
|
| 357 |
+
Image.new(mode, size, color).save(buf, "PNG")
|
| 358 |
+
return buf.getvalue()
|
| 359 |
+
|
| 360 |
+
|
| 361 |
+
def test_resize_image_fits_inside_the_box_and_keeps_the_shape():
|
| 362 |
+
import io
|
| 363 |
+
from PIL import Image
|
| 364 |
+
|
| 365 |
+
data, original, new = resize_image(_png((1920, 1080)), 320, 320)
|
| 366 |
+
assert original == (1920, 1080) and new == (320, 180)
|
| 367 |
+
img = Image.open(io.BytesIO(data))
|
| 368 |
+
assert img.format == "JPEG" and img.size == new
|
| 369 |
+
assert resize_image(_png((1920, 1080)), 100, 400)[2] == (100, 56)
|
| 370 |
+
assert resize_image(_png((1000, 2000)), 500, 500)[2] == (250, 500)
|
| 371 |
+
assert resize_image(_png((100, 50)), 400, 400)[2] == (400, 200) # small images are scaled up to the size asked for
|
| 372 |
+
|
| 373 |
+
|
| 374 |
+
def test_a_thumbnail_sized_result_stays_under_200_kb_even_for_noisy_images():
|
| 375 |
+
import io
|
| 376 |
+
from PIL import Image
|
| 377 |
+
|
| 378 |
+
noisy = io.BytesIO()
|
| 379 |
+
Image.effect_noise((2000, 2000), 90).convert("RGB").save(noisy, "PNG")
|
| 380 |
+
data, _original, new = resize_image(noisy.getvalue(), 320, 320)
|
| 381 |
+
assert max(new) == 320 and len(data) <= 200 * 1024
|
| 382 |
+
big, _o, bigsize = resize_image(noisy.getvalue(), 1280, 1280) # larger results are not squeezed
|
| 383 |
+
assert max(bigsize) == 1280 and len(big) > 200 * 1024
|
| 384 |
+
|
| 385 |
+
|
| 386 |
+
def test_transparent_images_get_a_white_background_not_a_black_one():
|
| 387 |
+
import io
|
| 388 |
+
from PIL import Image
|
| 389 |
+
|
| 390 |
+
data, _o, _n = resize_image(_png((200, 200), (0, 0, 0, 0), mode="RGBA"), 100, 100)
|
| 391 |
+
pixel = Image.open(io.BytesIO(data)).getpixel((50, 50))
|
| 392 |
+
assert min(pixel) > 240
|
| 393 |
+
|
| 394 |
+
|
| 395 |
+
def test_exif_orientation_is_applied():
|
| 396 |
+
import io
|
| 397 |
+
from PIL import Image
|
| 398 |
+
|
| 399 |
+
img = Image.new("RGB", (200, 100), (200, 0, 0))
|
| 400 |
+
exif = img.getexif()
|
| 401 |
+
exif[274] = 6 # rotate 90 degrees clockwise when displayed
|
| 402 |
+
buf = io.BytesIO()
|
| 403 |
+
img.save(buf, "JPEG", exif=exif)
|
| 404 |
+
_data, original, new = resize_image(buf.getvalue(), 100, 100)
|
| 405 |
+
assert original == (100, 200) and new == (50, 100)
|
| 406 |
+
|
| 407 |
+
|
| 408 |
+
def test_resize_image_rejects_what_is_not_an_image():
|
| 409 |
+
with pytest.raises(Exception):
|
| 410 |
+
resize_image(b"definitely not an image", 100, 100)
|
| 411 |
+
|
| 412 |
+
|
| 413 |
+
@needs_ffmpeg
|
| 414 |
+
async def test_extract_frame_writes_a_jpeg_and_tries_the_next_position_after_a_miss(media_dir, tmp_path):
|
| 415 |
+
out = tmp_path / "f.jpg"
|
| 416 |
+
assert await extract_frame(media_dir / "sample.mkv", out, [1.0])
|
| 417 |
+
assert out.read_bytes()[:2] == b"\xff\xd8"
|
| 418 |
+
from PIL import Image
|
| 419 |
+
|
| 420 |
+
assert Image.open(out).size == (320, 240) # the sample is smaller than the 1280 px limit
|
| 421 |
+
out.unlink()
|
| 422 |
+
assert await extract_frame(media_dir / "sample.mkv", out, [500.0, 0.0]) # nothing at 500 s: the next position works
|
| 423 |
+
assert out.stat().st_size > 0
|
| 424 |
+
|
| 425 |
+
|
| 426 |
+
@needs_ffmpeg
|
| 427 |
+
async def test_extract_frame_scales_down_wide_pictures(media_dir, tmp_path):
|
| 428 |
+
from PIL import Image
|
| 429 |
+
|
| 430 |
+
out = tmp_path / "small.jpg"
|
| 431 |
+
assert await extract_frame(media_dir / "sample.mkv", out, [0.0], width=160)
|
| 432 |
+
assert Image.open(out).size == (160, 120)
|
| 433 |
+
|
| 434 |
+
|
| 435 |
+
@needs_ffmpeg
|
| 436 |
+
async def test_extract_frame_gives_up_on_junk_without_raising(tmp_path):
|
| 437 |
+
junk = tmp_path / "junk.mkv"
|
| 438 |
+
junk.write_bytes(b"\x00" * 4096)
|
| 439 |
+
assert not await extract_frame(junk, tmp_path / "j.jpg", [1.0, 0.0])
|
| 440 |
+
assert not await extract_frame(tmp_path / "missing.mkv", tmp_path / "m.jpg", [0.0])
|
tests/test_mediainfo.py
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""/mediainfo: reading a file and laying the report out."""
|
| 2 |
+
import pytest
|
| 3 |
+
|
| 4 |
+
from bot import mediainfo as mi
|
| 5 |
+
from bot import telegraph
|
| 6 |
+
|
| 7 |
+
from .conftest import needs_ffmpeg
|
| 8 |
+
|
| 9 |
+
TEXT = """General
|
| 10 |
+
Complete name : /tmp/work/minfo1_2/probe.mkv
|
| 11 |
+
Format : Matroska
|
| 12 |
+
Conformance errors : 2
|
| 13 |
+
0x8538067 : Yes
|
| 14 |
+
General compliance : Element size 1 is more than maximal permitted size 2 (offset 0x34)
|
| 15 |
+
Matroska : Yes
|
| 16 |
+
CRC_Error_Pos : 943
|
| 17 |
+
Writing library : Lavf
|
| 18 |
+
|
| 19 |
+
Video
|
| 20 |
+
ID : 1
|
| 21 |
+
Format : AVC
|
| 22 |
+
Title : \x07bell\x1b[0m here
|
| 23 |
+
|
| 24 |
+
Menu
|
| 25 |
+
00:00:00.000 : Intro
|
| 26 |
+
Plain line without a separator
|
| 27 |
+
Empty value :
|
| 28 |
+
"""
|
| 29 |
+
|
| 30 |
+
FFPROBE = {
|
| 31 |
+
"format": {
|
| 32 |
+
"format_long_name": "Matroska / WebM", "duration": "1441.419",
|
| 33 |
+
"tags": {"title": "Show", "ENCODER": "libebml", "creation_time": "2026-01-01T00:00:00Z", "WEIRD_KEY": "x"},
|
| 34 |
+
},
|
| 35 |
+
"streams": [
|
| 36 |
+
{"index": 0, "codec_type": "video", "codec_name": "hevc", "codec_long_name": "H.265 / HEVC", "profile": "Main 10",
|
| 37 |
+
"level": 123, "width": 1920, "height": 1080, "avg_frame_rate": "24000/1001", "pix_fmt": "yuv420p10le",
|
| 38 |
+
"bits_per_raw_sample": "10", "display_aspect_ratio": "16:9", "color_space": "bt709",
|
| 39 |
+
"disposition": {"default": 1}, "tags": {"BPS": "3200000", "DURATION": "00:24:01.419000000"}},
|
| 40 |
+
{"index": 1, "codec_type": "audio", "codec_name": "aac", "profile": "LC", "channels": 2, "channel_layout": "stereo",
|
| 41 |
+
"sample_rate": "48000", "disposition": {"default": 1, "forced": 0}, "tags": {"language": "jpn", "title": "Japanese"}},
|
| 42 |
+
{"index": 2, "codec_type": "audio", "codec_name": "ac3", "channels": 1, "sample_rate": "44100", "tags": {"language": "hin"}},
|
| 43 |
+
{"index": 3, "codec_type": "subtitle", "codec_name": "ass", "disposition": {"forced": 1}, "tags": {"language": "eng"}},
|
| 44 |
+
{"index": 4, "codec_type": "attachment", "codec_name": "ttf", "tags": {"filename": "font.ttf"}},
|
| 45 |
+
],
|
| 46 |
+
"chapters": [{"start_time": "0.000000", "tags": {"title": "Intro"}}, {"start_time": "90.5", "tags": {"title": "Main"}}],
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def lines_of(report, title):
|
| 51 |
+
return dict(report.section(title).lines)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def report_of(*sections, **kw):
|
| 55 |
+
return mi.Report(sections=list(sections), engine=kw.pop("engine", "MediaInfo"), **kw)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
# ---------------------------------------------------------------------------
|
| 59 |
+
# MediaInfo's number formats
|
| 60 |
+
# ---------------------------------------------------------------------------
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
@pytest.mark.parametrize("num, text", [
|
| 64 |
+
(0, "0 Bytes"), (999, "999 Bytes"), (1024, "1.00 KiB"), (71 * 1024, "71.0 KiB"), (93550, "91.4 KiB"),
|
| 65 |
+
(845 * 1024 ** 2, "845 MiB"), (int(1.46 * 1024 ** 3), "1.46 GiB"), (int(1023.9 * 1024), "1.00 MiB"), (5 * 1024 ** 4, "5.00 TiB"),
|
| 66 |
+
])
|
| 67 |
+
def test_sizes_are_written_like_mediainfo_does(num, text):
|
| 68 |
+
assert mi.mi_size(num) == text
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
@pytest.mark.parametrize("bps, text", [
|
| 72 |
+
(62, "62 b/s"), (370_000, "370 kb/s"), (1_936_000, "1 936 kb/s"), (9_999_000, "9 999 kb/s"),
|
| 73 |
+
(12_340_000, "12.3 Mb/s"), (150_000_000, "150 Mb/s"),
|
| 74 |
+
])
|
| 75 |
+
def test_bit_rates_are_written_like_mediainfo_does(bps, text):
|
| 76 |
+
assert mi.mi_rate(bps) == text
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
@pytest.mark.parametrize("ms, text", [
|
| 80 |
+
(2023, "2 s 23 ms"), (1_441_000, "24 min 1 s"), (5_040_000, "1 h 24 min"), (0, "0 s 0 ms"), (59_999.6, "1 min 0 s"),
|
| 81 |
+
])
|
| 82 |
+
def test_durations_are_written_like_mediainfo_does(ms, text):
|
| 83 |
+
assert mi.mi_duration(ms) == text
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
# ---------------------------------------------------------------------------
|
| 87 |
+
# reading MediaInfo's text
|
| 88 |
+
# ---------------------------------------------------------------------------
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def test_the_text_is_split_into_sections_of_labels_and_values():
|
| 92 |
+
sections = mi.parse_text(TEXT)
|
| 93 |
+
assert [s.title for s in sections] == ["General", "Video", "Menu"]
|
| 94 |
+
general = sections[0].lines
|
| 95 |
+
assert general[0] == ("Complete name", "/tmp/work/minfo1_2/probe.mkv")
|
| 96 |
+
assert (" 0x8538067", "Yes") in general and (" General compliance", general[4][1]) == general[4] # nesting is kept
|
| 97 |
+
video = dict(sections[1].lines)
|
| 98 |
+
assert video["Title"] == "bell[0m here" # control characters are gone
|
| 99 |
+
menu = sections[2].lines
|
| 100 |
+
assert menu[0] == ("00:00:00.000", "Intro") and ("", "Plain line without a separator") in menu and ("Empty value", "") in menu
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def test_windows_line_endings_and_blank_runs_are_fine():
|
| 104 |
+
sections = mi.parse_text("General\r\nFormat : MPEG-4\r\n\r\n\r\nAudio\r\nFormat : AAC\r\n")
|
| 105 |
+
assert [(s.title, s.lines) for s in sections] == [("General", [("Format", "MPEG-4")]), ("Audio", [("Format", "AAC")])]
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
# ---------------------------------------------------------------------------
|
| 109 |
+
# the General section of a file we only have the start of
|
| 110 |
+
# ---------------------------------------------------------------------------
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def test_a_cut_short_file_gets_its_real_name_and_size_and_no_false_alarms():
|
| 114 |
+
report = report_of(*mi.parse_text(TEXT), truncated=True)
|
| 115 |
+
mi.fix_general(report, "Show 01.mkv", 700 * 1024 ** 2, 1_440_000.0)
|
| 116 |
+
general = report.section("General")
|
| 117 |
+
assert [label for label, _ in general.lines] == ["Complete name", "Format", "Writing library", "File size", "Overall bit rate"]
|
| 118 |
+
values = dict(general.lines)
|
| 119 |
+
assert values["Complete name"] == "Show 01.mkv" and values["File size"] == "700 MiB"
|
| 120 |
+
assert values["Overall bit rate"] == "4 078 kb/s" # 700 MiB over 24 minutes
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def test_a_cut_short_file_with_no_duration_loses_a_bit_rate_that_would_be_wrong():
|
| 124 |
+
report = report_of(*mi.parse_text("General\nFormat : X\nOverall bit rate : 11.9 kb/s\nOverall bit rate mode : Constant\n"), truncated=True)
|
| 125 |
+
mi.fix_general(report, "a.bin", 5000, None)
|
| 126 |
+
assert [label for label, _ in report.section("General").lines] == ["Complete name", "Format", "Overall bit rate mode", "File size"]
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def test_a_whole_file_keeps_everything_mediainfo_says_about_it():
|
| 130 |
+
report = report_of(*mi.parse_text(TEXT))
|
| 131 |
+
mi.fix_general(report, "x.mkv", 1000, 1000.0)
|
| 132 |
+
labels = [label for label, _ in report.section("General").lines]
|
| 133 |
+
assert "Conformance errors" in labels and "CRC_Error_Pos" in labels and " 0x8538067" in labels # real problems stay visible
|
| 134 |
+
assert dict(report.section("General").lines)["File size"] == "1000 Bytes" and "Overall bit rate" not in labels
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def test_a_report_with_no_general_section_gets_one():
|
| 138 |
+
report = report_of(truncated=False, engine="")
|
| 139 |
+
mi.fix_general(report, "mystery.bin", 2048, None)
|
| 140 |
+
assert report.sections[0].title == "General" and report.sections[0].lines == [("Complete name", "mystery.bin"), ("File size", "2.00 KiB")]
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
# ---------------------------------------------------------------------------
|
| 144 |
+
# the report
|
| 145 |
+
# ---------------------------------------------------------------------------
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def test_the_summary_line_counts_the_streams():
|
| 149 |
+
general = mi.Section("General", [("Format", "Matroska"), ("Duration", "24 min 1 s")])
|
| 150 |
+
streams = [mi.Section("Video"), mi.Section("Audio #1"), mi.Section("Audio #2"), mi.Section("Text #1"), mi.Section("Menu")]
|
| 151 |
+
assert report_of(general, *streams).summary() == "Matroska · 24 min 1 s · 1 video · 2 audio · 1 subtitle"
|
| 152 |
+
assert report_of(general).summary() == "Matroska · 24 min 1 s"
|
| 153 |
+
assert report_of().summary() == ""
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def test_only_real_streams_count_as_streams():
|
| 157 |
+
assert not report_of(mi.Section("General"), mi.Section("Menu"), mi.Section("Telegram")).has_streams
|
| 158 |
+
assert report_of(mi.Section("General"), mi.Section("Image")).has_streams
|
| 159 |
+
assert report_of(mi.Section("General"), mi.Section("Text #12")).has_streams
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def test_the_telegram_section_lists_what_telegram_knows_and_skips_the_rest():
|
| 163 |
+
full = mi.telegram_section("Show 01.mkv", 93550, mime="video/x-matroska", sent_as="Video", sent_on="2026-10-21 09:00:00 UTC",
|
| 164 |
+
dc="4", caption="[HDR] Dual")
|
| 165 |
+
assert full.title == "Telegram" and full.lines == [
|
| 166 |
+
("File name", "Show 01.mkv"), ("File size", "91.4 KiB (93 550 bytes)"), ("MIME type", "video/x-matroska"),
|
| 167 |
+
("Sent as", "Video"), ("Sent on", "2026-10-21 09:00:00 UTC"), ("Data center", "4"), ("Caption", "[HDR] Dual"),
|
| 168 |
+
]
|
| 169 |
+
assert mi.telegram_section("x", 0).lines == [("File name", "x")]
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
# ---------------------------------------------------------------------------
|
| 173 |
+
# laying a section out
|
| 174 |
+
# ---------------------------------------------------------------------------
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def test_values_line_up_after_the_labels():
|
| 178 |
+
section = mi.Section("Video", [("Format", "AVC"), ("Duration", "2 s"), ("ID", "1")])
|
| 179 |
+
assert mi.render_section(section) == "Format : AVC\nDuration : 2 s\nID : 1"
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def test_long_values_wrap_under_the_start_of_the_value():
|
| 183 |
+
section = mi.Section("Video", [("Encoding settings", " / ".join(f"option{i}={i}" for i in range(40))), ("ID", "1")])
|
| 184 |
+
lines = mi.render_section(section, width=64).splitlines()
|
| 185 |
+
assert lines[0].startswith("Encoding settings : option0=0 / ") and len(lines) > 3
|
| 186 |
+
indent = " " * len("Encoding settings : ")
|
| 187 |
+
assert all(line.startswith(indent) for line in lines[1:-1]) and lines[-1].startswith("ID")
|
| 188 |
+
assert all(len(line) <= 64 + 8 for line in lines) # a wrapped value gets at least MIN_VALUE columns
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def test_a_very_long_label_does_not_push_every_value_to_the_right():
|
| 192 |
+
section = mi.Section("Video", [("ID", "1"), ("Format settings, Reference frames", "1 frame"), ("Format", "AVC")])
|
| 193 |
+
lines = mi.render_section(section).splitlines()
|
| 194 |
+
assert lines == ["ID : 1", "Format settings, Reference frames : 1 frame", "Format : AVC"]
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def test_unbroken_text_is_still_wrapped_and_lines_end_cleanly():
|
| 198 |
+
section = mi.Section("Menu", [("", "x" * 150), ("Blank", ""), ("Name", "y" * 200)])
|
| 199 |
+
lines = mi.render_section(section, width=64).splitlines()
|
| 200 |
+
assert max(len(line) for line in lines) <= 64 + 8 and all(line == line.rstrip() for line in lines)
|
| 201 |
+
assert "".join(line.strip() for line in lines).count("y") == 200
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def test_values_can_be_shortened_to_fit():
|
| 205 |
+
section = mi.Section("General", [("Comment", "z" * 500)])
|
| 206 |
+
line = mi.render_section(section, max_value=50)
|
| 207 |
+
assert line.endswith("…") and len(line.split(" : ")[1]) == 50
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
# ---------------------------------------------------------------------------
|
| 211 |
+
# the Telegraph page
|
| 212 |
+
# ---------------------------------------------------------------------------
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def test_every_section_becomes_a_heading_and_a_block_and_the_notes_come_last():
|
| 216 |
+
report = report_of(mi.Section("General", [("Format", "Matroska")]), mi.Section("Telegram", [("File name", "a.mkv")]), truncated=True)
|
| 217 |
+
nodes = mi.to_nodes(report)
|
| 218 |
+
assert [n["tag"] for n in nodes] == ["h4", "pre", "h4", "pre", "p", "p"]
|
| 219 |
+
assert nodes[0]["children"] == ["General"] and nodes[1]["children"] == ["Format : Matroska"]
|
| 220 |
+
notes = [n["children"][0]["children"][0] for n in nodes if n["tag"] == "p"]
|
| 221 |
+
assert "Only the start of the file was read" in notes[0] and notes[1] == "Made by the bot with MediaInfo."
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def test_extra_notes_are_kept_and_an_unknown_engine_is_not_named():
|
| 225 |
+
report = report_of(mi.Section("General", [("Format", "x")]), engine="", notes=["No tracks could be read."])
|
| 226 |
+
notes = [n["children"][0]["children"][0] for n in mi.to_nodes(report) if n["tag"] == "p"]
|
| 227 |
+
assert notes == ["No tracks could be read.", "Made by the bot."]
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def test_a_page_that_is_too_big_has_its_long_values_shortened_first():
|
| 231 |
+
big = mi.Section("Video", [(f"Setting {i}", "v" * 600) for i in range(60)])
|
| 232 |
+
report = report_of(mi.Section("General", [("Format", "x")]), big, mi.Section("Telegram", [("File name", "a")]))
|
| 233 |
+
assert telegraph.content_size(mi.to_nodes(report, limit=10 ** 9)) > 30_000
|
| 234 |
+
nodes = mi.to_nodes(report, limit=30_000)
|
| 235 |
+
assert telegraph.content_size(nodes) <= 30_000
|
| 236 |
+
titles = [n["children"][0] for n in nodes if n["tag"] == "h4"]
|
| 237 |
+
assert titles == ["General", "Video", "Telegram"] # nothing dropped, only shortened
|
| 238 |
+
assert "…" in nodes[3]["children"][0]
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
def test_a_file_with_hundreds_of_tracks_drops_the_last_sections_but_keeps_the_ends():
|
| 242 |
+
sections = [mi.Section("General", [("Format", "Matroska")])]
|
| 243 |
+
sections += [mi.Section(f"Text #{i}", [("Format", "UTF-8"), ("Title", "t" * 80), ("Language", "English")]) for i in range(1, 301)]
|
| 244 |
+
sections.append(mi.Section("Telegram", [("File name", "big.mkv")]))
|
| 245 |
+
nodes = mi.to_nodes(report_of(*sections), limit=12_000)
|
| 246 |
+
assert telegraph.content_size(nodes) <= 12_000
|
| 247 |
+
titles = [n["children"][0] for n in nodes if n["tag"] == "h4"]
|
| 248 |
+
assert titles[0] == "General" and titles[-1] == "Telegram" and 3 < len(titles) < 302
|
| 249 |
+
notes = [n["children"][0]["children"][0] for n in nodes if n["tag"] == "p"]
|
| 250 |
+
assert any("page size limit" in note for note in notes)
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
def test_the_default_limit_is_what_telegraph_accepts():
|
| 254 |
+
report = report_of(mi.Section("General", [("Format", "x")]))
|
| 255 |
+
assert telegraph.content_size(mi.to_nodes(report)) <= telegraph.CONTENT_LIMIT < 64 * 1024
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
def test_the_plain_text_version_has_everything_the_page_has():
|
| 259 |
+
report = report_of(mi.Section("General", [("Format", "Matroska"), ("Duration", "2 s")]), mi.Section("Telegram", [("File name", "a.mkv")]), truncated=True)
|
| 260 |
+
text = mi.render_text(report)
|
| 261 |
+
assert text.startswith("General\nFormat : Matroska\nDuration : 2 s\n\nTelegram\nFile name : a.mkv\n")
|
| 262 |
+
assert "Only the start of the file was read" in text and text.rstrip().endswith("Made by the bot with MediaInfo.") and text.endswith("\n")
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
# ---------------------------------------------------------------------------
|
| 266 |
+
# the ffprobe fallback
|
| 267 |
+
# ---------------------------------------------------------------------------
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
def test_ffprobe_output_becomes_mediainfo_style_sections():
|
| 271 |
+
sections = mi.ffprobe_sections(FFPROBE, "Show 01.mkv", 700 * 1024 ** 2)
|
| 272 |
+
assert [s.title for s in sections] == ["General", "Video", "Audio #1", "Audio #2", "Text", "Menu"]
|
| 273 |
+
report = report_of(*sections)
|
| 274 |
+
general = lines_of(report, "General")
|
| 275 |
+
assert general["Complete name"] == "Show 01.mkv" and general["Format"] == "Matroska / WebM" and general["File size"] == "700 MiB"
|
| 276 |
+
assert general["Duration"] == "24 min 1 s" and general["Overall bit rate"] == "4 074 kb/s"
|
| 277 |
+
assert general["Title"] == "Show" and general["Writing application"] == "libebml" and general["Encoded date"] == "2026-01-01T00:00:00Z"
|
| 278 |
+
assert general["Weird Key"] == "x" and general["Attachments"] == "font.ttf"
|
| 279 |
+
video = lines_of(report, "Video")
|
| 280 |
+
assert video["Format"] == "H.265 / HEVC" and video["Format profile"] == "Main 10@L4.1" and video["Frame rate"] == "23.976 FPS"
|
| 281 |
+
assert video["Width"] == "1920 pixels" and video["Height"] == "1080 pixels" and video["Bit depth"] == "10 bits"
|
| 282 |
+
assert video["Bit rate"] == "3 200 kb/s" and video["Duration"] == "24 min 1 s" and video["Default"] == "Yes" and video["Forced"] == "No"
|
| 283 |
+
first, second = lines_of(report, "Audio #1"), lines_of(report, "Audio #2")
|
| 284 |
+
assert first["Language"] == "Japanese" and first["Title"] == "Japanese" and first["Channel(s)"] == "2 channels"
|
| 285 |
+
assert first["Sampling rate"] == "48 kHz" and first["Format profile"] == "LC" and first["Default"] == "Yes"
|
| 286 |
+
assert second["Language"] == "Hindi" and second["Channel(s)"] == "1 channel" and second["Sampling rate"] == "44.1 kHz"
|
| 287 |
+
text = lines_of(report, "Text")
|
| 288 |
+
assert text["Language"] == "English" and text["Forced"] == "Yes" and text["Default"] == "No"
|
| 289 |
+
assert report.section("Menu").lines == [("00:00:00.000", "Intro"), ("00:01:30.500", "Main")]
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
@pytest.mark.parametrize("codec, level, shown", [
|
| 293 |
+
("h264", 41, "L4.1"), ("h264", 40, "L4"), ("h264", 12, "L1.2"), ("hevc", 120, "L4"), ("hevc", 153, "L5.1"),
|
| 294 |
+
("h264", -99, ""), ("vp9", 41, ""), ("h264", None, ""),
|
| 295 |
+
])
|
| 296 |
+
def test_codec_levels_are_shown_the_way_each_codec_counts_them(codec, level, shown):
|
| 297 |
+
assert mi._level({"codec_name": codec, "level": level}) == shown
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
def test_ffprobe_with_nothing_to_show_gives_just_a_general_section():
|
| 301 |
+
sections = mi.ffprobe_sections({"format": {"format_name": "mp3"}}, "a.mp3", 0)
|
| 302 |
+
assert [s.title for s in sections] == ["General"] and sections[0].lines == [("Complete name", "a.mp3"), ("Format", "mp3")]
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
# ---------------------------------------------------------------------------
|
| 306 |
+
# real files
|
| 307 |
+
# ---------------------------------------------------------------------------
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
@needs_ffmpeg
|
| 311 |
+
async def test_a_real_mkv_is_read_by_mediainfo(media_dir):
|
| 312 |
+
pytest.importorskip("pymediainfo")
|
| 313 |
+
path = media_dir / "sample.mkv"
|
| 314 |
+
report = await mi.read_file(path, "Show 01.mkv", path.stat().st_size, extra=mi.telegram_section("Show 01.mkv", path.stat().st_size))
|
| 315 |
+
assert report.engine == "MediaInfo" and report.has_streams and not report.truncated
|
| 316 |
+
assert [s.title for s in report.sections] == ["General", "Video", "Audio #1", "Audio #2", "Audio #3", "Text #1", "Text #2", "Menu", "Telegram"]
|
| 317 |
+
general = lines_of(report, "General")
|
| 318 |
+
assert general["Complete name"] == "Show 01.mkv" and general["Format"] == "Matroska" # not the path it was read from
|
| 319 |
+
assert lines_of(report, "Audio #1")["Language"] == "Japanese" and lines_of(report, "Text #2")["Language"] == "English"
|
| 320 |
+
assert "AVC" in lines_of(report, "Video")["Format"] and lines_of(report, "Video")["Width"] == "320 pixels"
|
| 321 |
+
assert report.summary().startswith("Matroska · 2 s") and report.summary().endswith("1 video · 3 audio · 2 subtitle")
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
@needs_ffmpeg
|
| 325 |
+
async def test_without_mediainfo_ffprobe_gives_the_same_layout(media_dir, monkeypatch):
|
| 326 |
+
monkeypatch.setattr(mi, "_mediainfo", lambda path: None)
|
| 327 |
+
path = media_dir / "sample.mkv"
|
| 328 |
+
report = await mi.read_file(path, "Show 01.mkv", path.stat().st_size)
|
| 329 |
+
assert report.engine == "ffprobe" and report.has_streams
|
| 330 |
+
assert [s.title for s in report.sections] == ["General", "Video", "Audio #1", "Audio #2", "Audio #3", "Text #1", "Text #2", "Menu"]
|
| 331 |
+
assert lines_of(report, "General")["Complete name"] == "Show 01.mkv" and lines_of(report, "Audio #3")["Language"] == "Hindi"
|
| 332 |
+
assert report.section("Menu").lines == [("00:00:00.000", "Intro"), ("00:00:01.000", "Main")]
|
| 333 |
+
|
| 334 |
+
|
| 335 |
+
@needs_ffmpeg
|
| 336 |
+
async def test_a_failing_mediainfo_falls_back_to_ffprobe(media_dir, monkeypatch):
|
| 337 |
+
def boom(path):
|
| 338 |
+
raise RuntimeError("libmediainfo crashed")
|
| 339 |
+
|
| 340 |
+
monkeypatch.setattr(mi, "_mediainfo", boom)
|
| 341 |
+
report = await mi.read_file(media_dir / "sample.mp4", "Movie.mp4", (media_dir / "sample.mp4").stat().st_size)
|
| 342 |
+
assert report.engine == "ffprobe" and report.has_streams and lines_of(report, "Audio #2")["Language"] == "English"
|
| 343 |
+
|
| 344 |
+
|
| 345 |
+
@needs_ffmpeg
|
| 346 |
+
async def test_an_audio_file_shows_its_tags(media_dir):
|
| 347 |
+
path = media_dir / "sample.mp3"
|
| 348 |
+
report = await mi.read_file(path, "Song.mp3", path.stat().st_size)
|
| 349 |
+
assert report.has_streams and report.section("Audio") is not None
|
| 350 |
+
assert "Old" in lines_of(report, "General").values() # the title tag, however the engine labels it
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
@needs_ffmpeg
|
| 354 |
+
async def test_only_the_start_of_an_mkv_still_gets_the_real_size_and_bit_rate(media_dir, tmp_path):
|
| 355 |
+
pytest.importorskip("pymediainfo")
|
| 356 |
+
whole = media_dir / "sample.mkv"
|
| 357 |
+
cut = tmp_path / "probe.mkv"
|
| 358 |
+
cut.write_bytes(whole.read_bytes()[:3000])
|
| 359 |
+
real = whole.stat().st_size
|
| 360 |
+
expected = await mi.read_file(whole, "Show 01.mkv", real)
|
| 361 |
+
report = await mi.read_file(cut, "Show 01.mkv", real)
|
| 362 |
+
assert report.truncated and report.has_streams
|
| 363 |
+
general, reference = lines_of(report, "General"), lines_of(expected, "General")
|
| 364 |
+
assert general["File size"] == reference["File size"] and general["Overall bit rate"] == reference["Overall bit rate"]
|
| 365 |
+
assert not any("Conformance" in label or label.strip() == "CRC_Error_Pos" for label, _ in report.section("General").lines)
|
| 366 |
+
|
| 367 |
+
|
| 368 |
+
@needs_ffmpeg
|
| 369 |
+
async def test_an_mp4_cut_before_its_index_has_no_streams_but_still_a_report(media_dir, tmp_path):
|
| 370 |
+
cut = tmp_path / "probe.mp4"
|
| 371 |
+
cut.write_bytes((media_dir / "sample.mp4").read_bytes()[:3000])
|
| 372 |
+
report = await mi.read_file(cut, "Movie.mp4", 40 * 1024 * 1024)
|
| 373 |
+
assert report.truncated and not report.has_streams
|
| 374 |
+
assert lines_of(report, "General")["Complete name"] == "Movie.mp4" and lines_of(report, "General")["File size"] == "40.0 MiB"
|
| 375 |
+
|
| 376 |
+
|
| 377 |
+
async def test_a_file_that_is_not_media_still_gets_a_general_section(tmp_path):
|
| 378 |
+
path = tmp_path / "notes.txt"
|
| 379 |
+
path.write_text("hello\n" * 100)
|
| 380 |
+
report = await mi.read_file(path, "notes.txt", path.stat().st_size)
|
| 381 |
+
assert not report.has_streams and not report.truncated
|
| 382 |
+
general = lines_of(report, "General")
|
| 383 |
+
assert general["Complete name"] == "notes.txt" and general["File size"] == "600 Bytes"
|
tests/test_mediainfo_app.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""/mediainfo in the chat: reply to a file, get a Telegraph link."""
|
| 2 |
+
import io
|
| 3 |
+
import shutil
|
| 4 |
+
from datetime import datetime, timezone
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
import pytest
|
| 8 |
+
|
| 9 |
+
from bot import app as appmod
|
| 10 |
+
from bot.telegraph import Telegraph, TelegraphError
|
| 11 |
+
|
| 12 |
+
from .appkit import OWNER, STRANGER, say, send_doc
|
| 13 |
+
from .conftest import needs_ffmpeg
|
| 14 |
+
from .faketelegraph import serve_fake_telegraph
|
| 15 |
+
from .tgfakes import FakeMessage, document_message
|
| 16 |
+
|
| 17 |
+
URL = "https://telegra.ph/Show-05-10-21"
|
| 18 |
+
HINT = "Reply to a file or video with /mediainfo"
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class StubTelegraph:
|
| 22 |
+
"""What the bot talks to instead of Telegraph: remembers the pages it was asked for."""
|
| 23 |
+
|
| 24 |
+
def __init__(self, url=URL, error=None):
|
| 25 |
+
self.url, self.error, self.calls = url, error, []
|
| 26 |
+
|
| 27 |
+
async def create_page(self, title, nodes):
|
| 28 |
+
self.calls.append((title, nodes))
|
| 29 |
+
if self.error is not None:
|
| 30 |
+
raise self.error
|
| 31 |
+
return self.url
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def blocks(nodes):
|
| 35 |
+
"""{heading: text of its block} for a page's nodes."""
|
| 36 |
+
return {n["children"][0]: nodes[i + 1]["children"][0] for i, n in enumerate(nodes) if n["tag"] == "h4"}
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def notes(nodes):
|
| 40 |
+
return [n["children"][0]["children"][0] for n in nodes if n["tag"] == "p"]
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def serve(monkeypatch, media_dir, name="sample.mkv"):
|
| 44 |
+
"""Make transfer.download copy a real sample file; returns the list of calls."""
|
| 45 |
+
calls = []
|
| 46 |
+
|
| 47 |
+
async def fake_download(client, message, dest, **kw):
|
| 48 |
+
calls.append(kw)
|
| 49 |
+
shutil.copy(media_dir / name, dest)
|
| 50 |
+
|
| 51 |
+
monkeypatch.setattr(appmod.transfer, "download", fake_download)
|
| 52 |
+
return calls
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def serve_junk(monkeypatch, content=b"\x00" * 4096):
|
| 56 |
+
calls = []
|
| 57 |
+
|
| 58 |
+
async def fake_download(client, message, dest, **kw):
|
| 59 |
+
calls.append(kw)
|
| 60 |
+
Path(dest).write_bytes(content)
|
| 61 |
+
|
| 62 |
+
monkeypatch.setattr(appmod.transfer, "download", fake_download)
|
| 63 |
+
return calls
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def leftovers(ctx):
|
| 67 |
+
root = Path(ctx.cfg.temp_dir)
|
| 68 |
+
return list(root.glob("minfo*")) if root.exists() else []
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def use_stub(ctx, **kw):
|
| 72 |
+
ctx.app.telegraph = StubTelegraph(**kw)
|
| 73 |
+
return ctx.app.telegraph
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def real_size(media_dir, name="sample.mkv"):
|
| 77 |
+
return (media_dir / name).stat().st_size
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
# ---------------------------------------------------------------------------
|
| 81 |
+
# the flow
|
| 82 |
+
# ---------------------------------------------------------------------------
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
@needs_ffmpeg
|
| 86 |
+
async def test_reply_to_a_video_and_the_telegraph_link_comes_back(ctx, media_dir, monkeypatch):
|
| 87 |
+
calls = serve(monkeypatch, media_dir)
|
| 88 |
+
tg = use_stub(ctx)
|
| 89 |
+
video = document_message("Show - 05.mkv", real_size(media_dir), mid=70, caption="Dual Audio")
|
| 90 |
+
video.date = datetime(2026, 10, 19, 21, 30, 12, tzinfo=timezone.utc)
|
| 91 |
+
video.document.dc_id = 4
|
| 92 |
+
ev = await say(ctx, OWNER, "/mediainfo", reply=video)
|
| 93 |
+
assert ev.texts == ["🔎 Reading the file…"]
|
| 94 |
+
|
| 95 |
+
entity, text, kw = ctx.client.edited[-1]
|
| 96 |
+
assert entity.id == 1001 # the "Reading…" message became the answer
|
| 97 |
+
assert "🔎 <b>MediaInfo</b> · Show - 05.mkv" in text and URL in text and "Matroska" in text and "1 video · 3 audio · 2 subtitle" in text
|
| 98 |
+
assert kw["parse_mode"] == "html" and kw["link_preview"] is False
|
| 99 |
+
(button,), = kw["buttons"]
|
| 100 |
+
assert button.type.url == URL and button.text.startswith("📄")
|
| 101 |
+
|
| 102 |
+
(title, nodes), = tg.calls
|
| 103 |
+
assert title == "MediaInfo – Show - 05.mkv"
|
| 104 |
+
page = blocks(nodes)
|
| 105 |
+
assert list(page) == ["General", "Video", "Audio #1", "Audio #2", "Audio #3", "Text #1", "Text #2", "Menu", "Telegram"]
|
| 106 |
+
assert "Show - 05.mkv" in page["General"] and "probe.mkv" not in page["General"] # the file's own name, not the temp copy
|
| 107 |
+
telegram = page["Telegram"]
|
| 108 |
+
for line in ("File name : Show - 05.mkv", "MIME type : video/x-matroska", "Sent as : Video",
|
| 109 |
+
"Sent on : 2026-10-19 21:30:12 UTC", "Data center : 4", "Caption : Dual Audio"):
|
| 110 |
+
assert line in telegram, line
|
| 111 |
+
assert len(calls) == 1 and calls[0]["max_bytes"] == appmod.TRACKS_PROBE_BYTES # the start of the file was enough
|
| 112 |
+
assert leftovers(ctx) == []
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
async def test_without_a_file_there_is_a_hint_and_nothing_is_downloaded(ctx, monkeypatch):
|
| 116 |
+
async def no_download(*a, **kw):
|
| 117 |
+
raise AssertionError("there is nothing to read")
|
| 118 |
+
|
| 119 |
+
monkeypatch.setattr(appmod.transfer, "download", no_download)
|
| 120 |
+
tg = use_stub(ctx)
|
| 121 |
+
for reply in (None, FakeMessage("just some text")):
|
| 122 |
+
ev = await say(ctx, OWNER, "/mediainfo", reply=reply)
|
| 123 |
+
assert HINT in ev.texts[0] and "Telegraph" in ev.texts[0]
|
| 124 |
+
assert tg.calls == [] and ctx.client.edited == []
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
@needs_ffmpeg
|
| 128 |
+
async def test_a_file_sent_with_the_command_as_its_caption_is_read_not_renamed(ctx, media_dir, monkeypatch):
|
| 129 |
+
serve(monkeypatch, media_dir)
|
| 130 |
+
tg = use_stub(ctx)
|
| 131 |
+
for mid, caption in ((71, "/mediainfo"), (72, "/mediainfo@my_rename_bot")):
|
| 132 |
+
ev = await send_doc(ctx, OWNER, "Show.mkv", size=real_size(media_dir), caption=caption, mid=mid)
|
| 133 |
+
assert ev.texts == ["🔎 Reading the file…"] # no "Queued" message from the auto-renamer
|
| 134 |
+
assert ctx.app.queue.waiting == 0 and len(tg.calls) == 2
|
| 135 |
+
assert "Caption" not in blocks(tg.calls[0][1])["Telegram"] # the caption was only the command
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
async def test_other_captions_still_rename_the_file(ctx):
|
| 139 |
+
ev = await send_doc(ctx, OWNER, "Show.mkv", size=5 * 1024 * 1024, caption="/mediainfoX", mid=73)
|
| 140 |
+
assert ev.texts[0].startswith("📥 <b>Queued</b>") and ctx.app.queue.waiting == 1
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
@needs_ffmpeg
|
| 144 |
+
async def test_a_video_whose_index_is_at_the_end_is_read_from_the_whole_file(ctx, media_dir, monkeypatch):
|
| 145 |
+
calls = []
|
| 146 |
+
|
| 147 |
+
async def fake_download(client, message, dest, **kw):
|
| 148 |
+
calls.append(kw)
|
| 149 |
+
if "max_bytes" in kw:
|
| 150 |
+
Path(dest).write_bytes((media_dir / "sample.mp4").read_bytes()[:3000]) # an mp4 cut before its index
|
| 151 |
+
else:
|
| 152 |
+
shutil.copy(media_dir / "sample.mp4", dest)
|
| 153 |
+
|
| 154 |
+
monkeypatch.setattr(appmod.transfer, "download", fake_download)
|
| 155 |
+
tg = use_stub(ctx)
|
| 156 |
+
await say(ctx, OWNER, "/mediainfo", reply=document_message("Movie.mp4", 40 * 1024 * 1024, mid=74, mime="video/mp4"))
|
| 157 |
+
assert len(calls) == 2 and "max_bytes" in calls[0] and "max_bytes" not in calls[1]
|
| 158 |
+
assert any("needs the whole file" in text for _e, text, _k in ctx.client.edited)
|
| 159 |
+
(_title, nodes), = tg.calls
|
| 160 |
+
assert list(blocks(nodes))[:2] == ["General", "Video"] and "No tracks" not in " ".join(notes(nodes))
|
| 161 |
+
assert URL in ctx.client.edited[-1][1] and leftovers(ctx) == []
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
@pytest.mark.parametrize("size, why", [(2 * 1024 * 1024, "may be damaged"), (900 * 1024 * 1024, "too big for me to download completely")])
|
| 165 |
+
async def test_a_video_with_no_readable_tracks_still_gets_a_page_that_says_so(ctx, monkeypatch, size, why):
|
| 166 |
+
calls = serve_junk(monkeypatch)
|
| 167 |
+
tg = use_stub(ctx)
|
| 168 |
+
await say(ctx, OWNER, "/mediainfo", reply=document_message("Broken.mkv", size, mid=75))
|
| 169 |
+
assert len(calls) == 1 # too small to need more, or too big to fetch whole
|
| 170 |
+
(_title, nodes), = tg.calls
|
| 171 |
+
assert any(why in note for note in notes(nodes))
|
| 172 |
+
assert list(blocks(nodes)) == ["General", "Telegram"] and URL in ctx.client.edited[-1][1]
|
| 173 |
+
assert leftovers(ctx) == []
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
async def test_a_file_that_is_not_media_is_read_from_its_start_only(ctx, monkeypatch):
|
| 177 |
+
calls = serve_junk(monkeypatch, b"PK\x03\x04" + b"\x00" * 4000)
|
| 178 |
+
tg = use_stub(ctx)
|
| 179 |
+
await say(ctx, OWNER, "/mediainfo", reply=document_message("archive.zip", 50 * 1024 * 1024, mid=76, video=False, mime="application/zip"))
|
| 180 |
+
assert len(calls) == 1 and "max_bytes" in calls[0]
|
| 181 |
+
(_title, nodes), = tg.calls
|
| 182 |
+
page = blocks(nodes)
|
| 183 |
+
assert list(page) == ["General", "Telegram"] and "archive.zip" in page["General"] and "50.0 MiB" in page["General"]
|
| 184 |
+
assert "Sent as : Document" in page["Telegram"] and "No tracks" not in " ".join(notes(nodes))
|
| 185 |
+
assert any("Only the start of the file was read" in note for note in notes(nodes))
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
async def test_a_download_that_fails_is_reported_and_cleaned_up(ctx, monkeypatch):
|
| 189 |
+
async def fail(client, message, dest, **kw):
|
| 190 |
+
Path(dest).write_bytes(b"partial")
|
| 191 |
+
raise ConnectionError("connection reset")
|
| 192 |
+
|
| 193 |
+
monkeypatch.setattr(appmod.transfer, "download", fail)
|
| 194 |
+
tg = use_stub(ctx)
|
| 195 |
+
await say(ctx, OWNER, "/mediainfo", reply=document_message("Show.mkv", 5 * 1024 * 1024, mid=77))
|
| 196 |
+
assert ctx.client.edited[-1][1] == "❌ I couldn't read that file." and tg.calls == [] and leftovers(ctx) == []
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
# ---------------------------------------------------------------------------
|
| 200 |
+
# when Telegraph does not work
|
| 201 |
+
# ---------------------------------------------------------------------------
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
@needs_ffmpeg
|
| 205 |
+
async def test_when_telegraph_fails_the_report_comes_as_a_text_file(ctx, media_dir, monkeypatch):
|
| 206 |
+
serve(monkeypatch, media_dir)
|
| 207 |
+
use_stub(ctx, error=TelegraphError("Telegraph asks to wait 5 seconds", "FLOOD_WAIT_5"))
|
| 208 |
+
await say(ctx, OWNER, "/mediainfo", reply=document_message("Show: 05?.mkv", real_size(media_dir), mid=78))
|
| 209 |
+
(entity, file, kw), = [f for f in ctx.client.files if isinstance(f[1], io.BytesIO)]
|
| 210 |
+
assert entity == OWNER and file.name == "Show_ 05_ - MediaInfo.txt" and kw["force_document"] is True
|
| 211 |
+
assert "Telegraph asks to wait 5 seconds, so here it is as a text file." in kw["caption"] and kw["parse_mode"] == "html"
|
| 212 |
+
text = file.getvalue().decode()
|
| 213 |
+
assert text.startswith("General\n") and "\nVideo\n" in text and "\nTelegram\nFile name" in text and "Show: 05?.mkv" in text
|
| 214 |
+
assert (OWNER, [1001]) in ctx.client.deleted # the "Reading…" message goes
|
| 215 |
+
assert leftovers(ctx) == []
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
async def test_another_telegraph_failure_does_not_show_its_details(ctx, monkeypatch):
|
| 219 |
+
serve_junk(monkeypatch)
|
| 220 |
+
use_stub(ctx, error=RuntimeError("secret token abc123 rejected"))
|
| 221 |
+
await say(ctx, OWNER, "/mediainfo", reply=document_message("Show.mkv", 2 * 1024 * 1024, mid=79))
|
| 222 |
+
(_e, _file, kw), = [f for f in ctx.client.files if isinstance(f[1], io.BytesIO)]
|
| 223 |
+
assert "Telegraph is not available, so here it is as a text file." in kw["caption"] and "abc123" not in kw["caption"]
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
async def test_an_unexpected_error_does_not_leave_the_reading_message_hanging(ctx, monkeypatch):
|
| 227 |
+
def broken(message, caption=True):
|
| 228 |
+
raise RuntimeError("boom")
|
| 229 |
+
|
| 230 |
+
monkeypatch.setattr(appmod, "telegram_facts", broken)
|
| 231 |
+
ev = await say(ctx, OWNER, "/mediainfo", reply=document_message("Show.mkv", 2 * 1024 * 1024, mid=80))
|
| 232 |
+
assert ev.texts == ["🔎 Reading the file…"]
|
| 233 |
+
assert ctx.client.edited[-1][1] == "⚠️ Something went wrong while reading that file."
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
# ---------------------------------------------------------------------------
|
| 237 |
+
# access and plumbing
|
| 238 |
+
# ---------------------------------------------------------------------------
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
async def test_only_allowed_users_can_use_it(ctx, monkeypatch):
|
| 242 |
+
tg = use_stub(ctx)
|
| 243 |
+
ev = await say(ctx, STRANGER, "/mediainfo", reply=document_message("Show.mkv", 1024, mid=81))
|
| 244 |
+
assert ev.out == [] and tg.calls == []
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
async def test_the_command_is_in_the_menu():
|
| 248 |
+
commands = dict(appmod.COMMANDS)
|
| 249 |
+
assert "mediainfo" in commands and 3 <= len(commands["mediainfo"]) <= 256
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
async def test_the_telegraph_token_lives_in_the_database(ctx):
|
| 253 |
+
assert await ctx.app._load_telegraph_token() is None
|
| 254 |
+
await ctx.app._save_telegraph_token("abc")
|
| 255 |
+
assert await ctx.app._load_telegraph_token() == "abc" and await ctx.db.kv_get("telegraph_token") == "abc"
|
| 256 |
+
await ctx.app._save_telegraph_token("") # what the client does when Telegraph rejects the token
|
| 257 |
+
assert await ctx.db.kv_get("telegraph_token") is None
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
@needs_ffmpeg
|
| 261 |
+
async def test_the_whole_chain_with_the_real_client_and_a_fake_telegraph(ctx, media_dir, monkeypatch):
|
| 262 |
+
serve(monkeypatch, media_dir, "sample.mp4")
|
| 263 |
+
fake = await serve_fake_telegraph()
|
| 264 |
+
try:
|
| 265 |
+
ctx.app.telegraph = Telegraph(ctx.app._load_telegraph_token, ctx.app._save_telegraph_token, api=fake.url)
|
| 266 |
+
ctx.app.telegraph.set_author("Rename Bot", "https://t.me/rename_bot")
|
| 267 |
+
await say(ctx, OWNER, "/mediainfo", reply=document_message("Movie.mp4", real_size(media_dir, "sample.mp4"), mid=82, mime="video/mp4"))
|
| 268 |
+
await say(ctx, OWNER, "/mediainfo", reply=document_message("Movie 2.mp4", real_size(media_dir, "sample.mp4"), mid=83, mime="video/mp4"))
|
| 269 |
+
assert fake.accounts == 1 and len(fake.pages) == 2 # one account, kept for the second page
|
| 270 |
+
assert await ctx.db.kv_get("telegraph_token") == "token1"
|
| 271 |
+
page = fake.pages[0]
|
| 272 |
+
assert page["title"] == "MediaInfo – Movie.mp4" and page["author_name"] == "Rename Bot" and page["author_url"] == "https://t.me/rename_bot"
|
| 273 |
+
assert "Audio #2" in page["content"] and "Movie.mp4" in page["content"]
|
| 274 |
+
assert "https://telegra.ph/Page-1" in ctx.client.edited[-2][1] and "https://telegra.ph/Page-2" in ctx.client.edited[-1][1]
|
| 275 |
+
finally:
|
| 276 |
+
await fake.server.close()
|
tests/test_parser.py
CHANGED
|
@@ -3,7 +3,6 @@ import pytest
|
|
| 3 |
from bot.parser import (
|
| 4 |
DEFAULT_TEMPLATE,
|
| 5 |
apply_remove_words,
|
| 6 |
-
build_manual_name,
|
| 7 |
compute_new_name,
|
| 8 |
finalize_name,
|
| 9 |
missing_variables,
|
|
@@ -186,18 +185,6 @@ def test_finalize_truncates_by_bytes_and_keeps_ext():
|
|
| 186 |
assert len(out.encode("utf-8")) <= 200
|
| 187 |
|
| 188 |
|
| 189 |
-
def test_manual_name_keeps_original_ext():
|
| 190 |
-
assert build_manual_name("My Movie", ".mkv") == "My Movie.mkv"
|
| 191 |
-
assert build_manual_name("My Movie.mkv", ".mkv") == "My Movie.mkv"
|
| 192 |
-
assert build_manual_name("My Movie.mp4", ".mkv") == "My Movie.mkv"
|
| 193 |
-
assert build_manual_name("Season 1.5", ".mkv") == "Season 1.5.mkv"
|
| 194 |
-
assert build_manual_name("Fixed.mkv", "") == "Fixed.mkv"
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
def test_manual_name_sanitised():
|
| 198 |
-
assert build_manual_name('a/b:c*d?.mkv', ".mkv") == "a b c d.mkv"
|
| 199 |
-
|
| 200 |
-
|
| 201 |
def test_sanitize_and_remove_words():
|
| 202 |
assert sanitize(" ..hidden:name ") == "hidden name"
|
| 203 |
assert apply_remove_words("Show [Group] 720p", ["[group]"]) == "Show 720p"
|
|
|
|
| 3 |
from bot.parser import (
|
| 4 |
DEFAULT_TEMPLATE,
|
| 5 |
apply_remove_words,
|
|
|
|
| 6 |
compute_new_name,
|
| 7 |
finalize_name,
|
| 8 |
missing_variables,
|
|
|
|
| 185 |
assert len(out.encode("utf-8")) <= 200
|
| 186 |
|
| 187 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
def test_sanitize_and_remove_words():
|
| 189 |
assert sanitize(" ..hidden:name ") == "hidden name"
|
| 190 |
assert apply_remove_words("Show [Group] 720p", ["[group]"]) == "Show 720p"
|
tests/test_pipeline.py
CHANGED
|
@@ -286,3 +286,84 @@ def test_helpers():
|
|
| 286 |
assert original_name_of(nameless) == "video_55.mp4"
|
| 287 |
assert source_ext(nameless, "video_55.mp4") == ".mp4"
|
| 288 |
assert source_ext(FakeMessage("noext", 1, ext=".mkv"), "noext") == ".mkv"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 286 |
assert original_name_of(nameless) == "video_55.mp4"
|
| 287 |
assert source_ext(nameless, "video_55.mp4") == ".mp4"
|
| 288 |
assert source_ext(FakeMessage("noext", 1, ext=".mkv"), "noext") == ".mkv"
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
# ---------------------------------------------------------------------------
|
| 292 |
+
# saved /rename formats
|
| 293 |
+
# ---------------------------------------------------------------------------
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
async def make_fmt(env, name="One Piece", **changes):
|
| 297 |
+
fmt = await env.db.create_format(42, name, template="One Piece - E{episode}")
|
| 298 |
+
if changes:
|
| 299 |
+
await env.db.update_format(42, fmt["id"], **changes)
|
| 300 |
+
return await env.db.get_format(42, fmt["id"])
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
@needs_ffmpeg
|
| 304 |
+
async def test_a_format_brings_its_own_metadata_tracks_and_thumbnail(env):
|
| 305 |
+
# the global metadata is off and has a different title: the format must not depend on either
|
| 306 |
+
await env.db.update_settings(42, metadata_on=False, meta={"title": "GLOBAL"}, keep_audio=["hindi"])
|
| 307 |
+
await env.db.set_thumb(42, b"\xff\xd8global")
|
| 308 |
+
fmt = await make_fmt(env, meta={"title": "One Piece", "audio": "{lang}"}, keep_audio=["english", "japanese"], keep_subtitle=["2"])
|
| 309 |
+
await env.db.set_format_thumb(42, fmt["id"], b"\xff\xd8format")
|
| 310 |
+
fmt = await env.db.get_format(42, fmt["id"])
|
| 311 |
+
|
| 312 |
+
client, renamer, job = make(env)
|
| 313 |
+
job.fmt = fmt
|
| 314 |
+
await renamer.process(job)
|
| 315 |
+
|
| 316 |
+
out = await probe(client.uploads[0])
|
| 317 |
+
assert [s.lang for s in out.audio] == ["eng", "jpn"] and [s.lang for s in out.subtitles] == ["eng"]
|
| 318 |
+
assert out.tags.get("title") == "One Piece" and [s.title for s in out.audio] == ["English", "Japanese"]
|
| 319 |
+
(_t, _f, kw), = client.sent
|
| 320 |
+
assert kw["thumb"] == b"\xff\xd8format"
|
| 321 |
+
assert job.state == "done"
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
@needs_ffmpeg
|
| 325 |
+
async def test_a_format_without_a_thumbnail_uses_the_users_default_one(env):
|
| 326 |
+
await env.db.set_thumb(42, b"\xff\xd8global")
|
| 327 |
+
fmt = await make_fmt(env)
|
| 328 |
+
client, renamer, job = make(env)
|
| 329 |
+
job.fmt = fmt
|
| 330 |
+
await renamer.process(job)
|
| 331 |
+
(_t, _f, kw), = client.sent
|
| 332 |
+
assert kw["thumb"] == b"\xff\xd8global"
|
| 333 |
+
|
| 334 |
+
|
| 335 |
+
@needs_ffmpeg
|
| 336 |
+
async def test_a_format_that_sets_no_metadata_leaves_the_file_untouched_even_if_global_metadata_is_on(env):
|
| 337 |
+
await env.db.update_settings(42, metadata_on=True, meta={"title": "GLOBAL"}, keep_audio=["english"])
|
| 338 |
+
fmt = await make_fmt(env)
|
| 339 |
+
client, renamer, job = make(env)
|
| 340 |
+
job.fmt = fmt
|
| 341 |
+
await renamer.process(job)
|
| 342 |
+
assert client.uploads[0].read_bytes() == (env.media_dir / "sample.mkv").read_bytes() # no ffmpeg pass
|
| 343 |
+
assert job.state == "done"
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
@needs_ffmpeg
|
| 347 |
+
async def test_everything_else_still_comes_from_the_users_settings(env):
|
| 348 |
+
await env.db.update_settings(42, file_type="document", caption="<b>{filename}</b> by me", target_channel=-100777)
|
| 349 |
+
fmt = await make_fmt(env, meta={"title": "T"})
|
| 350 |
+
client, renamer, job = make(env)
|
| 351 |
+
job.fmt = fmt
|
| 352 |
+
job.new_name = "One Piece - E5.mkv"
|
| 353 |
+
await renamer.process(job)
|
| 354 |
+
(target, _f, kw), = client.sent
|
| 355 |
+
assert target == -100777 and kw["force_document"] is True and kw["caption"] == "<b>One Piece - E5</b> by me"
|
| 356 |
+
|
| 357 |
+
|
| 358 |
+
@needs_ffmpeg
|
| 359 |
+
async def test_a_format_deleted_while_queued_falls_back_to_the_users_thumbnail_and_still_finishes(env):
|
| 360 |
+
await env.db.set_thumb(42, b"\xff\xd8global")
|
| 361 |
+
fmt = await make_fmt(env, meta={"title": "One Piece"})
|
| 362 |
+
await env.db.delete_format(42, fmt["id"])
|
| 363 |
+
client, renamer, job = make(env)
|
| 364 |
+
job.fmt = fmt # the snapshot taken when the file was queued
|
| 365 |
+
await renamer.process(job)
|
| 366 |
+
(_t, _f, kw), = client.sent
|
| 367 |
+
assert kw["thumb"] == b"\xff\xd8global" and job.state == "done"
|
| 368 |
+
out = await probe(client.uploads[0])
|
| 369 |
+
assert out.tags.get("title") == "One Piece" # the snapshot's metadata is still applied
|
tests/test_resize.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""/resize: pick a size, send an image, get it back resized."""
|
| 2 |
+
import io
|
| 3 |
+
import time
|
| 4 |
+
|
| 5 |
+
from PIL import Image
|
| 6 |
+
|
| 7 |
+
from bot import app as appmod
|
| 8 |
+
from bot import panels
|
| 9 |
+
|
| 10 |
+
from .appkit import OWNER, STRANGER, click, data_of, jpeg, say, send_doc
|
| 11 |
+
from .tgfakes import FakeEvent, FakeMessage, dispatch
|
| 12 |
+
|
| 13 |
+
FULL_HD = (1920, 1080)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def results(ctx):
|
| 17 |
+
return [(entity, file, kw) for entity, file, kw in ctx.client.files if isinstance(file, io.BytesIO)]
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def size_of(file):
|
| 21 |
+
return Image.open(io.BytesIO(file.getvalue())).size
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
async def send_photo(ctx, mid=50, data=None, sender=OWNER):
|
| 25 |
+
ctx.client.media[mid] = data if data is not None else jpeg(FULL_HD)
|
| 26 |
+
ev = FakeEvent(sender, message=FakeMessage("", photo=True, mid=mid), photo_bytes=jpeg())
|
| 27 |
+
await dispatch(ctx.client, ev)
|
| 28 |
+
return ev
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
async def send_image_file(ctx, name="poster.png", mid=51, data=None, size=200 * 1024):
|
| 32 |
+
ctx.client.media[mid] = data if data is not None else jpeg(FULL_HD)
|
| 33 |
+
return await send_doc(ctx, OWNER, name, size=size, video=False, mime="image/png", mid=mid)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
# ---------------------------------------------------------------------------
|
| 37 |
+
# choosing the size
|
| 38 |
+
# ---------------------------------------------------------------------------
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
async def test_resize_offers_the_sizes(ctx):
|
| 42 |
+
ev = await say(ctx, OWNER, "/resize")
|
| 43 |
+
text, buttons = ev.texts[0], data_of(ev.out[0][1])
|
| 44 |
+
assert "RESIZE IMAGE" in text and "200 KB" in text and "/resize 320x180" in text
|
| 45 |
+
assert buttons == ["rz:s:320", "rz:s:640", "rz:s:1280", "rz:custom", "x:close"]
|
| 46 |
+
assert OWNER not in ctx.app.modes # nothing is waiting for an image until a size is chosen
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
async def test_a_preset_button_makes_the_bot_wait_for_the_image(ctx):
|
| 50 |
+
cb = await click(ctx, OWNER, "rz:s:320")
|
| 51 |
+
assert "Size: <b>320 px</b>" in cb.edits[-1][0] and "one image at a time" in cb.edits[-1][0]
|
| 52 |
+
assert data_of(cb.edits[-1][1]) == ["rz:menu", "rz:stop"]
|
| 53 |
+
mode = ctx.app.modes[OWNER]
|
| 54 |
+
assert mode.kind == "resize" and mode.data == {"box": (320, 320)} and mode.expires > time.time() + 500
|
| 55 |
+
await click(ctx, OWNER, "rz:s:1280")
|
| 56 |
+
assert ctx.app.modes[OWNER].data["box"] == (1280, 1280)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
async def test_unknown_presets_are_ignored(ctx):
|
| 60 |
+
cb = await click(ctx, OWNER, "rz:s:999")
|
| 61 |
+
assert cb.edits == [] and OWNER not in ctx.app.modes
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
async def test_the_size_can_be_given_with_the_command(ctx):
|
| 65 |
+
ev = await say(ctx, OWNER, "/resize 320x180")
|
| 66 |
+
assert "<b>320×180</b>" in ev.texts[0] and ctx.app.modes[OWNER].data["box"] == (320, 180)
|
| 67 |
+
ev = await say(ctx, OWNER, "/resize 640")
|
| 68 |
+
assert "<b>640 px</b>" in ev.texts[0] and ctx.app.modes[OWNER].data["box"] == (640, 640)
|
| 69 |
+
for bad in ("tiny", "10x10", "99999", "320 px"):
|
| 70 |
+
ev = await say(ctx, OWNER, f"/resize {bad}")
|
| 71 |
+
assert "❌" in ev.texts[0] and "/resize 320x180" in ev.texts[0], bad
|
| 72 |
+
assert ctx.app.modes[OWNER].data["box"] == (640, 640) # a bad size does not disturb the one that was set
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
async def test_a_custom_size_is_typed_in(ctx):
|
| 76 |
+
cb = await click(ctx, OWNER, "rz:custom", msg_id=500)
|
| 77 |
+
p = ctx.app.pending[OWNER]
|
| 78 |
+
assert p.kind == "resize:size" and p.msg_id == 500 and cb.edits[-1][0] == panels.RESIZE_CUSTOM_PROMPT
|
| 79 |
+
assert data_of(cb.edits[-1][1]) == ["rz:menu"]
|
| 80 |
+
|
| 81 |
+
ev = await say(ctx, OWNER, "nonsense", mid=60)
|
| 82 |
+
assert "couldn't read that size" in ev.texts[0] and "Try again" in ev.texts[0] and OWNER in ctx.app.pending
|
| 83 |
+
|
| 84 |
+
ev = await say(ctx, OWNER, "300 x 200", mid=61)
|
| 85 |
+
assert ev.deleted and OWNER not in ctx.app.pending
|
| 86 |
+
assert ctx.app.modes[OWNER].data["box"] == (300, 200)
|
| 87 |
+
(chat, text, kw), = ctx.client.edited
|
| 88 |
+
assert kw["message"] == 500 and "<b>300×200</b>" in text and data_of(kw["buttons"]) == ["rz:menu", "rz:stop"]
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
async def test_cancelling_the_custom_prompt_goes_back_to_the_sizes(ctx):
|
| 92 |
+
await click(ctx, OWNER, "rz:custom")
|
| 93 |
+
cb = await click(ctx, OWNER, "rz:menu")
|
| 94 |
+
assert OWNER not in ctx.app.pending and "RESIZE IMAGE" in cb.edits[-1][0]
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
async def test_change_size_and_stop_buttons(ctx):
|
| 98 |
+
await click(ctx, OWNER, "rz:s:320")
|
| 99 |
+
cb = await click(ctx, OWNER, "rz:menu")
|
| 100 |
+
assert "RESIZE IMAGE" in cb.edits[-1][0] and OWNER not in ctx.app.modes
|
| 101 |
+
await click(ctx, OWNER, "rz:s:320")
|
| 102 |
+
cb = await click(ctx, OWNER, "rz:stop")
|
| 103 |
+
assert "Stopped" in cb.edits[-1][0] and OWNER not in ctx.app.modes
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
# ---------------------------------------------------------------------------
|
| 107 |
+
# resizing
|
| 108 |
+
# ---------------------------------------------------------------------------
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
async def test_a_photo_comes_back_resized(ctx):
|
| 112 |
+
await say(ctx, OWNER, "/resize 320")
|
| 113 |
+
await send_photo(ctx)
|
| 114 |
+
(entity, file, kw), = results(ctx)
|
| 115 |
+
assert entity == OWNER and file.name == "resized.jpg" and size_of(file) == (320, 180)
|
| 116 |
+
assert len(file.getvalue()) <= 200 * 1024
|
| 117 |
+
assert "<b>320×180</b>" in kw["caption"] and "was 1920×1080" in kw["caption"] and kw["parse_mode"] == "html"
|
| 118 |
+
assert kw["force_document"] is False
|
| 119 |
+
assert OWNER not in ctx.app.modes
|
| 120 |
+
assert ctx.client.downloads == [(50, bytes, None)]
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
async def test_an_image_sent_as_a_file_works_too(ctx):
|
| 124 |
+
await say(ctx, OWNER, "/resize 640x360")
|
| 125 |
+
ev = await send_image_file(ctx)
|
| 126 |
+
assert ev.out == [] and ctx.app.queue.waiting == 0 # not renamed
|
| 127 |
+
(_e, file, _kw), = results(ctx)
|
| 128 |
+
assert size_of(file) == (640, 360)
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
async def test_what_is_not_an_image_does_not_use_up_the_request(ctx):
|
| 132 |
+
await say(ctx, OWNER, "/resize 320")
|
| 133 |
+
ev = await send_doc(ctx, OWNER, "Episode.mkv", mid=52)
|
| 134 |
+
assert "isn't an image" in ev.texts[0] and ctx.app.queue.waiting == 0 and results(ctx) == []
|
| 135 |
+
assert ctx.app.modes[OWNER].kind == "resize"
|
| 136 |
+
await send_photo(ctx)
|
| 137 |
+
assert len(results(ctx)) == 1
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
async def test_it_handles_one_image_per_command_and_extra_photos_do_not_replace_the_thumbnail(ctx):
|
| 141 |
+
await say(ctx, OWNER, "/resize 320")
|
| 142 |
+
await send_photo(ctx, mid=50)
|
| 143 |
+
extra = await send_photo(ctx, mid=51)
|
| 144 |
+
assert "/resize handles one file at a time" in extra.texts[0]
|
| 145 |
+
assert (await send_photo(ctx, mid=52)).out == []
|
| 146 |
+
assert len(results(ctx)) == 1
|
| 147 |
+
assert not await ctx.db.has_thumb(OWNER) # none of the extra photos became the saved thumbnail
|
| 148 |
+
doc = await send_image_file(ctx, mid=53)
|
| 149 |
+
assert doc.out == [] and ctx.app.queue.waiting == 0 # an image file right after is skipped as well, not auto-renamed
|
| 150 |
+
|
| 151 |
+
ctx.app._recent[OWNER][1] -= appmod.ONE_SHOT_GRACE + 1
|
| 152 |
+
await send_photo(ctx, mid=54)
|
| 153 |
+
assert await ctx.db.has_thumb(OWNER) # after the grace a photo is a thumbnail again
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
async def test_large_results_are_sent_as_files_so_telegram_does_not_shrink_them(ctx):
|
| 157 |
+
await say(ctx, OWNER, "/resize 2000")
|
| 158 |
+
await send_photo(ctx, data=jpeg((3000, 2000)))
|
| 159 |
+
(_e, file, kw), = results(ctx)
|
| 160 |
+
assert size_of(file) == (2000, 1333) and kw["force_document"] is True
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
async def test_resize_as_a_reply_works_at_once(ctx):
|
| 164 |
+
ctx.client.media[40] = jpeg(FULL_HD)
|
| 165 |
+
reply = FakeMessage("", photo=True, mid=40)
|
| 166 |
+
await say(ctx, OWNER, "/resize", reply=reply, mid=41)
|
| 167 |
+
(_e, file, _kw), = results(ctx)
|
| 168 |
+
assert size_of(file) == (320, 180) and OWNER not in ctx.app.modes # thumbnail size unless told otherwise
|
| 169 |
+
await say(ctx, OWNER, "/resize 640x640", reply=reply, mid=42)
|
| 170 |
+
assert size_of(results(ctx)[-1][1]) == (640, 360)
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
async def test_a_picture_that_cannot_be_read_gets_an_honest_answer(ctx):
|
| 174 |
+
await say(ctx, OWNER, "/resize 320")
|
| 175 |
+
ev = await send_photo(ctx, data=b"not a picture at all")
|
| 176 |
+
assert "couldn't read that image" in ev.texts[0] and results(ctx) == []
|
| 177 |
+
ctx.client.media[50] = None
|
| 178 |
+
await say(ctx, OWNER, "/resize 320")
|
| 179 |
+
ev = await send_photo(ctx, data=b"")
|
| 180 |
+
assert "couldn't download" in ev.texts[0]
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
async def test_very_large_files_are_refused_before_downloading(ctx):
|
| 184 |
+
await say(ctx, OWNER, "/resize 320")
|
| 185 |
+
ev = await send_image_file(ctx, size=appmod.RESIZE_MAX_BYTES + 1)
|
| 186 |
+
assert "I can resize images up to 20.00 MB" in ev.texts[0] and ctx.client.downloads == []
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
async def test_it_expires_and_cancel_ends_it(ctx):
|
| 190 |
+
await say(ctx, OWNER, "/resize 320")
|
| 191 |
+
ctx.app.modes[OWNER].expires = time.time() - 1
|
| 192 |
+
ev = await send_doc(ctx, OWNER, "A.mkv", mid=60)
|
| 193 |
+
assert "Queued" in ev.texts[0]
|
| 194 |
+
|
| 195 |
+
await say(ctx, OWNER, "/resize 320")
|
| 196 |
+
ev = await say(ctx, OWNER, "/cancel")
|
| 197 |
+
assert "Cancelled" in ev.texts[0] and OWNER not in ctx.app.modes
|
| 198 |
+
await send_photo(ctx)
|
| 199 |
+
assert results(ctx) == [] and await ctx.db.has_thumb(OWNER)
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
async def test_a_format_thumbnail_prompt_takes_priority_over_a_waiting_resize(ctx):
|
| 203 |
+
fmt = await ctx.db.create_format(OWNER, "One Piece")
|
| 204 |
+
await say(ctx, OWNER, "/resize 320")
|
| 205 |
+
await click(ctx, OWNER, f"fm:th:{fmt['id']}") # opened later: the user is setting the format's thumbnail now
|
| 206 |
+
await dispatch(ctx.client, FakeEvent(OWNER, message=FakeMessage("", photo=True, mid=70), photo_bytes=jpeg()))
|
| 207 |
+
assert (await ctx.db.get_format(OWNER, fmt["id"]))["has_thumb"] and results(ctx) == []
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
async def test_strangers_cannot_use_it(ctx):
|
| 211 |
+
ev = await say(ctx, STRANGER, "/resize 320")
|
| 212 |
+
assert ev.out == [] and STRANGER not in ctx.app.modes
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
async def test_starting_it_ends_a_sequence_and_says_so(ctx):
|
| 216 |
+
await say(ctx, OWNER, "/sequence")
|
| 217 |
+
await send_doc(ctx, OWNER, "Show - 01.mkv", mid=92)
|
| 218 |
+
ev = await say(ctx, OWNER, "/resize 320")
|
| 219 |
+
assert "1 file(s) was discarded" in ev.texts[0] and ctx.app.modes[OWNER].kind == "resize"
|
| 220 |
+
cb = await click(ctx, OWNER, "rz:s:640") # via the menu too
|
| 221 |
+
assert "discarded" not in cb.edits[-1][0] # the sequence was already dropped by the first command
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
async def test_both_commands_are_in_the_command_menu(ctx):
|
| 225 |
+
assert "resize" in dict(appmod.COMMANDS) and "ethumb" in dict(appmod.COMMANDS)
|
tests/test_sequence.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
|
| 3 |
+
from bot.sequence import MAX_LISTED, SeqItem, analyse, detect, format_report, ranges
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def item(mid, name, caption=""):
|
| 7 |
+
return SeqItem(msg_id=mid, name=name, caption=caption)
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def names(items):
|
| 11 |
+
return [i.name for i in items]
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
# ---------------------------------------------------------------------------
|
| 15 |
+
# reading the episode
|
| 16 |
+
# ---------------------------------------------------------------------------
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@pytest.mark.parametrize(
|
| 20 |
+
"name,expected",
|
| 21 |
+
[
|
| 22 |
+
("1147 - One Piece [Dual] 720p @Anime_X_Universe.mkv", (1, 1147)),
|
| 23 |
+
("[SubsPlease] Tokyo Revengers - 05 (1080p) [ABCD1234].mkv", (1, 5)),
|
| 24 |
+
("Show.S02E14.720p.mkv", (2, 14)),
|
| 25 |
+
("Show 1x07.mkv", (1, 7)),
|
| 26 |
+
("One Piece [1146] 720p.mkv", (1, 1146)),
|
| 27 |
+
("Some Movie 2023 1080p.mkv", (None, None)),
|
| 28 |
+
("holiday video.mp4", (None, None)),
|
| 29 |
+
],
|
| 30 |
+
)
|
| 31 |
+
def test_detect_from_the_file_name(name, expected):
|
| 32 |
+
assert detect(name, "", "filename") == expected
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def test_the_rename_mode_decides_which_source_is_read_first_and_the_other_is_the_fallback():
|
| 36 |
+
assert detect("Show S01E02.mkv", "Show S01E09", "filename") == (1, 2)
|
| 37 |
+
assert detect("Show S01E02.mkv", "Show S01E09", "caption") == (1, 9)
|
| 38 |
+
assert detect("random name.mkv", "One Piece - 1147", "filename") == (1, 1147) # nothing in the name: use the caption
|
| 39 |
+
assert detect("Show S01E02.mkv", "just words", "caption") == (1, 2) # nothing in the caption: use the name
|
| 40 |
+
assert detect("random.mkv", "", "caption") == (None, None)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
# ---------------------------------------------------------------------------
|
| 44 |
+
# ordering
|
| 45 |
+
# ---------------------------------------------------------------------------
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def test_files_come_out_in_ascending_episode_order_whatever_order_they_were_sent():
|
| 49 |
+
sent = [item(5, "1147 - One Piece.mkv"), item(6, "1145 - One Piece.mkv"), item(7, "1146 - One Piece.mkv"), item(8, "1148 - One Piece.mkv")]
|
| 50 |
+
a = analyse(sent, "filename")
|
| 51 |
+
assert names(a.ordered) == ["1145 - One Piece.mkv", "1146 - One Piece.mkv", "1147 - One Piece.mkv", "1148 - One Piece.mkv"]
|
| 52 |
+
assert a.missing == {} and a.repeated == {} and a.unknown == []
|
| 53 |
+
assert a.first == (1, 1145) and a.last == (1, 1148)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def test_episode_ten_sorts_after_nine_not_after_one():
|
| 57 |
+
a = analyse([item(1, "Show - 10.mkv"), item(2, "Show - 9.mkv"), item(3, "Show - 1.mkv"), item(4, "Show - 100.mkv")], "filename")
|
| 58 |
+
assert [i.episode for i in a.ordered] == [1, 9, 10, 100]
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def test_seasons_sort_before_episodes():
|
| 62 |
+
sent = [item(1, "Show S02E01.mkv"), item(2, "Show S01E03.mkv"), item(3, "Show S01E01.mkv"), item(4, "Show S02E02.mkv")]
|
| 63 |
+
a = analyse(sent, "filename")
|
| 64 |
+
assert [(i.season, i.episode) for i in a.ordered] == [(1, 1), (1, 3), (2, 1), (2, 2)]
|
| 65 |
+
assert a.missing == {1: [2]} # S01E02 is missing; the jump from S01 to S02 is not a gap
|
| 66 |
+
assert a.first == (1, 1) and a.last == (2, 2)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def test_files_without_an_episode_number_go_last_in_the_order_they_were_sent():
|
| 70 |
+
sent = [item(9, "zzz.mkv"), item(3, "Show - 02.mkv"), item(4, "aaa.mkv"), item(2, "Show - 01.mkv")]
|
| 71 |
+
a = analyse(sent, "filename")
|
| 72 |
+
assert names(a.ordered) == ["Show - 01.mkv", "Show - 02.mkv", "aaa.mkv", "zzz.mkv"]
|
| 73 |
+
assert names(a.unknown) == ["aaa.mkv", "zzz.mkv"]
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def test_nothing_is_numbered():
|
| 77 |
+
a = analyse([item(1, "b.mkv"), item(2, "a.mkv")], "filename")
|
| 78 |
+
assert names(a.ordered) == ["b.mkv", "a.mkv"] and a.first is None and a.last is None and a.missing == {}
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
# ---------------------------------------------------------------------------
|
| 82 |
+
# gaps and repeats
|
| 83 |
+
# ---------------------------------------------------------------------------
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def test_missing_episodes_are_the_gaps_between_the_first_and_the_last():
|
| 87 |
+
sent = [item(i, f"One Piece - {n}.mkv") for i, n in enumerate((1145, 1146, 1148, 1151, 1152, 1156), start=1)]
|
| 88 |
+
a = analyse(sent, "filename")
|
| 89 |
+
assert a.missing == {1: [1147, 1149, 1150, 1153, 1154, 1155]}
|
| 90 |
+
# an episode before the first one you sent is not "missing"
|
| 91 |
+
assert analyse([item(1, "S - 5.mkv"), item(2, "S - 6.mkv")], "filename").missing == {}
|
| 92 |
+
assert analyse([item(1, "S - 5.mkv")], "filename").missing == {}
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def test_repeated_episodes_list_every_file_that_shares_the_number():
|
| 96 |
+
sent = [item(1, "Show - 05 [A].mkv"), item(2, "Show - 05 [B].mkv"), item(3, "Show - 06.mkv"), item(4, "Show - 05 [C].mkv")]
|
| 97 |
+
a = analyse(sent, "filename")
|
| 98 |
+
assert list(a.repeated) == [(1, 5)] and names(a.repeated[(1, 5)]) == ["Show - 05 [A].mkv", "Show - 05 [B].mkv", "Show - 05 [C].mkv"]
|
| 99 |
+
assert names(a.ordered)[:3] == ["Show - 05 [A].mkv", "Show - 05 [B].mkv", "Show - 05 [C].mkv"] # both copies are kept, in send order
|
| 100 |
+
assert a.missing == {}
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def test_the_same_episode_number_in_different_seasons_is_not_a_repeat():
|
| 104 |
+
a = analyse([item(1, "Show S01E01.mkv"), item(2, "Show S02E01.mkv")], "filename")
|
| 105 |
+
assert a.repeated == {}
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def test_ranges():
|
| 109 |
+
assert ranges([3, 7, 8, 9]) == "E03, E07–E09"
|
| 110 |
+
assert ranges([1147]) == "E1147"
|
| 111 |
+
assert ranges([9, 3, 4]) == "E03–E04, E09"
|
| 112 |
+
assert ranges([]) == ""
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
# ---------------------------------------------------------------------------
|
| 116 |
+
# the report
|
| 117 |
+
# ---------------------------------------------------------------------------
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def reports(files, mode="filename", failed=()):
|
| 121 |
+
sent = [item(i, n) for i, n in enumerate(files, start=1)]
|
| 122 |
+
a = analyse(sent, mode)
|
| 123 |
+
return format_report(a, len(sent), len(sent) - len(failed), [item(0, n) for n in failed])
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def report(files, mode="filename", failed=()):
|
| 127 |
+
messages = reports(files, mode, failed)
|
| 128 |
+
assert len(messages) == 1
|
| 129 |
+
return messages[0]
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def test_a_clean_sequence_says_so():
|
| 133 |
+
text = report(["One Piece - 1145.mkv", "One Piece - 1146.mkv", "One Piece - 1147.mkv"])
|
| 134 |
+
assert "Sorted 3 files" in text and "E1145 → E1147" in text and "No missing or repeated episodes." in text
|
| 135 |
+
assert "Missing" not in text and "Repeated" not in text
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def test_files_without_a_number_do_not_hide_that_the_rest_is_complete():
|
| 139 |
+
text = report(["S - 01.mkv", "S - 02.mkv", "notes.mkv"])
|
| 140 |
+
assert "No missing or repeated episodes." in text and "No episode number found" in text
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def test_nothing_numbered_means_nothing_to_say_about_gaps():
|
| 144 |
+
text = report(["a.mkv", "b.mkv"])
|
| 145 |
+
assert "No missing or repeated" not in text and "No episode number found" in text and "→" not in text
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def test_one_file_is_not_plural():
|
| 149 |
+
assert "Sorted 1 file</b>" in report(["One Piece - 1145.mkv"])
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def test_the_report_lists_missing_repeated_and_unnumbered_files():
|
| 153 |
+
text = report(["S - 01.mkv", "S - 02 a.mkv", "S - 02 b.mkv", "S - 05.mkv", "S - 08.mkv", "notes.mkv"])
|
| 154 |
+
assert "Sorted 6 files" in text and "E01 → E08" in text
|
| 155 |
+
assert "Missing episodes</b>\nE03–E04, E06–E07" in text
|
| 156 |
+
assert "Repeated episodes" in text and "E02 ×2" in text and "S - 02 a.mkv" in text and "S - 02 b.mkv" in text
|
| 157 |
+
assert "No episode number found" in text and "notes.mkv" in text
|
| 158 |
+
assert "No missing or repeated" not in text
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def test_the_report_labels_seasons_only_when_there_are_several():
|
| 162 |
+
one = report(["S S01E01.mkv", "S S01E03.mkv"])
|
| 163 |
+
assert "E02" in one and "S01E02" not in one
|
| 164 |
+
two = report(["S S01E01.mkv", "S S01E03.mkv", "S S02E01.mkv", "S S02E02.mkv", "S S02E02 x.mkv"])
|
| 165 |
+
assert "S01: E02" in two and "S02E02 ×2" in two and "S01E01 → S02E02" in two
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def test_the_report_mentions_files_that_could_not_be_sent():
|
| 169 |
+
text = report(["S - 01.mkv", "S - 02.mkv"], failed=["S - 02.mkv"])
|
| 170 |
+
assert "Sorted 1 of 2 files" in text and "Couldn't be sent" in text and "S - 02.mkv" in text
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def test_names_are_escaped_and_long_lists_are_cut():
|
| 174 |
+
files = ["a&b <x>.mkv"] + [f"Dup {n}.mkv" for n in range(40)]
|
| 175 |
+
text = report(files)
|
| 176 |
+
assert "a&b <x>.mkv" in text and "<x>" not in text
|
| 177 |
+
assert "… and 29 more" in text and text.count("• ") == MAX_LISTED
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def test_ranges_are_cut_when_there_are_too_many_gaps():
|
| 181 |
+
text = ranges(range(2, 2000, 2), limit=300)
|
| 182 |
+
assert len(text) <= 300 and text.endswith(" more") and text.startswith("E02, E04")
|
| 183 |
+
shown = text.count("E")
|
| 184 |
+
assert shown > 10
|
| 185 |
+
assert ranges([3, 7], limit=300) == "E03, E07"
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def test_a_huge_report_is_split_into_messages_telegram_accepts():
|
| 189 |
+
files = []
|
| 190 |
+
files += [f"Long Running Show Name With A Rather Long Title - {n}.mkv" for n in range(1, 400, 2)] # 200 gaps
|
| 191 |
+
for n in range(1, 30): # many repeated numbers
|
| 192 |
+
files += [f"Another Show S02E{n:02d} [v{k}] with a very long name indeed.mkv" for k in range(5)]
|
| 193 |
+
files += [f"unknown file number {n} with quite a long name for a file.mkv" for n in range(50)]
|
| 194 |
+
messages = reports(files, failed=[f"deleted file {n}.mkv" for n in range(30)])
|
| 195 |
+
assert len(messages) > 1
|
| 196 |
+
assert all(len(m) <= 4000 for m in messages)
|
| 197 |
+
joined = "\n\n".join(messages)
|
| 198 |
+
assert "Missing episodes" in joined and "Repeated episodes" in joined and "No episode number found" in joined and "Couldn't be sent" in joined
|
| 199 |
+
assert messages[0].startswith("✅ <b>Sorted")
|
tests/test_sequence_app.py
ADDED
|
@@ -0,0 +1,442 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""/sequence (collect files) and /sort (send them back in episode order, then report gaps and repeats)."""
|
| 2 |
+
import asyncio
|
| 3 |
+
import time
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
from telethon import errors
|
| 7 |
+
|
| 8 |
+
from bot import app as appmod
|
| 9 |
+
|
| 10 |
+
from .appkit import ADMIN_CFG, OWNER, STRANGER, click, data_of, say, send_doc
|
| 11 |
+
|
| 12 |
+
OP = "{n} - One Piece [Dual] 720p @Anime_X_Universe.mkv"
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@pytest.fixture(autouse=True)
|
| 16 |
+
def quick(monkeypatch):
|
| 17 |
+
monkeypatch.setattr(appmod, "SEQUENCE_STATUS_DELAY", 0.02)
|
| 18 |
+
monkeypatch.setattr(appmod, "SORT_GAP", 0)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
async def settle(delay=0.12):
|
| 22 |
+
await asyncio.sleep(delay)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
async def feed(ctx, numbers, uid=OWNER, first_id=100):
|
| 26 |
+
"""Send One Piece episodes in this order; returns {episode: message id} (the last id wins for repeats)."""
|
| 27 |
+
ids = {}
|
| 28 |
+
for k, n in enumerate(numbers):
|
| 29 |
+
await send_doc(ctx, uid, OP.format(n=n), mid=first_id + k)
|
| 30 |
+
ids[n] = first_id + k
|
| 31 |
+
return ids
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def sent_ids(ctx, uid=OWNER):
|
| 35 |
+
return [f.id for entity, f, _kw in ctx.client.files if entity == uid]
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def texts(ctx):
|
| 39 |
+
return [t for _e, t, _k in ctx.client.sent]
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def last_text(ctx):
|
| 43 |
+
return ctx.client.sent[-1][1]
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
# ---------------------------------------------------------------------------
|
| 47 |
+
# collecting
|
| 48 |
+
# ---------------------------------------------------------------------------
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
async def test_sequence_asks_for_files_and_collects_them_instead_of_renaming(ctx):
|
| 52 |
+
ev = await say(ctx, OWNER, "/sequence")
|
| 53 |
+
assert "SEQUENCE" in ev.texts[0] and "/sort" in ev.texts[0] and data_of(ev.out[0][1]) == ["sq:cx"]
|
| 54 |
+
assert ctx.app.modes[OWNER].kind == "sequence"
|
| 55 |
+
|
| 56 |
+
for k, n in enumerate((1147, 1145, 1146)):
|
| 57 |
+
got = await send_doc(ctx, OWNER, OP.format(n=n), mid=100 + k)
|
| 58 |
+
assert got.out == [] # nothing is queued, nothing is said per file
|
| 59 |
+
assert ctx.app.queue.waiting == 0 and ctx.app.queue.running == 0
|
| 60 |
+
assert [i.msg_id for i in ctx.app.modes[OWNER].data.items] == [100, 101, 102]
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
async def test_one_status_message_follows_each_burst_of_files_and_replaces_the_last(ctx):
|
| 64 |
+
await say(ctx, OWNER, "/sequence")
|
| 65 |
+
await feed(ctx, (1, 2, 3))
|
| 66 |
+
await settle()
|
| 67 |
+
(status,) = [(t, k) for _e, t, k in ctx.client.sent if "collected" in t]
|
| 68 |
+
assert "3 files collected" in status[0] and "/sort" in status[0]
|
| 69 |
+
assert data_of(status[1]["buttons"]) == ["sq:sort", "sq:cx"]
|
| 70 |
+
assert ctx.client.deleted == []
|
| 71 |
+
|
| 72 |
+
await send_doc(ctx, OWNER, OP.format(n=4), mid=200)
|
| 73 |
+
await settle()
|
| 74 |
+
assert [t for t in texts(ctx) if "collected" in t][-1].startswith("📥 <b>4 files collected")
|
| 75 |
+
assert ctx.client.deleted == [(OWNER, [2001])] # the old status goes, so the newest one is at the bottom
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
async def test_a_second_sequence_command_keeps_the_files_already_collected(ctx):
|
| 79 |
+
await say(ctx, OWNER, "/sequence")
|
| 80 |
+
await feed(ctx, (1, 2))
|
| 81 |
+
ev = await say(ctx, OWNER, "/sequence")
|
| 82 |
+
assert "already collecting" in ev.texts[0] and "2 files collected" in ev.texts[0] and data_of(ev.out[0][1]) == ["sq:sort", "sq:cx"]
|
| 83 |
+
assert len(ctx.app.modes[OWNER].data.items) == 2
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
async def test_the_limit_is_announced_once_and_extra_files_are_ignored(ctx, monkeypatch):
|
| 87 |
+
monkeypatch.setattr(appmod, "SEQUENCE_MAX", 3)
|
| 88 |
+
await say(ctx, OWNER, "/sequence")
|
| 89 |
+
replies = []
|
| 90 |
+
for k in range(6):
|
| 91 |
+
replies += (await send_doc(ctx, OWNER, OP.format(n=k + 1), mid=100 + k)).texts
|
| 92 |
+
assert len(ctx.app.modes[OWNER].data.items) == 3
|
| 93 |
+
assert len(replies) == 1 and "limit: 3 files" in replies[0]
|
| 94 |
+
await settle()
|
| 95 |
+
assert "3 files collected" in texts(ctx)[-1] and "most I can sort" in texts(ctx)[-1]
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
async def test_every_user_has_their_own_sequence(ctx):
|
| 99 |
+
await say(ctx, OWNER, "/sequence")
|
| 100 |
+
await say(ctx, ADMIN_CFG, "/sequence")
|
| 101 |
+
await feed(ctx, (3, 1, 2), uid=OWNER, first_id=100)
|
| 102 |
+
await feed(ctx, (9, 8), uid=ADMIN_CFG, first_id=500)
|
| 103 |
+
await say(ctx, ADMIN_CFG, "/sort")
|
| 104 |
+
assert sent_ids(ctx, ADMIN_CFG) == [501, 500] and sent_ids(ctx, OWNER) == []
|
| 105 |
+
await say(ctx, OWNER, "/sort")
|
| 106 |
+
assert sent_ids(ctx, OWNER) == [101, 102, 100]
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
async def test_strangers_cannot_start_a_sequence(ctx):
|
| 110 |
+
ev = await say(ctx, STRANGER, "/sequence")
|
| 111 |
+
assert ev.out == [] and STRANGER not in ctx.app.modes
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
# ---------------------------------------------------------------------------
|
| 115 |
+
# /sort
|
| 116 |
+
# ---------------------------------------------------------------------------
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
async def test_sort_sends_the_files_back_in_ascending_episode_order(ctx):
|
| 120 |
+
await say(ctx, OWNER, "/sequence")
|
| 121 |
+
ids = await feed(ctx, (1147, 1145, 1148, 1146))
|
| 122 |
+
await say(ctx, OWNER, "/sort")
|
| 123 |
+
assert sent_ids(ctx) == [ids[1145], ids[1146], ids[1147], ids[1148]]
|
| 124 |
+
assert OWNER not in ctx.app.modes
|
| 125 |
+
assert "Sorted 4 files" in last_text(ctx) and "E1145 → E1148" in last_text(ctx) and "No missing or repeated episodes." in last_text(ctx)
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
async def test_sort_reports_missing_and_repeated_episodes_after_the_files(ctx):
|
| 129 |
+
await say(ctx, OWNER, "/sequence")
|
| 130 |
+
await feed(ctx, (1147, 1145, 1149, 1146, 1146, 1152))
|
| 131 |
+
await say(ctx, OWNER, "/sort")
|
| 132 |
+
assert len(sent_ids(ctx)) == 6
|
| 133 |
+
report = last_text(ctx)
|
| 134 |
+
assert "Sorted 6 files" in report and "E1145 → E1152" in report
|
| 135 |
+
assert "Missing episodes</b>\nE1148, E1150–E1151" in report
|
| 136 |
+
assert "Repeated episodes" in report and "E1146 ×2" in report
|
| 137 |
+
assert "No missing or repeated" not in report
|
| 138 |
+
assert ctx.client.sent[-1][2]["parse_mode"] == "html"
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
async def test_files_without_an_episode_number_are_sent_last_and_listed(ctx):
|
| 142 |
+
await say(ctx, OWNER, "/sequence")
|
| 143 |
+
await send_doc(ctx, OWNER, "notes.mkv", mid=100)
|
| 144 |
+
await send_doc(ctx, OWNER, OP.format(n=2), mid=101)
|
| 145 |
+
await send_doc(ctx, OWNER, OP.format(n=1), mid=102)
|
| 146 |
+
await say(ctx, OWNER, "/sort")
|
| 147 |
+
assert sent_ids(ctx) == [102, 101, 100]
|
| 148 |
+
assert "No episode number found" in last_text(ctx) and "notes.mkv" in last_text(ctx)
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
async def test_captions_and_their_formatting_travel_with_each_file(ctx):
|
| 152 |
+
await say(ctx, OWNER, "/sequence")
|
| 153 |
+
await send_doc(ctx, OWNER, OP.format(n=2), mid=100, caption="Episode two <b>bold</b>")
|
| 154 |
+
await send_doc(ctx, OWNER, OP.format(n=1), mid=101)
|
| 155 |
+
ctx.client.messages[100].entities = ["BOLD-ENTITY"]
|
| 156 |
+
await say(ctx, OWNER, "/sort")
|
| 157 |
+
(_e1, first, kw1), (_e2, second, kw2) = ctx.client.files
|
| 158 |
+
assert (first.id, second.id) == (101, 100)
|
| 159 |
+
assert kw1["caption"] == "" and kw2["caption"] == "Episode two <b>bold</b>"
|
| 160 |
+
assert kw2["formatting_entities"] == ["BOLD-ENTITY"] and kw2["parse_mode"] is None # the text is not parsed a second time
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
async def test_sort_fetches_the_messages_again_so_old_file_references_still_work(ctx):
|
| 164 |
+
await say(ctx, OWNER, "/sequence")
|
| 165 |
+
await feed(ctx, (2, 1, 3))
|
| 166 |
+
await say(ctx, OWNER, "/sort")
|
| 167 |
+
(chat, ids), = ctx.client.get_calls
|
| 168 |
+
assert chat == OWNER and sorted(ids) == [100, 101, 102] and ids == [101, 100, 102]
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
async def test_the_rename_mode_decides_where_the_episode_is_read_from(ctx):
|
| 172 |
+
await say(ctx, OWNER, "/mode caption")
|
| 173 |
+
await say(ctx, OWNER, "/sequence")
|
| 174 |
+
await send_doc(ctx, OWNER, "video.mkv", mid=100, caption="One Piece - 1147")
|
| 175 |
+
await send_doc(ctx, OWNER, "other.mkv", mid=101, caption="One Piece - 1145")
|
| 176 |
+
await send_doc(ctx, OWNER, "clip.mkv", mid=102, caption="One Piece - 1146")
|
| 177 |
+
await say(ctx, OWNER, "/sort")
|
| 178 |
+
assert sent_ids(ctx) == [101, 102, 100]
|
| 179 |
+
assert "E1145 → E1147" in last_text(ctx)
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
async def test_sort_with_nothing_collected_explains_how_it_works(ctx):
|
| 183 |
+
for _ in range(2):
|
| 184 |
+
await say(ctx, OWNER, "/sort")
|
| 185 |
+
assert "Nothing to sort yet" in last_text(ctx) and "/sequence" in last_text(ctx)
|
| 186 |
+
await say(ctx, OWNER, "/sequence") # started but empty: still nothing to sort
|
| 187 |
+
assert ctx.client.files == []
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
async def test_after_a_sort_files_are_renamed_as_usual_again(ctx):
|
| 191 |
+
await say(ctx, OWNER, "/sequence")
|
| 192 |
+
await feed(ctx, (1, 2))
|
| 193 |
+
await say(ctx, OWNER, "/sort")
|
| 194 |
+
ev = await send_doc(ctx, OWNER, OP.format(n=3), mid=300)
|
| 195 |
+
assert "Queued" in ev.texts[0] and ctx.app.queue.waiting == 1
|
| 196 |
+
# and a new sequence starts from scratch
|
| 197 |
+
await say(ctx, OWNER, "/sequence")
|
| 198 |
+
await feed(ctx, (7,), first_id=400)
|
| 199 |
+
await say(ctx, OWNER, "/sort")
|
| 200 |
+
assert sent_ids(ctx)[-1] == 400 and "Sorted 1 file" in last_text(ctx)
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
async def test_a_sequence_that_nobody_finished_is_forgotten_after_its_time(ctx):
|
| 204 |
+
await say(ctx, OWNER, "/sequence")
|
| 205 |
+
await feed(ctx, (1, 2))
|
| 206 |
+
ctx.app.modes[OWNER].expires = time.time() - 1
|
| 207 |
+
await say(ctx, OWNER, "/sort")
|
| 208 |
+
assert "Nothing to sort yet" in last_text(ctx) and ctx.client.files == []
|
| 209 |
+
ev = await send_doc(ctx, OWNER, OP.format(n=3), mid=300)
|
| 210 |
+
assert "Queued" in ev.texts[0]
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
async def test_every_new_file_gives_the_sequence_more_time(ctx):
|
| 214 |
+
await say(ctx, OWNER, "/sequence")
|
| 215 |
+
mode = ctx.app.modes[OWNER]
|
| 216 |
+
mode.expires = time.time() + 5
|
| 217 |
+
await feed(ctx, (1,))
|
| 218 |
+
assert mode.expires > time.time() + appmod.SEQUENCE_WAIT - 60
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
async def test_the_status_message_is_removed_and_a_progress_message_replaces_it(ctx, monkeypatch):
|
| 222 |
+
monkeypatch.setattr(appmod, "SORT_PROGRESS_EVERY", 2)
|
| 223 |
+
await say(ctx, OWNER, "/sequence")
|
| 224 |
+
await feed(ctx, (5, 4, 3, 2, 1))
|
| 225 |
+
await settle()
|
| 226 |
+
status_id = [2000 + i + 1 for i, (_e, t, _k) in enumerate(ctx.client.sent) if "collected" in t][-1]
|
| 227 |
+
await say(ctx, OWNER, "/sort")
|
| 228 |
+
assert (OWNER, [status_id]) in ctx.client.deleted
|
| 229 |
+
progress = [t for _c, t, _k in ctx.client.edited]
|
| 230 |
+
assert progress == ["🔢 <b>Sorting…</b> 2 of 5 sent", "🔢 <b>Sorting…</b> 4 of 5 sent"]
|
| 231 |
+
sorting_id = [2000 + i + 1 for i, (_e, t, _k) in enumerate(ctx.client.sent) if "Sorting 5 files" in t][0]
|
| 232 |
+
assert (OWNER, [sorting_id]) in ctx.client.deleted # the progress message goes before the report
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
# ---------------------------------------------------------------------------
|
| 236 |
+
# buttons
|
| 237 |
+
# ---------------------------------------------------------------------------
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
async def test_the_sort_button_does_the_same_as_the_command(ctx):
|
| 241 |
+
await say(ctx, OWNER, "/sequence")
|
| 242 |
+
await feed(ctx, (3, 1, 2))
|
| 243 |
+
cb = await click(ctx, OWNER, "sq:sort")
|
| 244 |
+
assert cb.answers and sent_ids(ctx) == [101, 102, 100] and "Sorted 3 files" in last_text(ctx)
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
async def test_the_discard_button_throws_the_files_away(ctx):
|
| 248 |
+
await say(ctx, OWNER, "/sequence")
|
| 249 |
+
await feed(ctx, (3, 1))
|
| 250 |
+
cb = await click(ctx, OWNER, "sq:cx")
|
| 251 |
+
assert "2 files discarded" in cb.edits[-1][0] and cb.edits[-1][1] == []
|
| 252 |
+
assert OWNER not in ctx.app.modes
|
| 253 |
+
await say(ctx, OWNER, "/sort")
|
| 254 |
+
assert "Nothing to sort yet" in last_text(ctx)
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
async def test_old_buttons_say_the_sequence_is_over(ctx):
|
| 258 |
+
for data in ("sq:sort", "sq:cx"):
|
| 259 |
+
cb = await click(ctx, OWNER, data)
|
| 260 |
+
assert cb.alerts == ["That sequence is over."]
|
| 261 |
+
assert ctx.client.files == [] and ctx.client.sent == []
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
# ---------------------------------------------------------------------------
|
| 265 |
+
# /cancel
|
| 266 |
+
# ---------------------------------------------------------------------------
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
async def test_cancel_discards_the_collected_files(ctx):
|
| 270 |
+
await say(ctx, OWNER, "/sequence")
|
| 271 |
+
await feed(ctx, (2, 1))
|
| 272 |
+
await settle()
|
| 273 |
+
status_id = 2001 # the "2 files collected" message
|
| 274 |
+
before = len([t for t in texts(ctx) if "collected" in t])
|
| 275 |
+
ev = await say(ctx, OWNER, "/cancel")
|
| 276 |
+
assert "Sequence cancelled" in ev.texts[0] and "2 files discarded" in ev.texts[0]
|
| 277 |
+
assert (OWNER, [status_id]) in ctx.client.deleted # its buttons would be dead, so it goes
|
| 278 |
+
await say(ctx, OWNER, "/sort")
|
| 279 |
+
assert "Nothing to sort yet" in last_text(ctx)
|
| 280 |
+
await settle()
|
| 281 |
+
assert len([t for t in texts(ctx) if "collected" in t]) == before == 1 # no new status after the cancel
|
| 282 |
+
ev = await send_doc(ctx, OWNER, OP.format(n=1), mid=300)
|
| 283 |
+
assert "Queued" in ev.texts[0]
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
async def test_cancel_leaves_queued_jobs_for_the_next_cancel(ctx):
|
| 287 |
+
await send_doc(ctx, OWNER, OP.format(n=9), mid=50)
|
| 288 |
+
assert ctx.app.queue.waiting == 1
|
| 289 |
+
await say(ctx, OWNER, "/sequence")
|
| 290 |
+
await feed(ctx, (2,))
|
| 291 |
+
ev = await say(ctx, OWNER, "/cancel")
|
| 292 |
+
assert "Sequence cancelled" in ev.texts[0] and "/cancel again" in ev.texts[0] and ctx.app.queue.waiting == 1
|
| 293 |
+
ev = await say(ctx, OWNER, "/cancel")
|
| 294 |
+
assert "Cancelling 1 job" in ev.texts[0]
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
async def test_cancel_stops_a_sort_that_is_sending(ctx):
|
| 298 |
+
await say(ctx, OWNER, "/sequence")
|
| 299 |
+
await feed(ctx, (5, 4, 3, 2, 1))
|
| 300 |
+
gate = asyncio.Event()
|
| 301 |
+
original = ctx.client.send_file
|
| 302 |
+
calls = []
|
| 303 |
+
|
| 304 |
+
async def slow(entity, file, **kw):
|
| 305 |
+
calls.append(file.id)
|
| 306 |
+
if len(calls) == 2:
|
| 307 |
+
await gate.wait()
|
| 308 |
+
return await original(entity, file, **kw)
|
| 309 |
+
|
| 310 |
+
ctx.client.send_file = slow
|
| 311 |
+
sorting = asyncio.ensure_future(say(ctx, OWNER, "/sort"))
|
| 312 |
+
await settle(0.05)
|
| 313 |
+
assert calls == [104, 103] # episode 1, then 2 is in flight
|
| 314 |
+
|
| 315 |
+
again = await say(ctx, OWNER, "/sort") # a second /sort while one is running
|
| 316 |
+
assert again.out == [] and "still sorting" in last_text(ctx)
|
| 317 |
+
ev = await say(ctx, OWNER, "/cancel")
|
| 318 |
+
assert "Stopping the sort" in ev.texts[0]
|
| 319 |
+
gate.set()
|
| 320 |
+
await sorting
|
| 321 |
+
assert sent_ids(ctx) == [104, 103] and "Stopped after 2 of 5 files" in last_text(ctx)
|
| 322 |
+
assert "Sorted" not in last_text(ctx) and OWNER not in ctx.app._sorting
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
# ---------------------------------------------------------------------------
|
| 326 |
+
# things going wrong while sending
|
| 327 |
+
# ---------------------------------------------------------------------------
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
async def test_a_short_flood_wait_is_waited_out_and_the_file_is_sent_again(ctx):
|
| 331 |
+
slept = []
|
| 332 |
+
|
| 333 |
+
async def pause(seconds):
|
| 334 |
+
slept.append(seconds)
|
| 335 |
+
|
| 336 |
+
ctx.app._pause = pause
|
| 337 |
+
await say(ctx, OWNER, "/sequence")
|
| 338 |
+
await feed(ctx, (2, 1, 3))
|
| 339 |
+
ctx.client.file_errors[100] = [errors.FloodWaitError(None, 90)]
|
| 340 |
+
await say(ctx, OWNER, "/sort")
|
| 341 |
+
assert sent_ids(ctx) == [101, 100, 102] and slept == [91]
|
| 342 |
+
assert "Sorted 3 files" in last_text(ctx) and "Couldn't be sent" not in last_text(ctx)
|
| 343 |
+
|
| 344 |
+
|
| 345 |
+
async def test_a_very_long_flood_wait_stops_the_sort_and_lists_what_was_not_sent(ctx):
|
| 346 |
+
await say(ctx, OWNER, "/sequence")
|
| 347 |
+
await feed(ctx, (4, 3, 2, 1))
|
| 348 |
+
ctx.client.file_errors[102] = [errors.FloodWaitError(None, 3600)] # episode 2
|
| 349 |
+
await say(ctx, OWNER, "/sort")
|
| 350 |
+
assert sent_ids(ctx) == [103] # episode 1 went out, the wait hit at episode 2
|
| 351 |
+
report = last_text(ctx)
|
| 352 |
+
assert "Sorted 1 of 4 files" in report and "wait 1h 0m 0s" in report and "Couldn't be sent" in report
|
| 353 |
+
assert report.count(OP.format(n=2)[:20]) >= 1 and OP.format(n=4)[:4] in report
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
async def test_a_status_that_is_still_being_sent_when_sort_starts_is_cleaned_up(ctx):
|
| 357 |
+
original = ctx.client.send_message
|
| 358 |
+
release = asyncio.Event()
|
| 359 |
+
|
| 360 |
+
async def slow_status(entity, text="", **kw):
|
| 361 |
+
if "collected" in text:
|
| 362 |
+
await release.wait()
|
| 363 |
+
return await original(entity, text, **kw)
|
| 364 |
+
|
| 365 |
+
ctx.client.send_message = slow_status
|
| 366 |
+
await say(ctx, OWNER, "/sequence")
|
| 367 |
+
await feed(ctx, (2, 1))
|
| 368 |
+
await settle() # the status message is on its way, not sent yet
|
| 369 |
+
await say(ctx, OWNER, "/sort")
|
| 370 |
+
release.set()
|
| 371 |
+
await settle()
|
| 372 |
+
collected = [i + 1 + 2000 for i, (_e, t, _k) in enumerate(ctx.client.sent) if "collected" in t]
|
| 373 |
+
assert len(collected) == 1 and (OWNER, collected) in ctx.client.deleted
|
| 374 |
+
assert "Sorted 2 files" in last_text(ctx) or sent_ids(ctx) == [101, 100]
|
| 375 |
+
|
| 376 |
+
|
| 377 |
+
async def test_a_file_that_fails_is_listed_and_the_others_still_arrive(ctx):
|
| 378 |
+
await say(ctx, OWNER, "/sequence")
|
| 379 |
+
await feed(ctx, (3, 2, 1))
|
| 380 |
+
ctx.client.file_errors[101] = [RuntimeError("MEDIA_EMPTY")] # episode 2
|
| 381 |
+
await say(ctx, OWNER, "/sort")
|
| 382 |
+
assert sent_ids(ctx) == [102, 100]
|
| 383 |
+
report = last_text(ctx)
|
| 384 |
+
assert "Sorted 2 of 3 files" in report and "Couldn't be sent" in report and "2 - One Piece" in report
|
| 385 |
+
|
| 386 |
+
|
| 387 |
+
async def test_a_file_deleted_before_sorting_is_listed_not_fatal(ctx):
|
| 388 |
+
await say(ctx, OWNER, "/sequence")
|
| 389 |
+
await feed(ctx, (3, 2, 1))
|
| 390 |
+
del ctx.client.messages[101]
|
| 391 |
+
await say(ctx, OWNER, "/sort")
|
| 392 |
+
assert sent_ids(ctx) == [102, 100]
|
| 393 |
+
assert "Sorted 2 of 3 files" in last_text(ctx) and "Couldn't be sent" in last_text(ctx)
|
| 394 |
+
assert OWNER not in ctx.app._sorting
|
| 395 |
+
|
| 396 |
+
|
| 397 |
+
async def test_sort_that_blows_up_does_not_leave_the_user_locked_out(ctx):
|
| 398 |
+
await say(ctx, OWNER, "/sequence")
|
| 399 |
+
await feed(ctx, (2, 1))
|
| 400 |
+
|
| 401 |
+
async def boom(*a, **kw):
|
| 402 |
+
raise RuntimeError("telegram is down")
|
| 403 |
+
|
| 404 |
+
original, ctx.client.get_messages = ctx.client.get_messages, boom
|
| 405 |
+
ev = await say(ctx, OWNER, "/sort")
|
| 406 |
+
assert "Something went wrong" in ev.texts[-1] and OWNER not in ctx.app._sorting
|
| 407 |
+
ctx.client.get_messages = original
|
| 408 |
+
await say(ctx, OWNER, "/sequence")
|
| 409 |
+
await feed(ctx, (1,), first_id=300)
|
| 410 |
+
await say(ctx, OWNER, "/sort")
|
| 411 |
+
assert sent_ids(ctx) == [300]
|
| 412 |
+
|
| 413 |
+
|
| 414 |
+
# ---------------------------------------------------------------------------
|
| 415 |
+
# other commands that wait for a file
|
| 416 |
+
# ---------------------------------------------------------------------------
|
| 417 |
+
|
| 418 |
+
|
| 419 |
+
async def test_starting_a_rename_ends_the_sequence_and_says_so(ctx):
|
| 420 |
+
fmt = await ctx.db.create_format(OWNER, "One Piece")
|
| 421 |
+
await say(ctx, OWNER, "/sequence")
|
| 422 |
+
await feed(ctx, (1, 2))
|
| 423 |
+
cb = await click(ctx, OWNER, f"rn:p:{fmt['id']}")
|
| 424 |
+
assert "One Piece" in cb.edits[-1][0] and "2 file(s) was discarded" in cb.edits[-1][0]
|
| 425 |
+
assert ctx.app.modes[OWNER].kind == "rename"
|
| 426 |
+
await settle()
|
| 427 |
+
assert [t for t in texts(ctx) if "collected" in t] == []
|
| 428 |
+
|
| 429 |
+
|
| 430 |
+
async def test_a_rename_with_no_sequence_has_no_such_note(ctx):
|
| 431 |
+
fmt = await ctx.db.create_format(OWNER, "One Piece")
|
| 432 |
+
cb = await click(ctx, OWNER, f"rn:p:{fmt['id']}")
|
| 433 |
+
assert "discarded" not in cb.edits[-1][0]
|
| 434 |
+
|
| 435 |
+
|
| 436 |
+
async def test_starting_a_sequence_over_a_rename_session_replaces_it(ctx):
|
| 437 |
+
fmt = await ctx.db.create_format(OWNER, "One Piece")
|
| 438 |
+
await click(ctx, OWNER, f"rn:p:{fmt['id']}")
|
| 439 |
+
ev = await say(ctx, OWNER, "/sequence")
|
| 440 |
+
assert "discarded" not in ev.texts[0] and ctx.app.modes[OWNER].kind == "sequence"
|
| 441 |
+
await send_doc(ctx, OWNER, OP.format(n=1), mid=100)
|
| 442 |
+
assert ctx.app.queue.waiting == 0 # collected, not renamed
|
tests/test_smedia.py
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""/smedia: the owner picks an image or a GIF, and /start shows it above the menu."""
|
| 2 |
+
import base64
|
| 3 |
+
import io
|
| 4 |
+
import json
|
| 5 |
+
import time
|
| 6 |
+
from types import SimpleNamespace
|
| 7 |
+
|
| 8 |
+
from PIL import Image
|
| 9 |
+
from telethon import types
|
| 10 |
+
|
| 11 |
+
from bot import app as appmod
|
| 12 |
+
|
| 13 |
+
from .appkit import ADMIN_CFG, OWNER, STRANGER, click, data_of, jpeg, say, send_doc
|
| 14 |
+
from .tgfakes import FakeEvent, FakeMessage, dispatch, make_file
|
| 15 |
+
|
| 16 |
+
GIF_BYTES = b"\x00\x00\x00\x18ftypmp42-fake-animation-bytes"
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def uploads(ctx):
|
| 20 |
+
"""The files the bot uploaded (bytes), as (chat, file, kwargs)."""
|
| 21 |
+
return [(entity, file, kw) for entity, file, kw in ctx.client.files if isinstance(file, io.BytesIO)]
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
async def send_photo(ctx, mid=50, data=None, sender=OWNER):
|
| 25 |
+
ctx.client.media[mid] = data if data is not None else jpeg((800, 600))
|
| 26 |
+
ev = FakeEvent(sender, message=FakeMessage("", photo=True, mid=mid), photo_bytes=jpeg()) # photo_bytes: what a thumbnail is made of
|
| 27 |
+
await dispatch(ctx.client, ev)
|
| 28 |
+
return ev
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
async def send_image_file(ctx, name="banner.png", mid=51, data=None, size=200 * 1024, sender=OWNER):
|
| 32 |
+
ctx.client.media[mid] = data if data is not None else jpeg((800, 600))
|
| 33 |
+
return await send_doc(ctx, sender, name, size=size, video=False, mime="image/png", mid=mid)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
async def send_gif(ctx, mid=60, data=GIF_BYTES, size=1024 * 1024, sender=OWNER):
|
| 37 |
+
ctx.client.media[mid] = data
|
| 38 |
+
file = make_file("anim.mp4", size, "video/mp4", width=480, height=270, duration=3.5)
|
| 39 |
+
ev = FakeEvent(sender, message=FakeMessage("", mid=mid, file=file, video=True, gif=True))
|
| 40 |
+
await dispatch(ctx.client, ev)
|
| 41 |
+
return ev
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
async def choose(ctx, kind):
|
| 45 |
+
return await click(ctx, OWNER, f"sm:{kind}")
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
async def stored(ctx):
|
| 49 |
+
"""(description, file bytes) of the saved /start media, or (None, None)."""
|
| 50 |
+
meta = await ctx.db.kv_get("start_media")
|
| 51 |
+
data = await ctx.db.kv_get("start_media_data")
|
| 52 |
+
return (json.loads(meta) if meta else None), (base64.b64decode(data) if data else None)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def telegram_gives_ids(ctx, *ids):
|
| 56 |
+
"""Make send_file answer like Telegram does: with a message whose file has an id."""
|
| 57 |
+
original, wanted = ctx.client.send_file, iter(ids)
|
| 58 |
+
|
| 59 |
+
async def send_file(entity, file, **kw):
|
| 60 |
+
sent = await original(entity, file, **kw)
|
| 61 |
+
sent.file = SimpleNamespace(id=next(wanted, "FILEID-LATER"))
|
| 62 |
+
return sent
|
| 63 |
+
|
| 64 |
+
ctx.client.send_file = send_file
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def file_ids_go_stale(ctx):
|
| 68 |
+
"""Sending by file id fails (an expired reference); uploading still works."""
|
| 69 |
+
original = ctx.client.send_file
|
| 70 |
+
|
| 71 |
+
async def send_file(entity, file, **kw):
|
| 72 |
+
if isinstance(file, str):
|
| 73 |
+
raise RuntimeError("FILE_REFERENCE_EXPIRED")
|
| 74 |
+
return await original(entity, file, **kw)
|
| 75 |
+
|
| 76 |
+
ctx.client.send_file = send_file
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
# ---------------------------------------------------------------------------
|
| 80 |
+
# the menu
|
| 81 |
+
# ---------------------------------------------------------------------------
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
async def test_the_menu_offers_image_and_gif(ctx):
|
| 85 |
+
ev = await say(ctx, OWNER, "/smedia")
|
| 86 |
+
assert "START MEDIA" in ev.texts[0] and "/start shows: <b>nothing, just the menu</b>" in ev.texts[0]
|
| 87 |
+
assert data_of(ev.out[0][1]) == ["sm:image", "sm:gif", "x:close"] # nothing to remove yet
|
| 88 |
+
assert OWNER not in ctx.app.modes
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
async def test_only_the_owner_can_use_it(ctx):
|
| 92 |
+
assert "smedia" in appmod.OWNER_COMMANDS and "smedia" not in dict(appmod.COMMANDS) # not in the public command menu
|
| 93 |
+
ev = await say(ctx, ADMIN_CFG, "/smedia")
|
| 94 |
+
assert ev.texts == ["⛔ Only the owner can do that."]
|
| 95 |
+
assert (await say(ctx, STRANGER, "/smedia")).out == []
|
| 96 |
+
cb = await click(ctx, ADMIN_CFG, "sm:image") # an allowed user who got hold of the buttons
|
| 97 |
+
assert cb.alerts == ["Owner only."] and ADMIN_CFG not in ctx.app.modes
|
| 98 |
+
assert not hasattr(ctx.cfg, "start_image") # the START_IMAGE setting is gone
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
async def test_choosing_makes_the_bot_wait_for_that_kind_of_media(ctx):
|
| 102 |
+
cb = await choose(ctx, "image")
|
| 103 |
+
assert "Send the image" in cb.edits[-1][0] and "/cancel to stop" in cb.edits[-1][0]
|
| 104 |
+
assert data_of(cb.edits[-1][1]) == ["sm:menu", "sm:stop"]
|
| 105 |
+
mode = ctx.app.modes[OWNER]
|
| 106 |
+
assert mode.kind == "smedia" and mode.data == {"kind": "image"} and mode.expires > time.time() + 500
|
| 107 |
+
cb = await choose(ctx, "gif")
|
| 108 |
+
assert "Send the GIF" in cb.edits[-1][0] and ctx.app.modes[OWNER].data == {"kind": "gif"}
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
async def test_back_stop_and_cancel_leave_nothing_waiting(ctx):
|
| 112 |
+
await choose(ctx, "image")
|
| 113 |
+
cb = await click(ctx, OWNER, "sm:menu")
|
| 114 |
+
assert "START MEDIA" in cb.edits[-1][0] and OWNER not in ctx.app.modes
|
| 115 |
+
await choose(ctx, "gif")
|
| 116 |
+
cb = await click(ctx, OWNER, "sm:stop")
|
| 117 |
+
assert "Stopped" in cb.edits[-1][0] and OWNER not in ctx.app.modes
|
| 118 |
+
await choose(ctx, "gif")
|
| 119 |
+
ev = await say(ctx, OWNER, "/cancel")
|
| 120 |
+
assert "Send /smedia again" in ev.texts[0] and OWNER not in ctx.app.modes
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
# ---------------------------------------------------------------------------
|
| 124 |
+
# an image
|
| 125 |
+
# ---------------------------------------------------------------------------
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
async def test_a_photo_becomes_the_start_image_and_is_shown_right_away(ctx):
|
| 129 |
+
await choose(ctx, "image")
|
| 130 |
+
photo = jpeg((800, 600))
|
| 131 |
+
ev = await send_photo(ctx, data=photo)
|
| 132 |
+
(entity, file, kw), = uploads(ctx)
|
| 133 |
+
assert entity == OWNER and file.name == "start.jpg" and file.getvalue() == photo
|
| 134 |
+
assert "Auto Rename Bot" in kw["caption"] and "m:rename" in data_of(kw["buttons"]) and kw["parse_mode"] == "html"
|
| 135 |
+
assert ev.texts == ["✅ Done: /start shows this image from now on."]
|
| 136 |
+
assert OWNER not in ctx.app.modes and ctx.client.downloads == [(50, bytes, None)]
|
| 137 |
+
meta, data = await stored(ctx)
|
| 138 |
+
assert meta == {"kind": "image", "file_id": ""} and data == photo
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
async def test_the_menu_then_shows_what_is_set_and_offers_to_remove_it(ctx):
|
| 142 |
+
await choose(ctx, "image")
|
| 143 |
+
await send_photo(ctx)
|
| 144 |
+
ev = await say(ctx, OWNER, "/smedia")
|
| 145 |
+
assert "/start shows: <b>an image</b>" in ev.texts[0] and data_of(ev.out[0][1]) == ["sm:image", "sm:gif", "sm:rm", "x:close"]
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
async def test_an_image_sent_as_a_file_is_made_to_fit_without_being_enlarged(ctx):
|
| 149 |
+
await choose(ctx, "image")
|
| 150 |
+
ev = await send_image_file(ctx, data=jpeg((3000, 2000)))
|
| 151 |
+
assert ev.texts == ["✅ Done: /start shows this image from now on."] and ctx.app.queue.waiting == 0 # not renamed
|
| 152 |
+
_meta, data = await stored(ctx)
|
| 153 |
+
img = Image.open(io.BytesIO(data))
|
| 154 |
+
assert img.format == "JPEG" and img.size == (1280, 853)
|
| 155 |
+
await choose(ctx, "image")
|
| 156 |
+
await send_image_file(ctx, mid=52, data=jpeg((400, 300)))
|
| 157 |
+
assert Image.open(io.BytesIO((await stored(ctx))[1])).size == (400, 300) # smaller pictures stay as they are
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
async def test_what_is_not_an_image_does_not_use_up_the_request(ctx):
|
| 161 |
+
await choose(ctx, "image")
|
| 162 |
+
ev = await send_doc(ctx, OWNER, "Episode.mkv", mid=53)
|
| 163 |
+
assert "isn't an image" in ev.texts[0] and ctx.app.queue.waiting == 0 and uploads(ctx) == [] # not renamed either
|
| 164 |
+
ev = await send_gif(ctx)
|
| 165 |
+
assert "isn't an image" in ev.texts[0]
|
| 166 |
+
assert ctx.app.modes[OWNER].kind == "smedia"
|
| 167 |
+
await send_photo(ctx)
|
| 168 |
+
assert len(uploads(ctx)) == 1 and (await stored(ctx))[0]["kind"] == "image"
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
async def test_a_picture_that_cannot_be_read_is_refused(ctx):
|
| 172 |
+
await choose(ctx, "image")
|
| 173 |
+
ev = await send_image_file(ctx, data=b"this is not a picture")
|
| 174 |
+
assert "couldn't read that image" in ev.texts[0] and await stored(ctx) == (None, None) and uploads(ctx) == []
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
async def test_a_download_that_gives_nothing_is_reported(ctx):
|
| 178 |
+
await choose(ctx, "image")
|
| 179 |
+
ev = await send_photo(ctx, data=b"")
|
| 180 |
+
assert "couldn't download that" in ev.texts[0] and "/smedia" in ev.texts[0]
|
| 181 |
+
assert await stored(ctx) == (None, None) and OWNER not in ctx.app.modes
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
async def test_a_file_that_is_too_big_is_refused_and_the_request_stays(ctx):
|
| 185 |
+
await choose(ctx, "image")
|
| 186 |
+
ev = await send_image_file(ctx, size=appmod.START_MEDIA_MAX_BYTES + 1)
|
| 187 |
+
assert "the biggest I can keep is 10.00 MB" in ev.texts[0] and await stored(ctx) == (None, None)
|
| 188 |
+
assert ctx.app.modes[OWNER].kind == "smedia" and ctx.client.downloads == [] # refused before downloading
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
# ---------------------------------------------------------------------------
|
| 192 |
+
# a GIF
|
| 193 |
+
# ---------------------------------------------------------------------------
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
async def test_a_gif_becomes_the_start_gif_and_is_sent_as_an_animation(ctx):
|
| 197 |
+
await choose(ctx, "gif")
|
| 198 |
+
ev = await send_gif(ctx)
|
| 199 |
+
assert ev.texts == ["✅ Done: /start shows this GIF from now on."]
|
| 200 |
+
(entity, file, kw), = uploads(ctx)
|
| 201 |
+
assert entity == OWNER and file.name == "start.mp4" and file.getvalue() == GIF_BYTES and kw["mime_type"] == "video/mp4"
|
| 202 |
+
video = next(a for a in kw["attributes"] if isinstance(a, types.DocumentAttributeVideo))
|
| 203 |
+
assert (video.duration, video.w, video.h, video.supports_streaming) == (3.5, 480, 270, True)
|
| 204 |
+
assert any(isinstance(a, types.DocumentAttributeAnimated) for a in kw["attributes"])
|
| 205 |
+
assert "Auto Rename Bot" in kw["caption"] and kw["buttons"]
|
| 206 |
+
meta, data = await stored(ctx)
|
| 207 |
+
assert meta == {"kind": "gif", "mime": "video/mp4", "width": 480, "height": 270, "duration": 3.5, "file_id": ""} and data == GIF_BYTES
|
| 208 |
+
assert "/start shows: <b>a GIF</b>" in (await say(ctx, OWNER, "/smedia")).texts[0]
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
async def test_what_is_not_a_gif_does_not_use_up_the_request(ctx):
|
| 212 |
+
await choose(ctx, "gif")
|
| 213 |
+
for ev in (await send_photo(ctx), await send_doc(ctx, OWNER, "Episode.mkv", mid=54)):
|
| 214 |
+
assert "isn't a GIF" in ev.texts[0]
|
| 215 |
+
assert ctx.app.queue.waiting == 0 and ctx.app.modes[OWNER].data == {"kind": "gif"} and await stored(ctx) == (None, None)
|
| 216 |
+
await send_gif(ctx)
|
| 217 |
+
assert (await stored(ctx))[0]["kind"] == "gif"
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
async def test_a_gif_nobody_asked_for_is_ignored(ctx):
|
| 221 |
+
ev = await send_gif(ctx)
|
| 222 |
+
assert ev.out == [] and await stored(ctx) == (None, None)
|
| 223 |
+
assert (await send_gif(ctx, sender=STRANGER)).out == []
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
# ---------------------------------------------------------------------------
|
| 227 |
+
# one file per request
|
| 228 |
+
# ---------------------------------------------------------------------------
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
async def test_extra_files_right_after_are_skipped_and_do_not_become_the_thumbnail(ctx):
|
| 232 |
+
await choose(ctx, "image")
|
| 233 |
+
await send_photo(ctx, mid=50)
|
| 234 |
+
extra = await send_photo(ctx, mid=51)
|
| 235 |
+
assert "/smedia handles one file at a time" in extra.texts[0]
|
| 236 |
+
assert (await send_photo(ctx, mid=52)).out == [] # the hint is given once
|
| 237 |
+
assert (await send_gif(ctx)).out == [] and (await send_image_file(ctx, mid=53)).out == []
|
| 238 |
+
assert len(uploads(ctx)) == 1 and not await ctx.db.has_thumb(OWNER) and ctx.app.queue.waiting == 0
|
| 239 |
+
ctx.app._recent[OWNER][1] -= appmod.ONE_SHOT_GRACE + 1
|
| 240 |
+
await send_photo(ctx, mid=54)
|
| 241 |
+
assert await ctx.db.has_thumb(OWNER) # after the grace a photo is a thumbnail again
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
# ---------------------------------------------------------------------------
|
| 245 |
+
# /start
|
| 246 |
+
# ---------------------------------------------------------------------------
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
async def test_start_without_media_is_just_the_menu(ctx):
|
| 250 |
+
ev = await say(ctx, OWNER, "/start")
|
| 251 |
+
assert "Auto Rename Bot" in ev.texts[0] and ctx.client.files == []
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
async def test_start_reuses_the_telegram_file_instead_of_uploading_again(ctx):
|
| 255 |
+
telegram_gives_ids(ctx, "FILEID-1")
|
| 256 |
+
await choose(ctx, "image")
|
| 257 |
+
await send_photo(ctx)
|
| 258 |
+
assert (await stored(ctx))[0]["file_id"] == "FILEID-1" # remembered from the preview
|
| 259 |
+
ev = await say(ctx, ADMIN_CFG, "/start") # every allowed user sees it
|
| 260 |
+
assert ev.out == [] and len(uploads(ctx)) == 1
|
| 261 |
+
(entity, file, kw) = ctx.client.files[-1]
|
| 262 |
+
assert entity == ADMIN_CFG and file == "FILEID-1" and "Auto Rename Bot" in kw["caption"] and "m:rename" in data_of(kw["buttons"])
|
| 263 |
+
assert "m:admin" not in data_of(kw["buttons"])
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
async def test_a_stale_file_id_falls_back_to_the_stored_copy_and_is_replaced(ctx):
|
| 267 |
+
telegram_gives_ids(ctx, "FILEID-1", "FILEID-2")
|
| 268 |
+
await choose(ctx, "gif")
|
| 269 |
+
await send_gif(ctx)
|
| 270 |
+
file_ids_go_stale(ctx)
|
| 271 |
+
ev = await say(ctx, OWNER, "/start")
|
| 272 |
+
assert ev.out == [] # still shown with its picture
|
| 273 |
+
entity, file, kw = ctx.client.files[-1]
|
| 274 |
+
assert isinstance(file, io.BytesIO) and file.getvalue() == GIF_BYTES and kw["mime_type"] == "video/mp4"
|
| 275 |
+
assert (await stored(ctx))[0]["file_id"] == "FILEID-2" # the next /start can use the fresh one
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
async def test_when_nothing_can_be_sent_start_still_shows_the_menu(ctx):
|
| 279 |
+
await choose(ctx, "image")
|
| 280 |
+
await send_photo(ctx)
|
| 281 |
+
ctx.client.fail_send_file = True
|
| 282 |
+
ev = await say(ctx, OWNER, "/start")
|
| 283 |
+
assert "Auto Rename Bot" in ev.texts[0] and data_of(ev.out[0][1])[0] == "m:rename"
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
async def test_damaged_saved_media_never_breaks_start(ctx):
|
| 287 |
+
for meta, data in (
|
| 288 |
+
("not json", "AAAA"), (json.dumps({"kind": "video", "file_id": ""}), "AAAA"), (json.dumps(["kind"]), "AAAA"),
|
| 289 |
+
(json.dumps({"kind": "image", "file_id": ""}), "!!! not base64 !!!"), (json.dumps({"kind": "image", "file_id": ""}), ""),
|
| 290 |
+
):
|
| 291 |
+
await ctx.app._store_start_media(meta, data)
|
| 292 |
+
ev = await say(ctx, OWNER, "/start")
|
| 293 |
+
assert "Auto Rename Bot" in ev.texts[0], meta
|
| 294 |
+
assert ctx.client.files == []
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
async def test_the_menu_fits_in_a_caption(ctx):
|
| 298 |
+
await choose(ctx, "image")
|
| 299 |
+
await send_photo(ctx)
|
| 300 |
+
await say(ctx, OWNER, "/template " + "{title} " * 30)
|
| 301 |
+
await say(ctx, OWNER, "/start")
|
| 302 |
+
assert appmod.visible_len(ctx.client.files[-1][2]["caption"]) < 1024
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
# ---------------------------------------------------------------------------
|
| 306 |
+
# when Telegram refuses it, and removing it
|
| 307 |
+
# ---------------------------------------------------------------------------
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
async def test_a_picture_telegram_refuses_leaves_the_old_one_in_place(ctx):
|
| 311 |
+
await choose(ctx, "image")
|
| 312 |
+
first = jpeg((640, 480))
|
| 313 |
+
await send_photo(ctx, data=first)
|
| 314 |
+
before = await stored(ctx)
|
| 315 |
+
ctx.client.fail_send_file = True
|
| 316 |
+
await choose(ctx, "image")
|
| 317 |
+
ev = await send_photo(ctx, mid=55, data=jpeg((300, 300)))
|
| 318 |
+
assert "wouldn't take that" in ev.texts[0] and "nothing was changed" in ev.texts[0]
|
| 319 |
+
assert await stored(ctx) == before == ({"kind": "image", "file_id": ""}, first)
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
async def test_a_picture_telegram_refuses_is_not_kept_when_there_was_none(ctx):
|
| 323 |
+
ctx.client.fail_send_file = True
|
| 324 |
+
await choose(ctx, "gif")
|
| 325 |
+
ev = await send_gif(ctx)
|
| 326 |
+
assert "wouldn't take that" in ev.texts[0] and await stored(ctx) == (None, None)
|
| 327 |
+
|
| 328 |
+
|
| 329 |
+
async def test_remove_goes_back_to_the_plain_menu(ctx):
|
| 330 |
+
await choose(ctx, "image")
|
| 331 |
+
await send_photo(ctx)
|
| 332 |
+
cb = await click(ctx, OWNER, "sm:rm")
|
| 333 |
+
assert "nothing, just the menu" in cb.edits[-1][0] and data_of(cb.edits[-1][1]) == ["sm:image", "sm:gif", "x:close"]
|
| 334 |
+
assert cb.toasts == ["Start media removed."] and await stored(ctx) == (None, None)
|
| 335 |
+
count = len(ctx.client.files)
|
| 336 |
+
ev = await say(ctx, OWNER, "/start")
|
| 337 |
+
assert "Auto Rename Bot" in ev.texts[0] and len(ctx.client.files) == count
|
tests/test_speed.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import json
|
| 3 |
+
import os
|
| 4 |
+
import shutil
|
| 5 |
+
import stat
|
| 6 |
+
import sys
|
| 7 |
+
import time
|
| 8 |
+
|
| 9 |
+
import pytest
|
| 10 |
+
|
| 11 |
+
from bot import speed
|
| 12 |
+
from bot.speed import SpeedError, SpeedResult
|
| 13 |
+
|
| 14 |
+
CLI_RESULT = {
|
| 15 |
+
"type": "result", "timestamp": "2026-10-21T04:00:00Z",
|
| 16 |
+
"ping": {"jitter": 0.52, "latency": 4.21, "low": 3.9, "high": 5.0},
|
| 17 |
+
"download": {"bandwidth": 31_250_000, "bytes": 400_000_000, "elapsed": 12000},
|
| 18 |
+
"upload": {"bandwidth": 12_500_000, "bytes": 150_000_000, "elapsed": 11000},
|
| 19 |
+
"packetLoss": 0, "isp": "Hetzner Online GmbH",
|
| 20 |
+
"server": {"id": 1234, "host": "speed.example.net", "name": "Example Telecom", "location": "Frankfurt", "country": "Germany"},
|
| 21 |
+
"result": {"id": "abc", "url": "https://www.speedtest.net/result/c/abc"},
|
| 22 |
+
}
|
| 23 |
+
PY_RESULT = {
|
| 24 |
+
"download": 245_310_000.0, "upload": 118_200_000.0, "ping": 11.5, "share": "http://www.speedtest.net/result/1.png",
|
| 25 |
+
"server": {"sponsor": "Example Telecom", "name": "Frankfurt", "country": "Germany"},
|
| 26 |
+
"client": {"isp": "Hetzner Online GmbH", "ip": "1.2.3.4"},
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def fake_binary(directory, name, version_text, body):
|
| 31 |
+
"""An executable `name` that answers --version with ``version_text`` and anything else by running ``body`` (sh)."""
|
| 32 |
+
path = directory / name
|
| 33 |
+
path.write_text(f'#!/bin/sh\nif [ "$1" = "--version" ]; then\n echo "{version_text}"\n exit 0\nfi\n{body}\n')
|
| 34 |
+
path.chmod(path.stat().st_mode | stat.S_IEXEC)
|
| 35 |
+
return path
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
SLEEP = shutil.which("sleep") or "/bin/sleep" # PATH is replaced in these tests, so scripts need absolute paths
|
| 39 |
+
CAT = shutil.which("cat") or "/bin/cat"
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def emit(bindir, obj):
|
| 43 |
+
"""Shell line that prints ``obj`` as one JSON line (it lives in a file, so quotes inside cannot break the script)."""
|
| 44 |
+
path = bindir.parent / f"out{len(list(bindir.parent.glob('out*.json')))}.json"
|
| 45 |
+
path.write_text(json.dumps(obj) + "\n")
|
| 46 |
+
return f"{CAT} {path}"
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@pytest.fixture
|
| 50 |
+
def bindir(tmp_path, monkeypatch):
|
| 51 |
+
d = tmp_path / "bin"
|
| 52 |
+
d.mkdir()
|
| 53 |
+
monkeypatch.setenv("PATH", str(d)) # only our fakes can be found
|
| 54 |
+
monkeypatch.delenv("SPEEDTEST_BIN", raising=False)
|
| 55 |
+
return d
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
# ---------------------------------------------------------------------------
|
| 59 |
+
# parsing
|
| 60 |
+
# ---------------------------------------------------------------------------
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def test_parse_cli_converts_bytes_per_second_to_megabits():
|
| 64 |
+
r = speed.parse_cli(CLI_RESULT)
|
| 65 |
+
assert round(r.download_mbps, 2) == 250.0 and round(r.upload_mbps, 2) == 100.0
|
| 66 |
+
assert r.ping_ms == 4.21 and r.jitter_ms == 0.52 and r.packet_loss == 0
|
| 67 |
+
assert r.isp == "Hetzner Online GmbH" and r.server == "Example Telecom" and r.location == "Frankfurt, Germany"
|
| 68 |
+
assert r.url == "https://www.speedtest.net/result/c/abc" and r.source == "Ookla Speedtest CLI"
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def test_parse_cli_copes_with_missing_details_but_not_missing_speeds():
|
| 72 |
+
r = speed.parse_cli({"download": {"bandwidth": 1_000_000}, "upload": {"bandwidth": 500_000}})
|
| 73 |
+
assert r.ping_ms == 0.0 and r.jitter_ms is None and r.packet_loss is None and r.isp == "" and r.url == "" and r.location == ""
|
| 74 |
+
for broken in ({}, {"download": {"bandwidth": 1}}, {"download": {"bandwidth": "x"}, "upload": {"bandwidth": 1}}):
|
| 75 |
+
with pytest.raises(SpeedError):
|
| 76 |
+
speed.parse_cli(broken)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def test_parse_python_converts_bits_per_second_to_megabits():
|
| 80 |
+
r = speed.parse_python(PY_RESULT)
|
| 81 |
+
assert round(r.download_mbps, 2) == 245.31 and round(r.upload_mbps, 2) == 118.2 and r.ping_ms == 11.5
|
| 82 |
+
assert r.isp == "Hetzner Online GmbH" and r.server == "Example Telecom" and r.location == "Frankfurt, Germany"
|
| 83 |
+
assert r.url.endswith("1.png") and "speedtest-cli" in r.source
|
| 84 |
+
with pytest.raises(SpeedError):
|
| 85 |
+
speed.parse_python({"ping": 3})
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def test_the_result_line_is_picked_out_of_the_cli_output():
|
| 89 |
+
out = "\n".join(["noise", json.dumps({"type": "log", "message": "x"}), json.dumps(CLI_RESULT), "", "{broken json"])
|
| 90 |
+
assert speed._last_result(out)["isp"] == "Hetzner Online GmbH"
|
| 91 |
+
assert speed._last_result("nothing useful") is None
|
| 92 |
+
assert speed._last_result(json.dumps({"type": "log", "message": "x"})) is None
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def test_the_reason_for_a_failure_comes_from_the_log_line():
|
| 96 |
+
out = json.dumps({"type": "log", "level": "error", "message": "Configuration - Couldn't resolve host name"})
|
| 97 |
+
assert speed._log_message(out, "") == "Configuration - Couldn't resolve host name"
|
| 98 |
+
assert speed._log_message("", "segmentation fault") == "segmentation fault"
|
| 99 |
+
assert speed._log_message("", "") == "no output"
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
# ---------------------------------------------------------------------------
|
| 103 |
+
# finding and running the CLI
|
| 104 |
+
# ---------------------------------------------------------------------------
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
async def test_the_official_cli_is_found_by_its_version_text(bindir):
|
| 108 |
+
assert await speed.find_ookla_cli() is None
|
| 109 |
+
fake_binary(bindir, "speedtest", "speedtest-cli 2.1.3\nPython 3.12", "exit 1") # the pip package's script is not the CLI
|
| 110 |
+
assert await speed.find_ookla_cli() is None
|
| 111 |
+
ookla = fake_binary(bindir, "ookla-speedtest", "Speedtest by Ookla 1.2.0.84 (5ae238b) Linux/x86_64", "exit 1")
|
| 112 |
+
assert await speed.find_ookla_cli() == str(ookla)
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
async def test_a_plain_speedtest_binary_is_accepted_when_it_is_the_ookla_one(bindir):
|
| 116 |
+
path = fake_binary(bindir, "speedtest", "Speedtest by Ookla 1.2.0.84", "exit 1")
|
| 117 |
+
assert await speed.find_ookla_cli() == str(path)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
async def test_the_speedtest_bin_variable_wins(bindir, tmp_path, monkeypatch):
|
| 121 |
+
other = tmp_path / "elsewhere"
|
| 122 |
+
other.mkdir()
|
| 123 |
+
chosen = fake_binary(other, "mytest", "Speedtest by Ookla 1.2.0.84", "exit 1")
|
| 124 |
+
fake_binary(bindir, "ookla-speedtest", "Speedtest by Ookla 1.2.0.84", "exit 1")
|
| 125 |
+
monkeypatch.setenv("SPEEDTEST_BIN", str(chosen))
|
| 126 |
+
assert await speed.find_ookla_cli() == str(chosen)
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
async def test_run_cli_returns_the_parsed_result(bindir):
|
| 130 |
+
path = fake_binary(bindir, "ookla-speedtest", "Speedtest by Ookla 1.2.0.84", emit(bindir, CLI_RESULT))
|
| 131 |
+
r = await speed.run_cli(str(path))
|
| 132 |
+
assert round(r.download_mbps) == 250 and r.server == "Example Telecom"
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
async def test_run_cli_passes_the_flags_that_make_it_non_interactive(bindir, tmp_path):
|
| 136 |
+
args_file = tmp_path / "args.txt"
|
| 137 |
+
path = fake_binary(bindir, "ookla-speedtest", "Speedtest by Ookla 1.2.0.84", f'echo "$@" > {args_file}\n{emit(bindir, CLI_RESULT)}')
|
| 138 |
+
await speed.run_cli(str(path))
|
| 139 |
+
args = args_file.read_text().split()
|
| 140 |
+
assert "--accept-license" in args and "--accept-gdpr" in args and "--format=json" in args
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
async def test_run_cli_reports_why_it_failed(bindir):
|
| 144 |
+
log = {"type": "log", "level": "error", "message": "Configuration - Couldn't resolve host name"}
|
| 145 |
+
path = fake_binary(bindir, "ookla-speedtest", "Speedtest by Ookla 1.2.0.84", f"{emit(bindir, log)}\nexit 1")
|
| 146 |
+
with pytest.raises(SpeedError, match="Couldn't resolve host name"):
|
| 147 |
+
await speed.run_cli(str(path))
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
async def test_run_cli_gives_up_after_the_timeout_and_kills_the_process(bindir, tmp_path):
|
| 151 |
+
pid_file = tmp_path / "pid"
|
| 152 |
+
path = fake_binary(bindir, "ookla-speedtest", "Speedtest by Ookla 1.2.0.84", f"echo $$ > {pid_file}\nexec {SLEEP} 30")
|
| 153 |
+
started = time.monotonic()
|
| 154 |
+
with pytest.raises(SpeedError, match="took too long"):
|
| 155 |
+
await speed.run_cli(str(path), timeout=0.5)
|
| 156 |
+
assert time.monotonic() - started < 5
|
| 157 |
+
pid = int(pid_file.read_text())
|
| 158 |
+
await asyncio.sleep(0.2)
|
| 159 |
+
with pytest.raises(ProcessLookupError):
|
| 160 |
+
os.kill(pid, 0)
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
async def test_a_binary_that_cannot_start_is_a_speed_error(tmp_path):
|
| 164 |
+
broken = tmp_path / "broken"
|
| 165 |
+
broken.write_text("not executable")
|
| 166 |
+
with pytest.raises(SpeedError):
|
| 167 |
+
await speed.run_cli(str(broken))
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
# ---------------------------------------------------------------------------
|
| 171 |
+
# run_speedtest: CLI first, Python client second
|
| 172 |
+
# ---------------------------------------------------------------------------
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
async def test_the_cli_is_used_when_it_works(bindir, monkeypatch):
|
| 176 |
+
fake_binary(bindir, "ookla-speedtest", "Speedtest by Ookla 1.2.0.84", emit(bindir, CLI_RESULT))
|
| 177 |
+
|
| 178 |
+
def boom():
|
| 179 |
+
raise AssertionError("the Python client is not needed")
|
| 180 |
+
|
| 181 |
+
monkeypatch.setattr(speed, "_run_python", boom)
|
| 182 |
+
r = await speed.run_speedtest()
|
| 183 |
+
assert r.source == "Ookla Speedtest CLI"
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
async def test_the_python_client_takes_over_when_the_cli_is_missing(bindir, monkeypatch):
|
| 187 |
+
monkeypatch.setattr(speed, "_run_python", lambda: PY_RESULT)
|
| 188 |
+
r = await speed.run_speedtest()
|
| 189 |
+
assert "speedtest-cli" in r.source and round(r.download_mbps) == 245
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
async def test_the_python_client_takes_over_when_the_cli_fails(bindir, monkeypatch):
|
| 193 |
+
fake_binary(bindir, "ookla-speedtest", "Speedtest by Ookla 1.2.0.84", "exit 1")
|
| 194 |
+
monkeypatch.setattr(speed, "_run_python", lambda: PY_RESULT)
|
| 195 |
+
assert "speedtest-cli" in (await speed.run_speedtest()).source
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
async def test_when_both_fail_the_reasons_are_all_given(bindir, monkeypatch):
|
| 199 |
+
fake_binary(bindir, "ookla-speedtest", "Speedtest by Ookla 1.2.0.84", "echo 'server unreachable'\nexit 1")
|
| 200 |
+
|
| 201 |
+
def failing():
|
| 202 |
+
raise SpeedError("speedtest-cli failed: HTTP Error 403: Forbidden")
|
| 203 |
+
|
| 204 |
+
monkeypatch.setattr(speed, "_run_python", failing)
|
| 205 |
+
with pytest.raises(SpeedError) as exc:
|
| 206 |
+
await speed.run_speedtest()
|
| 207 |
+
assert "server unreachable" in str(exc.value) and "403" in str(exc.value)
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
async def test_a_missing_python_client_is_explained(bindir, monkeypatch):
|
| 211 |
+
monkeypatch.setitem(sys.modules, "speedtest", None) # makes `import speedtest` fail, installed or not
|
| 212 |
+
with pytest.raises(SpeedError) as exc:
|
| 213 |
+
await speed.run_speedtest()
|
| 214 |
+
assert "Speedtest CLI is not installed" in str(exc.value) and "speedtest-cli package is not installed" in str(exc.value)
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
async def test_errors_from_the_python_client_are_wrapped(bindir, monkeypatch):
|
| 218 |
+
class FakeSpeedtest:
|
| 219 |
+
def __init__(self, *a, **kw):
|
| 220 |
+
raise RuntimeError("HTTP Error 403: Forbidden")
|
| 221 |
+
|
| 222 |
+
monkeypatch.setitem(sys.modules, "speedtest", type(sys)("speedtest"))
|
| 223 |
+
sys.modules["speedtest"].Speedtest = FakeSpeedtest
|
| 224 |
+
with pytest.raises(SpeedError, match="speedtest-cli failed: HTTP Error 403"):
|
| 225 |
+
await speed.run_speedtest()
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
async def test_a_python_client_that_hangs_is_given_up_on(bindir, monkeypatch):
|
| 229 |
+
monkeypatch.setattr(speed, "CLI_TIMEOUT", 0.1)
|
| 230 |
+
monkeypatch.setattr(speed, "_run_python", lambda: time.sleep(0.4) or PY_RESULT)
|
| 231 |
+
with pytest.raises(SpeedError, match="took too long"):
|
| 232 |
+
await speed.run_speedtest()
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
# ---------------------------------------------------------------------------
|
| 236 |
+
# the message
|
| 237 |
+
# ---------------------------------------------------------------------------
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def test_format_result_lists_every_detail():
|
| 241 |
+
text = speed.format_result(speed.parse_cli(CLI_RESULT))
|
| 242 |
+
assert "SPEEDTEST" in text and "Ookla" in text
|
| 243 |
+
assert "<code>250.00 Mbps</code>" in text and "<code>100.00 Mbps</code>" in text
|
| 244 |
+
assert "<code>4.21 ms</code>" in text and "jitter <code>0.52 ms</code>" in text and "<code>0%</code>" in text
|
| 245 |
+
assert "Hetzner Online GmbH" in text and "Example Telecom · Frankfurt, Germany" in text
|
| 246 |
+
assert '<a href="https://www.speedtest.net/result/c/abc">speedtest.net</a>' in text
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
def test_format_result_skips_what_is_unknown_and_escapes_the_rest():
|
| 250 |
+
r = SpeedResult(download_mbps=1234.5, upload_mbps=2.0, ping_ms=30, isp="A&B <ISP>", server="", location="", url="javascript:alert(1)")
|
| 251 |
+
text = speed.format_result(r)
|
| 252 |
+
assert "<code>1,234.50 Mbps</code>" in text and "A&B <ISP>" in text
|
| 253 |
+
assert "Server" not in text and "Packet loss" not in text and "jitter" not in text and "<a " not in text
|
| 254 |
+
quoted = SpeedResult(download_mbps=1, upload_mbps=1, ping_ms=1, url='https://x.test/a"b')
|
| 255 |
+
assert 'href="https://x.test/a"b"' in speed.format_result(quoted)
|
tests/test_speed_app.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""/speed: the command around the speed test."""
|
| 2 |
+
import asyncio
|
| 3 |
+
|
| 4 |
+
from bot import app as appmod
|
| 5 |
+
from bot import speed
|
| 6 |
+
from bot.speed import SpeedResult
|
| 7 |
+
|
| 8 |
+
from .appkit import ADMIN_CFG, OWNER, STRANGER, say
|
| 9 |
+
|
| 10 |
+
RESULT = SpeedResult(
|
| 11 |
+
download_mbps=250.0, upload_mbps=100.0, ping_ms=4.2, jitter_ms=0.5, packet_loss=0.0, isp="Hetzner",
|
| 12 |
+
server="Example Telecom", location="Frankfurt, Germany", url="https://www.speedtest.net/result/c/abc", source="Ookla Speedtest CLI",
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def serve(monkeypatch, result=RESULT, error=None, gate=None):
|
| 17 |
+
calls = []
|
| 18 |
+
|
| 19 |
+
async def fake():
|
| 20 |
+
calls.append(1)
|
| 21 |
+
if gate is not None:
|
| 22 |
+
await gate.wait()
|
| 23 |
+
if error is not None:
|
| 24 |
+
raise error
|
| 25 |
+
return result
|
| 26 |
+
|
| 27 |
+
monkeypatch.setattr(speed, "run_speedtest", fake)
|
| 28 |
+
return calls
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
async def test_speed_shows_a_status_and_then_the_figures(ctx, monkeypatch):
|
| 32 |
+
calls = serve(monkeypatch)
|
| 33 |
+
ev = await say(ctx, OWNER, "/speed")
|
| 34 |
+
assert ev.texts == ["⚡ Running the speed test… this takes about half a minute."]
|
| 35 |
+
(status, text, kw), = ctx.client.edited
|
| 36 |
+
assert status.id == 1001 and kw["parse_mode"] == "html" and kw["link_preview"] is False
|
| 37 |
+
assert "SPEEDTEST" in text and "250.00 Mbps" in text and "100.00 Mbps" in text and "speedtest.net/result/c/abc" in text
|
| 38 |
+
assert len(calls) == 1 and "may be lower" not in text
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
async def test_a_failed_test_says_why(ctx, monkeypatch):
|
| 42 |
+
serve(monkeypatch, error=speed.SpeedError("the Speedtest CLI is not installed; the speedtest-cli package is not installed <x>"))
|
| 43 |
+
await say(ctx, OWNER, "/speed")
|
| 44 |
+
text = ctx.client.edited[-1][1]
|
| 45 |
+
assert text.startswith("❌ I couldn't run the speed test: the Speedtest CLI is not installed") and "<x>" in text
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
async def test_an_unexpected_crash_is_reported_and_does_not_leave_the_lock_taken(ctx, monkeypatch):
|
| 49 |
+
serve(monkeypatch, error=RuntimeError("boom"))
|
| 50 |
+
await say(ctx, OWNER, "/speed")
|
| 51 |
+
assert "failed unexpectedly" in ctx.client.edited[-1][1]
|
| 52 |
+
serve(monkeypatch)
|
| 53 |
+
await say(ctx, OWNER, "/speed")
|
| 54 |
+
assert "250.00 Mbps" in ctx.client.edited[-1][1]
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
async def test_only_one_test_runs_at_a_time(ctx, monkeypatch):
|
| 58 |
+
gate = asyncio.Event()
|
| 59 |
+
calls = serve(monkeypatch, gate=gate)
|
| 60 |
+
first = asyncio.ensure_future(say(ctx, OWNER, "/speed"))
|
| 61 |
+
await asyncio.sleep(0.05)
|
| 62 |
+
second = await say(ctx, ADMIN_CFG, "/speed")
|
| 63 |
+
assert "already running" in second.texts[0] and len(calls) == 1
|
| 64 |
+
gate.set()
|
| 65 |
+
await first
|
| 66 |
+
assert "250.00 Mbps" in ctx.client.edited[-1][1]
|
| 67 |
+
again = await say(ctx, ADMIN_CFG, "/speed") # free again
|
| 68 |
+
assert again.texts[0].startswith("⚡")
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
async def test_the_result_mentions_a_running_job(ctx, monkeypatch):
|
| 72 |
+
serve(monkeypatch)
|
| 73 |
+
await say(ctx, OWNER, "/speed")
|
| 74 |
+
assert "A rename job was running" not in ctx.client.edited[-1][1]
|
| 75 |
+
monkeypatch.setattr(type(ctx.app.queue), "running", property(lambda self: 1))
|
| 76 |
+
await say(ctx, OWNER, "/speed")
|
| 77 |
+
assert "A rename job was running during the test" in ctx.client.edited[-1][1]
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
async def test_strangers_cannot_run_it_and_it_is_in_the_menu(ctx, monkeypatch):
|
| 81 |
+
calls = serve(monkeypatch)
|
| 82 |
+
assert (await say(ctx, STRANGER, "/speed")).out == [] and calls == []
|
| 83 |
+
assert "speed" in dict(appmod.COMMANDS)
|
tests/test_telegraph.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The Telegraph client, against a local server that answers like api.telegra.ph."""
|
| 2 |
+
import asyncio
|
| 3 |
+
import json
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
|
| 7 |
+
from bot import telegraph
|
| 8 |
+
from bot.telegraph import Telegraph, TelegraphError
|
| 9 |
+
|
| 10 |
+
from .faketelegraph import serve_fake_telegraph
|
| 11 |
+
|
| 12 |
+
NODES = [{"tag": "h4", "children": ["General"]}, {"tag": "pre", "children": ["Format : Matroska"]}]
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@pytest.fixture
|
| 16 |
+
async def server():
|
| 17 |
+
fake = await serve_fake_telegraph()
|
| 18 |
+
yield fake
|
| 19 |
+
await fake.server.close()
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def make(server, store=None, **kw):
|
| 23 |
+
store = {} if store is None else store
|
| 24 |
+
|
| 25 |
+
async def load():
|
| 26 |
+
return store.get("token")
|
| 27 |
+
|
| 28 |
+
async def save(token):
|
| 29 |
+
store["token"] = token
|
| 30 |
+
|
| 31 |
+
return Telegraph(load, save, api=server.url, **kw), store
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
# ---------------------------------------------------------------------------
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
async def test_the_first_page_makes_an_account_and_remembers_its_token(server):
|
| 38 |
+
tg, store = make(server)
|
| 39 |
+
url = await tg.create_page("MediaInfo – Episode 01.mkv", NODES)
|
| 40 |
+
assert url == "https://telegra.ph/Page-1"
|
| 41 |
+
assert server.accounts == 1 and store == {"token": "token1"}
|
| 42 |
+
form = server.pages[0]
|
| 43 |
+
assert form["access_token"] == "token1" and form["title"] == "MediaInfo – Episode 01.mkv"
|
| 44 |
+
assert form["author_name"] == "Auto Rename Bot" and form["return_content"] == "false" and "author_url" not in form
|
| 45 |
+
assert json.loads(form["content"]) == NODES
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
async def test_later_pages_use_the_same_account(server):
|
| 49 |
+
tg, store = make(server)
|
| 50 |
+
await tg.create_page("one", NODES)
|
| 51 |
+
await tg.create_page("two", NODES)
|
| 52 |
+
assert server.accounts == 1 and [p["access_token"] for p in server.pages] == ["token1", "token1"]
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
async def test_a_token_saved_by_an_earlier_run_is_used_without_a_new_account(server):
|
| 56 |
+
server.valid.add("kept")
|
| 57 |
+
tg, store = make(server, {"token": "kept"})
|
| 58 |
+
await tg.create_page("x", NODES)
|
| 59 |
+
assert server.accounts == 0 and server.pages[0]["access_token"] == "kept"
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
async def test_a_rejected_token_is_replaced_once_and_the_page_still_gets_made(server):
|
| 63 |
+
tg, store = make(server, {"token": "stale"})
|
| 64 |
+
url = await tg.create_page("x", NODES)
|
| 65 |
+
assert url == "https://telegra.ph/Page-1"
|
| 66 |
+
assert server.accounts == 1 and store == {"token": "token1"}
|
| 67 |
+
assert [a["access_token"] for a in server.attempts] == ["stale", "token1"]
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
async def test_a_token_that_is_rejected_again_is_an_error(server):
|
| 71 |
+
server.reject_all = True
|
| 72 |
+
tg, store = make(server, {"token": "stale"})
|
| 73 |
+
with pytest.raises(TelegraphError) as err:
|
| 74 |
+
await tg.create_page("x", NODES)
|
| 75 |
+
assert err.value.code == "ACCESS_TOKEN_INVALID" and server.accounts == 1 and len(server.attempts) == 2
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
async def test_the_first_use_from_several_places_makes_one_account(server):
|
| 79 |
+
tg, _store = make(server)
|
| 80 |
+
urls = await asyncio.gather(*(tg.create_page(f"p{i}", NODES) for i in range(6)))
|
| 81 |
+
assert server.accounts == 1 and len(set(urls)) == 6
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
async def test_a_flood_wait_says_how_long(server):
|
| 85 |
+
server.error = "FLOOD_WAIT_7"
|
| 86 |
+
tg, _store = make(server)
|
| 87 |
+
with pytest.raises(TelegraphError) as err:
|
| 88 |
+
await tg.create_page("x", NODES)
|
| 89 |
+
assert str(err.value) == "Telegraph asks to wait 7 seconds" and err.value.code == "FLOOD_WAIT_7"
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
async def test_other_errors_keep_their_code(server):
|
| 93 |
+
server.error = "PAGE_SAVE_FAILED"
|
| 94 |
+
tg, _store = make(server)
|
| 95 |
+
with pytest.raises(TelegraphError) as err:
|
| 96 |
+
await tg.create_page("x", NODES)
|
| 97 |
+
assert str(err.value) == "Telegraph said PAGE_SAVE_FAILED" and err.value.code == "PAGE_SAVE_FAILED"
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
async def test_a_page_over_the_size_limit_is_not_even_sent(server):
|
| 101 |
+
tg, _store = make(server)
|
| 102 |
+
big = [{"tag": "pre", "children": ["x" * 70_000]}]
|
| 103 |
+
with pytest.raises(TelegraphError) as err:
|
| 104 |
+
await tg.create_page("x", big)
|
| 105 |
+
assert err.value.code == "CONTENT_TOO_BIG" and server.attempts == [] and server.accounts == 0
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
async def test_an_answer_that_is_not_json_is_an_error(server):
|
| 109 |
+
server.raw = (502, "<html>Bad gateway</html>")
|
| 110 |
+
tg, _store = make(server)
|
| 111 |
+
with pytest.raises(TelegraphError) as err:
|
| 112 |
+
await tg.create_page("x", NODES)
|
| 113 |
+
assert "couldn't reach Telegraph" in str(err.value)
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
async def test_a_slow_server_times_out(server):
|
| 117 |
+
server.delay = 1.0
|
| 118 |
+
tg, _store = make(server, timeout=0.2)
|
| 119 |
+
with pytest.raises(TelegraphError) as err:
|
| 120 |
+
await tg.create_page("x", NODES)
|
| 121 |
+
assert "too long" in str(err.value)
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
async def test_an_unreachable_server_is_an_error():
|
| 125 |
+
async def load():
|
| 126 |
+
return "token"
|
| 127 |
+
|
| 128 |
+
async def save(token):
|
| 129 |
+
pass
|
| 130 |
+
|
| 131 |
+
tg = Telegraph(load, save, api="http://127.0.0.1:9", timeout=2) # nothing listens on the discard port
|
| 132 |
+
with pytest.raises(TelegraphError) as err:
|
| 133 |
+
await tg.create_page("x", NODES)
|
| 134 |
+
assert "couldn't reach Telegraph" in str(err.value)
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
async def test_the_author_and_its_link_are_sent_and_kept_within_telegraphs_limits(server):
|
| 138 |
+
tg, _store = make(server)
|
| 139 |
+
tg.set_author("Anime Rename Bot", "https://t.me/rename_bot")
|
| 140 |
+
await tg.create_page("x", NODES)
|
| 141 |
+
assert server.pages[0]["author_name"] == "Anime Rename Bot" and server.pages[0]["author_url"] == "https://t.me/rename_bot"
|
| 142 |
+
tg.set_author("N" * 300)
|
| 143 |
+
tg.set_author(" ") # a blank name changes nothing
|
| 144 |
+
await tg.create_page("y", NODES)
|
| 145 |
+
assert server.pages[1]["author_name"] == "N" * 128 and "author_url" not in server.pages[1] # no link was given this time
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
async def test_titles_are_never_empty_or_too_long(server):
|
| 149 |
+
tg, _store = make(server)
|
| 150 |
+
await tg.create_page(" ", NODES)
|
| 151 |
+
await tg.create_page("T" * 400, NODES)
|
| 152 |
+
assert server.pages[0]["title"] == "MediaInfo" and len(server.pages[1]["title"]) == 256
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def test_content_size_counts_bytes_not_characters():
|
| 156 |
+
nodes = [{"tag": "p", "children": ["日本語"]}]
|
| 157 |
+
assert telegraph.content_size(nodes) == len(json.dumps(nodes, ensure_ascii=False, separators=(",", ":")).encode()) > len("日本語")
|
tests/tgfakes.py
CHANGED
|
@@ -14,24 +14,27 @@ from telethon import events
|
|
| 14 |
|
| 15 |
|
| 16 |
def make_file(name: Optional[str] = "Movie.mkv", size: int = 5 * 1024 * 1024, mime: str = "video/x-matroska",
|
| 17 |
-
ext: Optional[str] = None
|
|
|
|
| 18 |
if ext is None:
|
| 19 |
ext = name[name.rfind("."):] if name and "." in name else ""
|
| 20 |
-
return SimpleNamespace(name=name, size=size, mime_type=mime, ext=ext)
|
| 21 |
|
| 22 |
|
| 23 |
class FakeMessage:
|
| 24 |
def __init__(self, text: str = "", *, mid: int = 1, sender: int = 1, file: Any = None, video: bool = False,
|
| 25 |
audio: bool = False, photo: bool = False, sticker: bool = False, gif: bool = False,
|
| 26 |
-
voice: bool = False, video_note: bool = False, fwd: bool = False, out: bool = False
|
|
|
|
| 27 |
self.id = mid
|
| 28 |
self.message = text
|
| 29 |
self.raw_text = text
|
|
|
|
| 30 |
self.out = out
|
| 31 |
self.fwd_from = object() if fwd else None
|
| 32 |
self.sender_id = sender
|
| 33 |
self.file = file
|
| 34 |
-
self.document =
|
| 35 |
self.video = object() if video else None
|
| 36 |
self.audio = object() if audio else None
|
| 37 |
self.photo = object() if photo else None
|
|
@@ -43,8 +46,8 @@ class FakeMessage:
|
|
| 43 |
|
| 44 |
|
| 45 |
def document_message(name: str = "Movie.mkv", size: int = 5 * 1024 * 1024, *, mid: int = 10, caption: str = "",
|
| 46 |
-
video: bool = True, mime: str = "video/x-matroska") -> FakeMessage:
|
| 47 |
-
return FakeMessage(caption, mid=mid, file=make_file(name, size, mime), video=video)
|
| 48 |
|
| 49 |
|
| 50 |
class FakeEvent:
|
|
@@ -135,6 +138,13 @@ class FakeClient:
|
|
| 135 |
self.requests: List[Any] = [] # raw MTProto requests
|
| 136 |
self.bad_entities: set = set()
|
| 137 |
self.fail_send_file = False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
|
| 139 |
def add_event_handler(self, callback: Any, event: Any = None) -> None:
|
| 140 |
self.handlers.append((callback, event))
|
|
@@ -152,9 +162,26 @@ class FakeClient:
|
|
| 152 |
self.sent.append((entity, text, kw))
|
| 153 |
return SimpleNamespace(id=2000 + len(self.sent))
|
| 154 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
async def send_file(self, entity: Any, file: Any, **kw: Any) -> SimpleNamespace:
|
| 156 |
if self.fail_send_file:
|
| 157 |
raise RuntimeError("cannot send file")
|
|
|
|
|
|
|
|
|
|
| 158 |
self.files.append((entity, file, kw))
|
| 159 |
return SimpleNamespace(id=3000 + len(self.files))
|
| 160 |
|
|
|
|
| 14 |
|
| 15 |
|
| 16 |
def make_file(name: Optional[str] = "Movie.mkv", size: int = 5 * 1024 * 1024, mime: str = "video/x-matroska",
|
| 17 |
+
ext: Optional[str] = None, *, width: Optional[int] = None, height: Optional[int] = None,
|
| 18 |
+
duration: Optional[float] = None) -> SimpleNamespace:
|
| 19 |
if ext is None:
|
| 20 |
ext = name[name.rfind("."):] if name and "." in name else ""
|
| 21 |
+
return SimpleNamespace(name=name, size=size, mime_type=mime, ext=ext, width=width, height=height, duration=duration)
|
| 22 |
|
| 23 |
|
| 24 |
class FakeMessage:
|
| 25 |
def __init__(self, text: str = "", *, mid: int = 1, sender: int = 1, file: Any = None, video: bool = False,
|
| 26 |
audio: bool = False, photo: bool = False, sticker: bool = False, gif: bool = False,
|
| 27 |
+
voice: bool = False, video_note: bool = False, fwd: bool = False, out: bool = False,
|
| 28 |
+
entities: Any = None, thumbs: Any = None):
|
| 29 |
self.id = mid
|
| 30 |
self.message = text
|
| 31 |
self.raw_text = text
|
| 32 |
+
self.entities = entities
|
| 33 |
self.out = out
|
| 34 |
self.fwd_from = object() if fwd else None
|
| 35 |
self.sender_id = sender
|
| 36 |
self.file = file
|
| 37 |
+
self.document = SimpleNamespace(thumbs=thumbs) if file is not None else None
|
| 38 |
self.video = object() if video else None
|
| 39 |
self.audio = object() if audio else None
|
| 40 |
self.photo = object() if photo else None
|
|
|
|
| 46 |
|
| 47 |
|
| 48 |
def document_message(name: str = "Movie.mkv", size: int = 5 * 1024 * 1024, *, mid: int = 10, caption: str = "",
|
| 49 |
+
video: bool = True, mime: str = "video/x-matroska", thumbs: Any = None) -> FakeMessage:
|
| 50 |
+
return FakeMessage(caption, mid=mid, file=make_file(name, size, mime), video=video, thumbs=thumbs)
|
| 51 |
|
| 52 |
|
| 53 |
class FakeEvent:
|
|
|
|
| 138 |
self.requests: List[Any] = [] # raw MTProto requests
|
| 139 |
self.bad_entities: set = set()
|
| 140 |
self.fail_send_file = False
|
| 141 |
+
self.messages: dict = {} # id -> message that get_messages() can find (a missing id comes back as None)
|
| 142 |
+
self.deleted: List[tuple] = [] # delete_messages(entity, ids)
|
| 143 |
+
self.file_errors: dict = {} # message id -> exceptions send_file() raises for it, one per call
|
| 144 |
+
self.get_calls: List[tuple] = []
|
| 145 |
+
self.media: dict = {} # message id -> bytes that download_media() returns
|
| 146 |
+
self.thumb_data: dict = {} # message id -> bytes that download_media(thumb=...) returns
|
| 147 |
+
self.downloads: List[tuple] = []
|
| 148 |
|
| 149 |
def add_event_handler(self, callback: Any, event: Any = None) -> None:
|
| 150 |
self.handlers.append((callback, event))
|
|
|
|
| 162 |
self.sent.append((entity, text, kw))
|
| 163 |
return SimpleNamespace(id=2000 + len(self.sent))
|
| 164 |
|
| 165 |
+
async def get_messages(self, entity: Any, ids: Any = None, **kw: Any) -> Any:
|
| 166 |
+
self.get_calls.append((entity, ids))
|
| 167 |
+
if isinstance(ids, (list, tuple)):
|
| 168 |
+
return [self.messages.get(i) for i in ids]
|
| 169 |
+
return self.messages.get(ids)
|
| 170 |
+
|
| 171 |
+
async def download_media(self, message: Any, file: Any = None, thumb: Any = None, **kw: Any) -> Any:
|
| 172 |
+
self.downloads.append((getattr(message, "id", None), file, thumb))
|
| 173 |
+
source = self.thumb_data if thumb is not None else self.media
|
| 174 |
+
return source.get(getattr(message, "id", None))
|
| 175 |
+
|
| 176 |
+
async def delete_messages(self, entity: Any, ids: Any, **kw: Any) -> None:
|
| 177 |
+
self.deleted.append((entity, list(ids) if isinstance(ids, (list, tuple)) else [ids]))
|
| 178 |
+
|
| 179 |
async def send_file(self, entity: Any, file: Any, **kw: Any) -> SimpleNamespace:
|
| 180 |
if self.fail_send_file:
|
| 181 |
raise RuntimeError("cannot send file")
|
| 182 |
+
errors_for = self.file_errors.get(getattr(file, "id", None))
|
| 183 |
+
if errors_for:
|
| 184 |
+
raise errors_for.pop(0)
|
| 185 |
self.files.append((entity, file, kw))
|
| 186 |
return SimpleNamespace(id=3000 + len(self.files))
|
| 187 |
|