github-actions[bot] commited on
Commit
58f00c9
·
1 Parent(s): 9399f83

Automated sync from GitHub

Browse files
.dockerignore ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ .git
2
+ .env
3
+ *.db
4
+ __pycache__
5
+ *.pyc
6
+ .pytest_cache
7
+ tests
8
+ venv
9
+ .venv
.env.example ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ---- required ---------------------------------------------------------------
2
+ API_ID=1234567
3
+ API_HASH=0123456789abcdef0123456789abcdef
4
+ BOT_TOKEN=123456789:replace-with-your-bot-token
5
+ OWNER_ID=123456789
6
+
7
+ # ---- strongly recommended -----------------------------------------------------
8
+ # Hugging Face has no persistent disk. Point this at a free Postgres (Neon, Supabase, ...)
9
+ # so settings, thumbnails, admins and the login session survive restarts.
10
+ # Without it the bot uses a local SQLite file that is wiped on every restart.
11
+ DATABASE_URL=postgresql://user:password@host/dbname?sslmode=require
12
+
13
+ # ---- optional -------------------------------------------------------------------
14
+ # Extra allowed users (comma separated). The owner can also use /addadmin and /deladmin.
15
+ ADMIN_IDS=
16
+ # Numeric chat id of a channel/group where the bot posts start-up, job and error notices.
17
+ LOG_CHANNEL=
18
+ # Banner for /start: an https image URL or a file path.
19
+ START_IMAGE=
20
+ # Optional Telethon StringSession to log in with (otherwise stored in the database).
21
+ SESSION_STRING=
22
+
23
+ # Parallel MTProto connections per transfer (1-16).
24
+ TRANSFER_CONNECTIONS=8
25
+ # Files processed at the same time (1-4).
26
+ MAX_CONCURRENT_JOBS=1
27
+ # Largest file accepted, in MB (Telegram's upload limit for bots is 2000).
28
+ MAX_FILE_MB=2000
29
+ # 1 = parallel chunked transfers (falls back to the standard path on any error), 0 = standard only.
30
+ FAST_TRANSFER=1
31
+ # Seconds between progress-message edits (minimum 2).
32
+ PROGRESS_INTERVAL=4
33
+
34
+ # Set by the Dockerfile; only change for local runs.
35
+ # PORT=7860
36
+ # TEMP_DIR=/tmp/rename
.gitattributes CHANGED
@@ -1,35 +1,8 @@
1
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
- *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
7
- *.gz filter=lfs diff=lfs merge=lfs -text
8
- *.h5 filter=lfs diff=lfs merge=lfs -text
9
- *.joblib filter=lfs diff=lfs merge=lfs -text
10
- *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
- *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
- *.model filter=lfs diff=lfs merge=lfs -text
13
- *.msgpack filter=lfs diff=lfs merge=lfs -text
14
- *.npy filter=lfs diff=lfs merge=lfs -text
15
- *.npz filter=lfs diff=lfs merge=lfs -text
16
- *.onnx filter=lfs diff=lfs merge=lfs -text
17
- *.ot filter=lfs diff=lfs merge=lfs -text
18
- *.parquet filter=lfs diff=lfs merge=lfs -text
19
- *.pb filter=lfs diff=lfs merge=lfs -text
20
- *.pickle filter=lfs diff=lfs merge=lfs -text
21
- *.pkl filter=lfs diff=lfs merge=lfs -text
22
- *.pt filter=lfs diff=lfs merge=lfs -text
23
- *.pth filter=lfs diff=lfs merge=lfs -text
24
- *.rar filter=lfs diff=lfs merge=lfs -text
25
- *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
30
- *.tgz filter=lfs diff=lfs merge=lfs -text
31
- *.wasm filter=lfs diff=lfs merge=lfs -text
32
- *.xz filter=lfs diff=lfs merge=lfs -text
33
- *.zip filter=lfs diff=lfs merge=lfs -text
34
- *.zst filter=lfs diff=lfs merge=lfs -text
35
- *tfevents* filter=lfs diff=lfs merge=lfs -text
 
1
+ *.webp filter=lfs diff=lfs merge=lfs -text
2
+ *.mp4 filter=lfs diff=lfs merge=lfs -text
3
+ *.mp3 filter=lfs diff=lfs merge=lfs -text
4
+ *.jpg filter=lfs diff=lfs merge=lfs -text
5
+ *.jpeg filter=lfs diff=lfs merge=lfs -text
6
+ *.png filter=lfs diff=lfs merge=lfs -text
7
+ *.ttf filter=lfs diff=lfs merge=lfs -text
8
+ *.otf filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.gitignore ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ .env
2
+ *.db
3
+ __pycache__/
4
+ *.pyc
5
+ .pytest_cache/
6
+ venv/
7
+ .venv/
Dockerfile ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
11
+
12
+ ENV HOME=/home/user \
13
+ PATH=/home/user/.local/bin:$PATH \
14
+ PYTHONUNBUFFERED=1 \
15
+ PYTHONDONTWRITEBYTECODE=1 \
16
+ PORT=7860 \
17
+ TEMP_DIR=/tmp/rename
18
+
19
+ WORKDIR /home/user/app
20
+
21
+ COPY --chown=user requirements.txt .
22
+ RUN pip install --no-cache-dir -r requirements.txt
23
+
24
+ COPY --chown=user bot ./bot
25
+
26
+ EXPOSE 7860
27
+ CMD ["python", "-m", "bot"]
bot/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ __version__ = "1.0.0"
bot/__main__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from .main import main
2
+
3
+ main()
bot/app.py ADDED
@@ -0,0 +1,774 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Telegram handlers: commands, inline menus and the job entry points."""
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 .parser import build_manual_name, compute_new_name
24
+ from .pipeline import Renamer, original_name_of, source_ext
25
+ from .queue import Job, JobQueue
26
+ from .settings_ops import META_COMMANDS, apply_setting, parse_channel, preview_template
27
+ from .utils import clip, esc, human_size
28
+
29
+ log = logging.getLogger(__name__)
30
+
31
+ COMMANDS = [
32
+ ("start", "Open the main menu"),
33
+ ("help", "How to use the bot"),
34
+ ("rename", "Reply to a file with a new name"),
35
+ ("mode", "Choose Filename or Caption mode"),
36
+ ("template", "Set the filename template"),
37
+ ("caption", "Set the caption template"),
38
+ ("filetype", "Choose how files are sent"),
39
+ ("metadata", "Metadata and track settings"),
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
+ TRACKS_PROBE_BYTES = 12 * 1024 * 1024
59
+ TRACKS_FULL_LIMIT = 700 * 1024 * 1024
60
+
61
+
62
+ @dataclass
63
+ class Pending:
64
+ kind: str
65
+ chat_id: int
66
+ msg_id: int
67
+ back: str
68
+ is_photo: bool
69
+ expires: float
70
+
71
+
72
+ def cmd_pattern(name: str) -> "re.Pattern[str]":
73
+ return re.compile(rf"(?is)^/{name}(?:@\w+)?(?:\s+(.*))?$")
74
+
75
+
76
+ def visible_len(html_text: str) -> int:
77
+ return len(html.unescape(re.sub(r"<[^>]+>", "", html_text)))
78
+
79
+
80
+ def plain(html_text: str) -> str:
81
+ return html.unescape(re.sub(r"<[^>]+>", "", html_text))
82
+
83
+
84
+ def is_renameable(message: Any) -> bool:
85
+ return (
86
+ getattr(message, "document", None) is not None
87
+ and not (message.sticker or message.gif or message.voice or message.video_note)
88
+ )
89
+
90
+
91
+ class App:
92
+ PENDING_TTL = 300
93
+
94
+ def __init__(self, client: Any, cfg: Config, db: Database):
95
+ self.client = client
96
+ self.cfg = cfg
97
+ self.db = db
98
+ self.admins: Set[int] = set(cfg.admin_ids)
99
+ self.pending: Dict[int, Pending] = {}
100
+ self.renamer = Renamer(client, cfg, db, self._log_send)
101
+ self.queue = JobQueue(self.renamer.process, workers=cfg.max_jobs, on_cancelled=self._on_job_cancelled)
102
+ self._warned: Set[int] = set()
103
+
104
+ # ------------------------------------------------------------------ auth
105
+ def is_owner(self, uid: Optional[int]) -> bool:
106
+ return uid is not None and uid == self.cfg.owner_id
107
+
108
+ def is_allowed(self, uid: Optional[int]) -> bool:
109
+ return uid is not None and (uid == self.cfg.owner_id or uid in self.admins)
110
+
111
+ # ----------------------------------------------------------------- setup
112
+ async def setup(self) -> None:
113
+ self.admins |= set(await self.db.list_admins())
114
+ self.client.parse_mode = "html"
115
+ self._register()
116
+ await self._set_commands()
117
+ if self.cfg.log_channel:
118
+ try:
119
+ await self.client.get_input_entity(self.cfg.log_channel)
120
+ except Exception as exc: # noqa: BLE001
121
+ log.warning("cannot access LOG_CHANNEL %s: %s (add the bot there as an admin)", self.cfg.log_channel, exc)
122
+ self.queue.start()
123
+
124
+ async def shutdown(self) -> None:
125
+ await self.queue.stop()
126
+
127
+ async def _set_commands(self) -> None:
128
+ try:
129
+ await self.client(
130
+ functions.bots.SetBotCommandsRequest(
131
+ scope=types.BotCommandScopeDefault(),
132
+ lang_code="",
133
+ commands=[types.BotCommand(command=c, description=d) for c, d in COMMANDS],
134
+ )
135
+ )
136
+ except Exception as exc: # noqa: BLE001
137
+ log.warning("could not set the bot command menu: %s", exc)
138
+
139
+ async def _log_send(self, text: str) -> None:
140
+ if self.cfg.log_channel:
141
+ await self.client.send_message(self.cfg.log_channel, text, parse_mode="html", link_preview=False)
142
+
143
+ # --------------------------------------------------------------- plumbing
144
+ def _guard(self, fn: Callable[[Any], Awaitable[None]], *, owner_only: bool = False):
145
+ async def wrapper(event: Any) -> None:
146
+ uid = event.sender_id
147
+ allowed = self.is_owner(uid) if owner_only else self.is_allowed(uid)
148
+ if not allowed:
149
+ if owner_only and self.is_allowed(uid): # a known user trying an owner command
150
+ try:
151
+ if hasattr(event, "data"):
152
+ await event.answer("Owner only.", alert=True)
153
+ else:
154
+ await event.respond("⛔ Only the owner can do that.")
155
+ except Exception: # noqa: BLE001
156
+ pass
157
+ else:
158
+ await self._stranger(uid)
159
+ return
160
+ try:
161
+ await fn(event)
162
+ except events.StopPropagation:
163
+ raise
164
+ except errors.MessageNotModifiedError:
165
+ pass
166
+ except Exception: # noqa: BLE001
167
+ log.exception("handler %s failed", getattr(fn, "__name__", fn))
168
+ try:
169
+ if hasattr(event, "data"):
170
+ await event.answer("⚠️ Something went wrong.", alert=True)
171
+ else:
172
+ await event.respond("⚠️ Something went wrong. Please try again.")
173
+ except Exception: # noqa: BLE001
174
+ pass
175
+
176
+ return wrapper
177
+
178
+ async def _stranger(self, uid: Optional[int]) -> None:
179
+ if uid is None or uid in self._warned:
180
+ return
181
+ self._warned.add(uid)
182
+ log.info("ignored unauthorised user %s", uid)
183
+ try:
184
+ await self._log_send(f"🚫 Ignored a message from <code>{uid}</code>. Use /addadmin {uid} to allow them.")
185
+ except Exception: # noqa: BLE001
186
+ pass
187
+
188
+ def _register(self) -> None:
189
+ add = self.client.add_event_handler
190
+ for name, handler in self._command_table().items():
191
+ add(
192
+ self._guard(handler, owner_only=name in OWNER_COMMANDS),
193
+ events.NewMessage(incoming=True, pattern=cmd_pattern(name), func=lambda e: e.is_private),
194
+ )
195
+ add(self._guard(self.on_media), events.NewMessage(incoming=True, func=lambda e: e.is_private and is_renameable(e.message)))
196
+ add(self._guard(self.on_photo), events.NewMessage(incoming=True, func=lambda e: e.is_private and e.message.photo is not None))
197
+ add(self._guard(self.on_text), events.NewMessage(incoming=True, func=self._wants_text))
198
+ add(self._guard(self.on_callback), events.CallbackQuery())
199
+ add(self._on_stranger, events.NewMessage(incoming=True, func=lambda e: e.is_private and not self.is_allowed(e.sender_id)))
200
+
201
+ async def _on_stranger(self, event: Any) -> None:
202
+ await self._stranger(event.sender_id)
203
+
204
+ def _wants_text(self, e: Any) -> bool:
205
+ text = e.raw_text or ""
206
+ return bool(e.is_private and text and not text.startswith("/") and e.sender_id in self.pending and not e.message.media)
207
+
208
+ def _command_table(self) -> Dict[str, Callable[[Any], Awaitable[None]]]:
209
+ table: Dict[str, Callable[[Any], Awaitable[None]]] = {
210
+ "start": self.cmd_start, "help": self.cmd_help, "rename": self.cmd_rename, "mode": self.cmd_mode,
211
+ "template": self.cmd_template, "caption": self.cmd_caption, "filetype": self.cmd_filetype,
212
+ "metadata": self.cmd_metadata, "keepaudio": self._cmd_keep("keep_audio", "Keep Audio", "/keepaudio English Japanese"),
213
+ "keepsubtitle": self._cmd_keep("keep_subtitle", "Keep Subtitle", "/keepsubtitle English"),
214
+ "tracks": self.cmd_tracks, "thumb": self.cmd_thumb, "delthumb": self.cmd_delthumb,
215
+ "removewords": self.cmd_remove, "setchannel": self.cmd_setchannel, "queue": self.cmd_queue,
216
+ "cancel": self.cmd_cancel, "status": self.cmd_status, "stats": self.cmd_stats,
217
+ "addadmin": self.cmd_addadmin, "deladmin": self.cmd_deladmin, "admins": self.cmd_admins,
218
+ }
219
+ for command, kind in META_COMMANDS.items():
220
+ table[command] = self._cmd_meta_field(command, kind)
221
+ return table
222
+
223
+ @staticmethod
224
+ def _args(event: Any) -> str:
225
+ return (event.pattern_match.group(1) or "").strip() if event.pattern_match else ""
226
+
227
+ async def _reply(self, event: Any, text: str, rows: Optional[List[panels.Row]] = None) -> Any:
228
+ buttons = panels.to_buttons(rows) if rows else None
229
+ return await event.respond(text, buttons=buttons, parse_mode="html", link_preview=False)
230
+
231
+ # ----------------------------------------------------------------- panels
232
+ async def panel(self, uid: int, name: str) -> panels.Panel:
233
+ s = await self.db.get_settings(uid)
234
+ if name == "home":
235
+ return panels.home(s, owner=self.is_owner(uid))
236
+ if name == "rename":
237
+ return panels.rename(s)
238
+ if name == "cap":
239
+ return panels.caption(s, await self.db.has_thumb(uid))
240
+ if name == "meta":
241
+ return panels.metadata(s)
242
+ if name == "tool":
243
+ return panels.tool(s)
244
+ if name == "ftype":
245
+ return panels.filetype(s)
246
+ if name == "help":
247
+ return panels.help_panel()
248
+ if name == "admin":
249
+ return panels.admin(
250
+ self.cfg.owner_id, sorted(self.admins), self.queue.running, self.queue.waiting, self.cfg.log_channel
251
+ )
252
+ raise KeyError(name)
253
+
254
+ @staticmethod
255
+ async def _remove_message(event: Any) -> None:
256
+ try:
257
+ await event.delete()
258
+ except Exception: # noqa: BLE001 - Telegram refuses to delete messages older than 48 hours
259
+ try:
260
+ await event.edit("Closed.", buttons=None)
261
+ except Exception: # noqa: BLE001
262
+ pass
263
+
264
+ async def _edit_panel(self, event: Any, text: str, rows: List[panels.Row]) -> None:
265
+ try:
266
+ await event.edit(text, buttons=panels.to_buttons(rows), parse_mode="html")
267
+ except errors.MessageNotModifiedError:
268
+ pass
269
+
270
+ # --------------------------------------------------------------- commands
271
+ async def cmd_start(self, event: Any) -> None:
272
+ self.pending.pop(event.sender_id, None)
273
+ text, rows = await self.panel(event.sender_id, "home")
274
+ if self.cfg.start_image:
275
+ try:
276
+ await self.client.send_file(
277
+ event.chat_id, self.cfg.start_image, caption=text, buttons=panels.to_buttons(rows), parse_mode="html"
278
+ )
279
+ return
280
+ except Exception as exc: # noqa: BLE001
281
+ log.warning("could not send START_IMAGE (%s); sending text only", exc)
282
+ await self._reply(event, text, rows)
283
+
284
+ async def cmd_help(self, event: Any) -> None:
285
+ text, _ = panels.help_panel()
286
+ await self._reply(event, text, [[("✖ Close", "x:close")]])
287
+
288
+ async def cmd_mode(self, event: Any) -> None:
289
+ arg = self._args(event).lower()
290
+ uid = event.sender_id
291
+ if arg in ("filename", "caption"):
292
+ await self.db.update_settings(uid, mode=arg)
293
+ await self._reply(event, f"✅ Rename mode: <code>{arg.upper()}</code>")
294
+ return
295
+ text, rows = await self.panel(uid, "rename")
296
+ await self._reply(event, text, rows)
297
+
298
+ async def cmd_filetype(self, event: Any) -> None:
299
+ arg = self._args(event).lower()
300
+ uid = event.sender_id
301
+ aliases = {"original": "same", "file": "document", "doc": "document"}
302
+ arg = aliases.get(arg, arg)
303
+ if arg in ("same", "document", "video", "audio"):
304
+ await self.db.update_settings(uid, file_type=arg)
305
+ label = "same as original" if arg == "same" else arg
306
+ await self._reply(event, f"✅ Files will be sent as: <code>{label}</code>")
307
+ return
308
+ text, rows = await self.panel(uid, "ftype")
309
+ await self._reply(event, text, rows)
310
+
311
+ async def cmd_metadata(self, event: Any) -> None:
312
+ arg = self._args(event).lower()
313
+ uid = event.sender_id
314
+ if arg in ("on", "off"):
315
+ await self.db.update_settings(uid, metadata_on=(arg == "on"))
316
+ await self._reply(event, f"✅ Metadata is now <b>{arg.upper()}</b>")
317
+ return
318
+ text, rows = await self.panel(uid, "meta")
319
+ await self._reply(event, text, rows)
320
+
321
+ async def cmd_template(self, event: Any) -> None:
322
+ arg = self._args(event)
323
+ uid = event.sender_id
324
+ if arg:
325
+ _ok, msg = await apply_setting(self.db, uid, "template", arg)
326
+ await self._reply(event, msg)
327
+ return
328
+ s = await self.db.get_settings(uid)
329
+ await self._reply(
330
+ event,
331
+ f"<b>Current template:</b>\n<code>{esc(s['template'])}</code>\n\n{preview_template(s['template'])}\n\n"
332
+ "Variables: <code>{title} {season} {episode} {quality} {year} {codec} {filename} {ext}</code>\n"
333
+ "A [bracket] group is dropped when a variable inside it is empty.\n\n"
334
+ "Change it: <code>/template [S{season}E{episode}] {title} [{quality}]</code>",
335
+ )
336
+
337
+ async def cmd_caption(self, event: Any) -> None:
338
+ arg = self._args(event)
339
+ uid = event.sender_id
340
+ if arg:
341
+ _ok, msg = await apply_setting(self.db, uid, "caption", arg)
342
+ await self._reply(event, msg)
343
+ return
344
+ s = await self.db.get_settings(uid)
345
+ await self._reply(
346
+ event,
347
+ f"<b>Current caption template:</b>\n<code>{esc(s['caption'])}</code>\n\n"
348
+ "Variables: <code>{filename} {original} {title} {season} {episode} {quality} {year} {codec} {size} {duration}</code>\n"
349
+ "Change it: <code>/caption &lt;b&gt;{filename}&lt;/b&gt;</code> · restore: <code>/caption reset</code>",
350
+ )
351
+
352
+ async def cmd_remove(self, event: Any) -> None:
353
+ arg = self._args(event)
354
+ uid = event.sender_id
355
+ if arg:
356
+ _ok, msg = await apply_setting(self.db, uid, "remove", arg)
357
+ await self._reply(event, msg)
358
+ return
359
+ s = await self.db.get_settings(uid)
360
+ words = ", ".join(s["remove_words"]) or "none"
361
+ await self._reply(event, f"<b>Words stripped from names:</b> {esc(words)}\n\nSet: <code>/removewords @Channel, [Group]</code> · clear: <code>/removewords off</code>")
362
+
363
+ def _cmd_meta_field(self, command: str, kind: str) -> Callable[[Any], Awaitable[None]]:
364
+ async def handler(event: Any) -> None:
365
+ arg = self._args(event)
366
+ if not arg:
367
+ s = await self.db.get_settings(event.sender_id)
368
+ field = kind[3:]
369
+ current = s["meta"].get(field) or "—"
370
+ await self._reply(event, f"<b>{field.title()}:</b> <code>{esc(current)}</code>\n\nSet it: <code>/{command} value</code> · clear: <code>/{command} off</code>")
371
+ return
372
+ _ok, msg = await apply_setting(self.db, event.sender_id, kind, arg)
373
+ await self._reply(event, msg)
374
+
375
+ handler.__name__ = f"cmd_{command}"
376
+ return handler
377
+
378
+ def _cmd_keep(self, kind: str, label: str, example: str) -> Callable[[Any], Awaitable[None]]:
379
+ async def handler(event: Any) -> None:
380
+ arg = self._args(event)
381
+ uid = event.sender_id
382
+ if not arg:
383
+ s = await self.db.get_settings(uid)
384
+ await self._reply(
385
+ event,
386
+ f"<b>{label}:</b> {esc(panels.fmt_tokens(s[kind]))}\n\n"
387
+ f"Set it with language names or track numbers:\n<code>{example}</code>\n<code>{example.split()[0]} 2 1</code>\n"
388
+ f"Only the listed tracks are kept, in that order. <code>{example.split()[0]} off</code> keeps everything.\n"
389
+ "Reply to a file with /tracks to see its track numbers.",
390
+ )
391
+ return
392
+ _ok, msg = await apply_setting(self.db, uid, kind, arg)
393
+ await self._reply(event, msg)
394
+
395
+ handler.__name__ = f"cmd_{kind}"
396
+ return handler
397
+
398
+ async def cmd_setchannel(self, event: Any) -> None:
399
+ arg = self._args(event)
400
+ uid = event.sender_id
401
+ if not arg:
402
+ s = await self.db.get_settings(uid)
403
+ current = f"<code>{s['target_channel']}</code>" if s["target_channel"] else "not set (files come back to this chat)"
404
+ await self._reply(event, f"<b>Send renamed files to:</b> {current}\n\nSet: <code>/setchannel -1001234567890</code> (add me to the channel as an admin first) · clear: <code>/setchannel off</code>")
405
+ return
406
+ ok, cid, err = parse_channel(arg)
407
+ if not ok:
408
+ await self._reply(event, err)
409
+ return
410
+ if cid is not None:
411
+ try:
412
+ await self.client.get_input_entity(cid)
413
+ except Exception: # noqa: BLE001
414
+ await self._reply(event, "❌ I can't access that chat. Add me to the channel as an admin that can post, then send the id again.")
415
+ return
416
+ await self.db.update_settings(uid, target_channel=cid)
417
+ await self._reply(event, f"✅ Renamed files will go to <code>{cid}</code>." if cid else "✅ Renamed files will come back to this chat.")
418
+
419
+ async def cmd_thumb(self, event: Any) -> None:
420
+ data = await self.db.get_thumb(event.sender_id)
421
+ if not data:
422
+ await self._reply(event, "You have no thumbnail. Send me a photo to set one.")
423
+ return
424
+ await self._send_thumb(event.chat_id, data)
425
+
426
+ async def _send_thumb(self, chat_id: int, data: bytes) -> None:
427
+ bio = io.BytesIO(data)
428
+ bio.name = "thumbnail.jpg"
429
+ await self.client.send_file(
430
+ chat_id, bio, caption="Your thumbnail", buttons=panels.to_buttons([[("🗑 Delete", "th:rm"), ("✖ Close", "x:close")]])
431
+ )
432
+
433
+ async def cmd_delthumb(self, event: Any) -> None:
434
+ if await self.db.has_thumb(event.sender_id):
435
+ await self.db.set_thumb(event.sender_id, None)
436
+ await self._reply(event, "🗑 Thumbnail deleted.")
437
+ else:
438
+ await self._reply(event, "You have no thumbnail.")
439
+
440
+ async def on_photo(self, event: Any) -> None:
441
+ raw = await event.download_media(bytes)
442
+ if not raw:
443
+ await self._reply(event, "❌ I couldn't download that photo.")
444
+ return
445
+ try:
446
+ thumb = await asyncio.to_thread(media.prepare_thumb, raw)
447
+ except Exception: # noqa: BLE001
448
+ await self._reply(event, "❌ I couldn't read that image.")
449
+ return
450
+ await self.db.set_thumb(event.sender_id, thumb)
451
+ await self._reply(event, "✅ Thumbnail saved. It will be used for the files I send back.", [[("🗑 Delete", "th:rm"), ("✖ Close", "x:close")]])
452
+
453
+ # ------------------------------------------------------------ rename flow
454
+ async def cmd_rename(self, event: Any) -> None:
455
+ arg = self._args(event)
456
+ reply = await event.get_reply_message()
457
+ if reply is None or not is_renameable(reply) or not arg:
458
+ await self._reply(event, "Reply to a file with <code>/rename New Name</code>.\nThe extension always stays the same.")
459
+ return
460
+ await self.enqueue(event, reply, manual=arg)
461
+
462
+ async def on_media(self, event: Any) -> None:
463
+ await self.enqueue(event, event.message)
464
+
465
+ async def enqueue(self, event: Any, msg: Any, manual: Optional[str] = None) -> None:
466
+ uid = event.sender_id
467
+ size = int(msg.file.size or 0) if msg.file is not None else 0
468
+ if size > self.cfg.max_file_bytes:
469
+ await self._reply(event, f"❌ That file is {human_size(size)}; the limit is {human_size(self.cfg.max_file_bytes)}.")
470
+ return
471
+ s = await self.db.get_settings(uid)
472
+ orig = original_name_of(msg)
473
+ ext = source_ext(msg, orig)
474
+ if manual:
475
+ new_name = build_manual_name(manual, ext)
476
+ else:
477
+ new_name, _info = compute_new_name(
478
+ template=s["template"], mode=s["mode"], original_name=orig, original_ext=ext,
479
+ caption=msg.raw_text or "", remove_words=s["remove_words"],
480
+ )
481
+ job = Job(
482
+ id=self.queue.new_id(), user_id=uid, chat_id=event.chat_id, msg_id=msg.id,
483
+ file_name=orig, size=size, new_name=new_name, manual=bool(manual),
484
+ )
485
+ ahead = self.queue.running + self.queue.waiting
486
+ where = "starting soon" if ahead == 0 else f"{ahead} ahead of it"
487
+ job.status_msg = await event.reply(
488
+ f"📥 <b>Queued</b> ({where})\n<code>{esc(clip(new_name, 120))}</code>",
489
+ buttons=panels.to_buttons([[("✖ Cancel", f"job:x:{job.id}")]]),
490
+ parse_mode="html",
491
+ )
492
+ self.queue.add(job)
493
+
494
+ async def _on_job_cancelled(self, job: Job) -> None:
495
+ if job.status_msg is not None:
496
+ await self.client.edit_message(
497
+ job.status_msg, f"❌ <b>Cancelled</b>\n<code>{esc(clip(job.new_name, 120))}</code>", buttons=None, parse_mode="html"
498
+ )
499
+
500
+ async def cmd_queue(self, event: Any) -> None:
501
+ uid = event.sender_id
502
+ jobs = sorted(self.queue.jobs.values(), key=lambda j: j.id) if self.is_owner(uid) else self.queue.for_user(uid)
503
+ if not jobs:
504
+ await self._reply(event, "Nothing in the queue.")
505
+ return
506
+ lines = ["<b>Queue</b>"]
507
+ for j in jobs[:20]:
508
+ icon = "⚙️" if j.state == "running" else "⏳"
509
+ who = f" · <code>{j.user_id}</code>" if self.is_owner(uid) else ""
510
+ lines.append(f"{icon} #{j.id}{who} · <code>{esc(clip(j.new_name, 60))}</code>")
511
+ if len(jobs) > 20:
512
+ lines.append(f"… and {len(jobs) - 20} more")
513
+ lines.append("\n/cancel — cancel yours · <code>/cancel 3</code> — cancel job #3")
514
+ await self._reply(event, "\n".join(lines))
515
+
516
+ async def cmd_cancel(self, event: Any) -> None:
517
+ uid = event.sender_id
518
+ arg = self._args(event).lower()
519
+ if not arg and self.pending.pop(uid, None) is not None:
520
+ await self._reply(event, "Cancelled — nothing was changed.")
521
+ return
522
+ owner_scope = None if self.is_owner(uid) else uid
523
+ if arg.isdigit():
524
+ ok = self.queue.cancel(int(arg), user_id=owner_scope)
525
+ await self._reply(event, "🛑 Cancelling…" if ok else "I couldn't find an active job with that number.")
526
+ return
527
+ count = self.queue.cancel_all() if (arg == "all" and self.is_owner(uid)) else self.queue.cancel_user(uid)
528
+ await self._reply(event, f"🛑 Cancelling {count} job(s)…" if count else "You have no active jobs.")
529
+
530
+ # ------------------------------------------------------------------ tracks
531
+ async def cmd_tracks(self, event: Any) -> None:
532
+ reply = await event.get_reply_message()
533
+ if reply is None or not is_renameable(reply):
534
+ await self._reply(event, "Reply to a video or audio file with /tracks to list its audio and subtitle tracks.")
535
+ return
536
+ status = await self._reply(event, "🔎 Reading the tracks…")
537
+ name = original_name_of(reply)
538
+ ext = source_ext(reply, name)
539
+ tmp_dir = Path(self.cfg.temp_dir) / f"tracks{event.chat_id}_{event.id}"
540
+ tmp_dir.mkdir(parents=True, exist_ok=True)
541
+ try:
542
+ path = tmp_dir / f"probe{ext}"
543
+ size = int(reply.file.size or 0)
544
+ info = None
545
+ await transfer.download(
546
+ self.client, reply, path, fast=self.cfg.fast_transfer, connections=2, max_bytes=TRACKS_PROBE_BYTES
547
+ )
548
+ try:
549
+ info = await media.probe(path)
550
+ except media.MediaError:
551
+ info = None
552
+ if (info is None or not info.streams) and 0 < size <= TRACKS_FULL_LIMIT and size > TRACKS_PROBE_BYTES:
553
+ await self.client.edit_message(status, "🔎 Reading the tracks (this one needs the whole file)…", parse_mode="html")
554
+ await transfer.download(self.client, reply, path, fast=self.cfg.fast_transfer, connections=self.cfg.connections)
555
+ try:
556
+ info = await media.probe(path)
557
+ except media.MediaError:
558
+ info = None
559
+ if info is None or not info.streams:
560
+ await self.client.edit_message(status, "❌ I couldn't read any tracks from that file.", parse_mode="html")
561
+ return
562
+ text = f"<b>{esc(clip(name, 80))}</b>\n\n{media.format_tracks(info)}"
563
+ await self.client.edit_message(status, text[:3900], parse_mode="html")
564
+ finally:
565
+ shutil.rmtree(tmp_dir, ignore_errors=True)
566
+
567
+ # ----------------------------------------------------------- status/stats
568
+ async def cmd_status(self, event: Any) -> None:
569
+ ping_ms: Optional[float] = None
570
+ try:
571
+ t0 = time.perf_counter()
572
+ await self.client(functions.PingRequest(ping_id=random.getrandbits(62)))
573
+ ping_ms = (time.perf_counter() - t0) * 1000
574
+ except Exception as exc: # noqa: BLE001
575
+ log.debug("ping failed: %s", exc)
576
+ await self._reply(event, sysinfo.format_status(ping_ms, getattr(self.client.session, "dc_id", None), telethon.__version__))
577
+
578
+ async def cmd_stats(self, event: Any) -> None:
579
+ await self._reply(event, await self._stats_text())
580
+
581
+ async def _stats_text(self) -> str:
582
+ users = await self.db.count_users()
583
+ files, total = await self.db.totals()
584
+ return await sysinfo.format_stats(users, files, total, self.queue.running, self.queue.waiting, self.cfg.temp_dir)
585
+
586
+ # ------------------------------------------------------------------ admins
587
+ async def cmd_addadmin(self, event: Any) -> None:
588
+ arg = self._args(event)
589
+ if not re.fullmatch(r"\d{4,15}", arg):
590
+ await self._reply(event, "Usage: <code>/addadmin 123456789</code> (a numeric user id; they can find theirs with @userinfobot).")
591
+ return
592
+ uid = int(arg)
593
+ if uid == self.cfg.owner_id or uid in self.admins:
594
+ await self._reply(event, "They already have access.")
595
+ return
596
+ await self.db.add_admin(uid, event.sender_id)
597
+ self.admins.add(uid)
598
+ await self._reply(event, f"✅ <code>{uid}</code> can now use the bot.")
599
+
600
+ async def cmd_deladmin(self, event: Any) -> None:
601
+ arg = self._args(event)
602
+ if not re.fullmatch(r"\d{4,15}", arg):
603
+ await self._reply(event, "Usage: <code>/deladmin 123456789</code>")
604
+ return
605
+ uid = int(arg)
606
+ removed = await self.db.del_admin(uid)
607
+ if uid in self.cfg.admin_ids:
608
+ await self._reply(event, "That id is listed in the ADMIN_IDS setting; remove it there.")
609
+ return
610
+ self.admins.discard(uid)
611
+ await self._reply(event, f"✅ <code>{uid}</code> was removed." if removed else "That id wasn't in the list.")
612
+
613
+ async def cmd_admins(self, event: Any) -> None:
614
+ ids = ", ".join(f"<code>{a}</code>" for a in sorted(self.admins)) or "none"
615
+ await self._reply(event, f"<b>Owner:</b> <code>{self.cfg.owner_id}</code>\n<b>Allowed users:</b> {ids}")
616
+
617
+ # --------------------------------------------------------------- text input
618
+ async def on_text(self, event: Any) -> None:
619
+ uid = event.sender_id
620
+ p = self.pending.get(uid)
621
+ if p is None or p.expires < time.time():
622
+ self.pending.pop(uid, None)
623
+ return
624
+ ok, msg = await apply_setting(self.db, uid, p.kind, event.raw_text)
625
+ if not ok:
626
+ await self._reply(event, msg + "\n\nTry again, or tap Cancel.")
627
+ return
628
+ self.pending.pop(uid, None)
629
+ try:
630
+ await event.delete()
631
+ except Exception: # noqa: BLE001
632
+ pass
633
+ text, rows = await self.panel(uid, p.back)
634
+ limit = 1000 if p.is_photo else 3900
635
+ full = f"{msg}\n\n{text}" if visible_len(msg) + visible_len(text) < limit else f"{msg}\n\n(Open the menu to see the update.)"
636
+ try:
637
+ await self.client.edit_message(p.chat_id, p.msg_id, full, buttons=panels.to_buttons(rows), parse_mode="html")
638
+ except Exception: # noqa: BLE001
639
+ await self._reply(event, full, rows)
640
+
641
+ # ---------------------------------------------------------------- callbacks
642
+ async def on_callback(self, event: Any) -> None:
643
+ uid = event.sender_id
644
+ data = event.data.decode("utf-8", "ignore")
645
+ parts = data.split(":")
646
+ head = parts[0]
647
+
648
+ if data == "x:close":
649
+ self.pending.pop(uid, None)
650
+ await event.answer()
651
+ await self._remove_message(event)
652
+ return
653
+
654
+ if head == "m" and len(parts) == 2 and parts[1] in PANEL_NAMES:
655
+ self.pending.pop(uid, None)
656
+ if parts[1] == "admin" and not self.is_owner(uid):
657
+ await event.answer("Owner only.", alert=True)
658
+ return
659
+ text, rows = await self.panel(uid, parts[1])
660
+ await self._edit_panel(event, text, rows)
661
+ await event.answer()
662
+ return
663
+
664
+ if head == "set" and len(parts) == 3:
665
+ await self._cb_set(event, uid, parts[1], parts[2])
666
+ return
667
+
668
+ if head == "in":
669
+ await self._cb_input(event, uid, parts)
670
+ return
671
+
672
+ if head == "clr" and len(parts) == 2:
673
+ if parts[1] == "caption":
674
+ _ok, msg = await apply_setting(self.db, uid, "caption", "reset")
675
+ panel = "cap"
676
+ elif parts[1] in ("keep_audio", "keep_subtitle"):
677
+ _ok, msg = await apply_setting(self.db, uid, parts[1], "off")
678
+ panel = "tool"
679
+ else:
680
+ await event.answer()
681
+ return
682
+ text, rows = await self.panel(uid, panel)
683
+ await self._edit_panel(event, text, rows)
684
+ await event.answer(clip(plain(msg), 190))
685
+ return
686
+
687
+ if head == "th" and len(parts) == 2:
688
+ if parts[1] in ("del", "rm"):
689
+ await self.db.set_thumb(uid, None)
690
+ if parts[1] == "rm": # button on a standalone thumbnail message
691
+ await event.answer("Thumbnail deleted.")
692
+ await self._remove_message(event)
693
+ else: # button inside the Caption & Thumbnail panel
694
+ text, rows = await self.panel(uid, "cap")
695
+ await self._edit_panel(event, text, rows)
696
+ await event.answer("Thumbnail deleted.")
697
+ return
698
+ if parts[1] == "view":
699
+ data_bytes = await self.db.get_thumb(uid)
700
+ if not data_bytes:
701
+ await event.answer("You have no thumbnail yet — send me a photo.", alert=True)
702
+ return
703
+ await event.answer()
704
+ await self._send_thumb(event.chat_id, data_bytes)
705
+ return
706
+
707
+ if data == "help:meta":
708
+ text, rows = panels.metadata_help()
709
+ await self._edit_panel(event, text, rows)
710
+ await event.answer()
711
+ return
712
+
713
+ if head == "adm" and len(parts) == 2:
714
+ if not self.is_owner(uid):
715
+ await event.answer("Owner only.", alert=True)
716
+ return
717
+ if parts[1] == "stats":
718
+ await event.answer()
719
+ await self.client.send_message(event.chat_id, await self._stats_text(), parse_mode="html")
720
+ elif parts[1] == "status":
721
+ await event.answer()
722
+ await self.client.send_message(
723
+ event.chat_id, sysinfo.format_status(None, getattr(self.client.session, "dc_id", None), telethon.__version__),
724
+ parse_mode="html",
725
+ )
726
+ elif parts[1] == "clear":
727
+ count = self.queue.cancel_all()
728
+ await event.answer(f"Cancelling {count} job(s).", alert=True)
729
+ return
730
+
731
+ if head == "job" and len(parts) == 3 and parts[1] == "x" and parts[2].isdigit():
732
+ ok = self.queue.cancel(int(parts[2]), user_id=None if self.is_owner(uid) else uid)
733
+ await event.answer("Cancelling…" if ok else "That job already finished.")
734
+ return
735
+
736
+ await event.answer()
737
+
738
+ async def _cb_set(self, event: Any, uid: int, field: str, value: str) -> None:
739
+ if field == "mode" and value in ("filename", "caption"):
740
+ await self.db.update_settings(uid, mode=value)
741
+ panel, toast = "rename", f"Mode: {value.title()}"
742
+ elif field == "ftype" and value in ("same", "document", "video", "audio"):
743
+ await self.db.update_settings(uid, file_type=value)
744
+ panel, toast = "ftype", "File type: " + ("Same as original" if value == "same" else value.title())
745
+ elif field == "meta" and value in ("on", "off"):
746
+ await self.db.update_settings(uid, metadata_on=(value == "on"))
747
+ panel, toast = "meta", f"Metadata {value.upper()}"
748
+ else:
749
+ await event.answer()
750
+ return
751
+ text, rows = await self.panel(uid, panel)
752
+ await self._edit_panel(event, text, rows)
753
+ await event.answer(toast)
754
+
755
+ async def _cb_input(self, event: Any, uid: int, parts: List[str]) -> None:
756
+ if len(parts) == 3 and parts[1] == "cancel":
757
+ self.pending.pop(uid, None)
758
+ back = parts[2] if parts[2] in PANEL_NAMES else "home"
759
+ text, rows = await self.panel(uid, back)
760
+ await self._edit_panel(event, text, rows)
761
+ await event.answer("Cancelled.")
762
+ return
763
+ kind = parts[1] if len(parts) == 2 else ""
764
+ if kind not in panels.PROMPTS:
765
+ await event.answer()
766
+ return
767
+ msg_obj = await event.get_message()
768
+ self.pending[uid] = Pending(
769
+ kind=kind, chat_id=event.chat_id, msg_id=event.message_id, back=BACK[kind],
770
+ is_photo=bool(msg_obj is not None and msg_obj.photo is not None), expires=time.time() + self.PENDING_TTL,
771
+ )
772
+ text, rows = panels.input_prompt(kind, BACK[kind])
773
+ await self._edit_panel(event, text, rows)
774
+ await event.answer()
bot/config.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Environment-driven configuration."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ from dataclasses import dataclass
6
+ from typing import Mapping, Optional, Tuple
7
+
8
+
9
+ class ConfigError(RuntimeError):
10
+ pass
11
+
12
+
13
+ def _parse_ids(value: Optional[str]) -> Tuple[int, ...]:
14
+ ids = []
15
+ for chunk in (value or "").replace(";", ",").replace(" ", ",").split(","):
16
+ chunk = chunk.strip()
17
+ if not chunk:
18
+ continue
19
+ try:
20
+ ids.append(int(chunk))
21
+ except ValueError as exc:
22
+ raise ConfigError(f"Invalid id {chunk!r} in ADMIN_IDS") from exc
23
+ return tuple(ids)
24
+
25
+
26
+ def _get_int(env: Mapping[str, str], name: str, default: int) -> int:
27
+ raw = env.get(name, "").strip()
28
+ if not raw:
29
+ return default
30
+ try:
31
+ return int(raw)
32
+ except ValueError as exc:
33
+ raise ConfigError(f"{name} must be an integer, got {raw!r}") from exc
34
+
35
+
36
+ def _get_bool(env: Mapping[str, str], name: str, default: bool) -> bool:
37
+ raw = env.get(name, "").strip().lower()
38
+ if not raw:
39
+ return default
40
+ return raw in {"1", "true", "yes", "on"}
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class Config:
45
+ api_id: int
46
+ api_hash: str
47
+ bot_token: str
48
+ owner_id: int
49
+ admin_ids: Tuple[int, ...]
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
57
+ connections: int
58
+ max_file_mb: int
59
+ fast_transfer: bool
60
+ progress_interval: float
61
+
62
+ @property
63
+ def max_file_bytes(self) -> int:
64
+ return self.max_file_mb * 1024 * 1024
65
+
66
+
67
+ def load_config(env: Optional[Mapping[str, str]] = None) -> Config:
68
+ env = os.environ if env is None else env
69
+
70
+ missing = [k for k in ("API_ID", "API_HASH", "BOT_TOKEN", "OWNER_ID") if not env.get(k, "").strip()]
71
+ if missing:
72
+ raise ConfigError("Missing required environment variables: " + ", ".join(missing))
73
+
74
+ log_channel_raw = env.get("LOG_CHANNEL", "").strip()
75
+ log_channel = None
76
+ if log_channel_raw:
77
+ try:
78
+ log_channel = int(log_channel_raw)
79
+ except ValueError as exc:
80
+ raise ConfigError("LOG_CHANNEL must be a numeric chat id like -1001234567890") from exc
81
+
82
+ connections = max(1, min(_get_int(env, "TRANSFER_CONNECTIONS", 8), 16))
83
+ max_jobs = max(1, min(_get_int(env, "MAX_CONCURRENT_JOBS", 1), 4))
84
+
85
+ return Config(
86
+ api_id=_get_int(env, "API_ID", 0),
87
+ api_hash=env["API_HASH"].strip(),
88
+ bot_token=env["BOT_TOKEN"].strip(),
89
+ owner_id=_get_int(env, "OWNER_ID", 0),
90
+ admin_ids=_parse_ids(env.get("ADMIN_IDS")),
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,
98
+ connections=connections,
99
+ max_file_mb=max(1, _get_int(env, "MAX_FILE_MB", 2000)),
100
+ fast_transfer=_get_bool(env, "FAST_TRANSFER", True),
101
+ progress_interval=max(2.0, float(env.get("PROGRESS_INTERVAL", "4") or 4)),
102
+ )
bot/db.py ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Async persistence (SQLAlchemy 2.x).
2
+
3
+ Hugging Face Spaces have no persistent disk, so point ``DATABASE_URL`` at an
4
+ external Postgres (Neon, Supabase, ...). SQLite works for local testing.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import copy
9
+ import json
10
+ import os
11
+ from datetime import datetime, timezone
12
+ from typing import Any, Dict, List, Optional, Tuple
13
+ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
14
+
15
+ from sqlalchemy import BigInteger, DateTime, Integer, LargeBinary, String, Text, func, select
16
+ from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine
17
+ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
18
+
19
+ from .parser import DEFAULT_TEMPLATE
20
+
21
+ DEFAULT_SETTINGS: Dict[str, Any] = {
22
+ "mode": "filename", # filename | caption
23
+ "template": DEFAULT_TEMPLATE,
24
+ "caption": "{filename}",
25
+ "file_type": "same", # same | document | video | audio
26
+ "remove_words": [],
27
+ "metadata_on": False,
28
+ "meta": {"title": "", "author": "", "artist": "", "audio": "", "subtitle": "", "video": ""},
29
+ "keep_audio": [],
30
+ "keep_subtitle": [],
31
+ "target_channel": None,
32
+ }
33
+
34
+
35
+ def _utcnow() -> datetime:
36
+ return datetime.now(timezone.utc)
37
+
38
+
39
+ class Base(DeclarativeBase):
40
+ pass
41
+
42
+
43
+ class User(Base):
44
+ __tablename__ = "users"
45
+
46
+ id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False)
47
+ settings: Mapped[str] = mapped_column(Text, default="{}")
48
+ thumb: Mapped[Optional[bytes]] = mapped_column(LargeBinary, nullable=True)
49
+ files_renamed: Mapped[int] = mapped_column(Integer, default=0)
50
+ bytes_processed: Mapped[int] = mapped_column(BigInteger, default=0)
51
+ first_seen: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
52
+
53
+
54
+ class Admin(Base):
55
+ __tablename__ = "admins"
56
+
57
+ id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False)
58
+ added_by: Mapped[int] = mapped_column(BigInteger, default=0)
59
+ added_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
60
+
61
+
62
+ class KV(Base):
63
+ __tablename__ = "kv"
64
+
65
+ key: Mapped[str] = mapped_column(String(64), primary_key=True)
66
+ value: Mapped[str] = mapped_column(Text, default="")
67
+
68
+
69
+ def normalize_database_url(url: str) -> Tuple[str, Dict[str, Any]]:
70
+ """Turn a provider URL into something SQLAlchemy's async drivers accept."""
71
+ connect_args: Dict[str, Any] = {}
72
+ if url.startswith("postgres://"):
73
+ url = "postgresql://" + url[len("postgres://"):]
74
+ if url.startswith("postgresql://"):
75
+ url = "postgresql+asyncpg://" + url[len("postgresql://"):]
76
+ if url.startswith("postgresql+asyncpg://"):
77
+ parts = urlsplit(url)
78
+ query = dict(parse_qsl(parts.query))
79
+ sslmode = query.pop("sslmode", None)
80
+ query.pop("channel_binding", None) # Neon adds this; asyncpg rejects it
81
+ if sslmode and sslmode != "disable":
82
+ connect_args["ssl"] = "require"
83
+ url = urlunsplit(parts._replace(query=urlencode(query)))
84
+ return url, connect_args
85
+
86
+
87
+ def _merge_settings(stored: Dict[str, Any]) -> Dict[str, Any]:
88
+ merged = copy.deepcopy(DEFAULT_SETTINGS)
89
+ for key, value in stored.items():
90
+ if key == "meta" and isinstance(value, dict):
91
+ merged["meta"].update({k: v for k, v in value.items() if k in merged["meta"]})
92
+ elif key in merged:
93
+ merged[key] = value
94
+ return merged
95
+
96
+
97
+ class Database:
98
+ def __init__(self, url: str):
99
+ self.url, connect_args = normalize_database_url(url)
100
+ if self.url.startswith("sqlite"):
101
+ path = self.url.split("///", 1)[-1]
102
+ directory = os.path.dirname(path)
103
+ if directory:
104
+ os.makedirs(directory, exist_ok=True)
105
+ self.engine: AsyncEngine = create_async_engine(self.url, pool_pre_ping=True, connect_args=connect_args)
106
+ self._sessions = async_sessionmaker(self.engine, expire_on_commit=False)
107
+ self._cache: Dict[int, Dict[str, Any]] = {}
108
+
109
+ async def init(self) -> None:
110
+ async with self.engine.begin() as conn:
111
+ await conn.run_sync(Base.metadata.create_all)
112
+
113
+ async def close(self) -> None:
114
+ await self.engine.dispose()
115
+
116
+ # ----- users / settings ---------------------------------------------------
117
+ async def _get_or_create(self, session, uid: int) -> User:
118
+ row = await session.get(User, uid)
119
+ if row is None:
120
+ row = User(id=uid, settings="{}")
121
+ session.add(row)
122
+ await session.flush()
123
+ return row
124
+
125
+ async def get_settings(self, uid: int) -> Dict[str, Any]:
126
+ if uid not in self._cache:
127
+ async with self._sessions() as session:
128
+ row = await self._get_or_create(session, uid)
129
+ await session.commit()
130
+ try:
131
+ stored = json.loads(row.settings or "{}")
132
+ except ValueError:
133
+ stored = {}
134
+ self._cache[uid] = _merge_settings(stored if isinstance(stored, dict) else {})
135
+ return copy.deepcopy(self._cache[uid])
136
+
137
+ async def update_settings(self, uid: int, **changes: Any) -> Dict[str, Any]:
138
+ """Update top-level keys; a ``meta`` dict is merged field by field."""
139
+ current = await self.get_settings(uid)
140
+ for key, value in changes.items():
141
+ if key not in DEFAULT_SETTINGS:
142
+ raise KeyError(key)
143
+ if key == "meta":
144
+ current["meta"].update(value)
145
+ else:
146
+ current[key] = value
147
+ async with self._sessions() as session:
148
+ row = await self._get_or_create(session, uid)
149
+ row.settings = json.dumps(current, ensure_ascii=False)
150
+ await session.commit()
151
+ self._cache[uid] = current
152
+ return copy.deepcopy(current)
153
+
154
+ async def set_thumb(self, uid: int, data: Optional[bytes]) -> None:
155
+ async with self._sessions() as session:
156
+ row = await self._get_or_create(session, uid)
157
+ row.thumb = data
158
+ await session.commit()
159
+
160
+ async def get_thumb(self, uid: int) -> Optional[bytes]:
161
+ async with self._sessions() as session:
162
+ row = await session.get(User, uid)
163
+ return bytes(row.thumb) if row is not None and row.thumb else None
164
+
165
+ async def has_thumb(self, uid: int) -> bool:
166
+ async with self._sessions() as session:
167
+ result = await session.execute(select(User.thumb.isnot(None)).where(User.id == uid))
168
+ return bool(result.scalar())
169
+
170
+ async def record_job(self, uid: int, size: int) -> None:
171
+ async with self._sessions() as session:
172
+ row = await self._get_or_create(session, uid)
173
+ row.files_renamed = (row.files_renamed or 0) + 1
174
+ row.bytes_processed = (row.bytes_processed or 0) + int(size)
175
+ await session.commit()
176
+
177
+ async def count_users(self) -> int:
178
+ async with self._sessions() as session:
179
+ return int((await session.execute(select(func.count()).select_from(User))).scalar() or 0)
180
+
181
+ async def totals(self) -> Tuple[int, int]:
182
+ async with self._sessions() as session:
183
+ row = (
184
+ await session.execute(
185
+ select(func.coalesce(func.sum(User.files_renamed), 0), func.coalesce(func.sum(User.bytes_processed), 0))
186
+ )
187
+ ).one()
188
+ return int(row[0]), int(row[1])
189
+
190
+ # ----- admins ------------------------------------------------------------------
191
+ async def list_admins(self) -> List[int]:
192
+ async with self._sessions() as session:
193
+ return [int(r) for r in (await session.execute(select(Admin.id).order_by(Admin.added_at))).scalars()]
194
+
195
+ async def add_admin(self, uid: int, added_by: int = 0) -> bool:
196
+ async with self._sessions() as session:
197
+ if await session.get(Admin, uid) is not None:
198
+ return False
199
+ session.add(Admin(id=uid, added_by=added_by))
200
+ await session.commit()
201
+ return True
202
+
203
+ async def del_admin(self, uid: int) -> bool:
204
+ async with self._sessions() as session:
205
+ row = await session.get(Admin, uid)
206
+ if row is None:
207
+ return False
208
+ await session.delete(row)
209
+ await session.commit()
210
+ return True
211
+
212
+ # ----- key/value (session string etc.) ---------------------------------------------
213
+ async def kv_get(self, key: str) -> Optional[str]:
214
+ async with self._sessions() as session:
215
+ row = await session.get(KV, key)
216
+ return row.value if row is not None else None
217
+
218
+ async def kv_set(self, key: str, value: str) -> None:
219
+ async with self._sessions() as session:
220
+ row = await session.get(KV, key)
221
+ if row is None:
222
+ session.add(KV(key=key, value=value))
223
+ else:
224
+ row.value = value
225
+ await session.commit()
226
+
227
+ async def kv_delete(self, key: str) -> None:
228
+ async with self._sessions() as session:
229
+ row = await session.get(KV, key)
230
+ if row is not None:
231
+ await session.delete(row)
232
+ await session.commit()
bot/errors.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ class Cancelled(Exception):
2
+ """Raised inside a job when the user cancels it."""
3
+
4
+
5
+ class MediaError(RuntimeError):
6
+ """ffprobe / ffmpeg failed."""
bot/fasttransfer.py ADDED
@@ -0,0 +1,430 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Parallel (multi-connection) transfers over MTProto.
2
+
3
+ The part-scheduling logic (``download_parts`` / ``upload_parts``) only talks to a
4
+ tiny ``Transport`` interface, so it is tested offline with fakes. The Telethon
5
+ specific glue (``open_transports`` and friends) uses a few private Telethon
6
+ helpers; if anything in the fast path fails the wrappers ``download`` /
7
+ ``upload`` fall back to Telethon's stock implementation, so the bot keeps
8
+ working (just slower).
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import itertools
14
+ import logging
15
+ import math
16
+ import os
17
+ import threading
18
+ from pathlib import Path
19
+ from typing import Any, Awaitable, Callable, List, Optional, Protocol, Sequence
20
+
21
+ from .errors import Cancelled
22
+
23
+ log = logging.getLogger(__name__)
24
+
25
+ DOWNLOAD_PART = 1024 * 1024 # upload.getFile allows up to 1 MiB per request
26
+ UPLOAD_PART = 512 * 1024 # upload.saveBigFilePart parts are at most 512 KiB
27
+ BIG_FILE_THRESHOLD = 10 * 1024 * 1024 # Telegram wants saveBigFilePart above 10 MB
28
+ PART_TIMEOUT = 120.0
29
+ MAX_RETRIES = 5
30
+ RETRY_BASE_DELAY = 1.0
31
+
32
+ # Errors that retrying cannot fix: fail fast and let the caller fall back.
33
+ _FATAL_ERROR_NAMES = {
34
+ "FileReferenceExpiredError", "FileReferenceInvalidError", "FileIdInvalidError", "LocationInvalidError",
35
+ "OffsetInvalidError", "LimitInvalidError", "FilePartsInvalidError", "FilePartSizeInvalidError",
36
+ "FileMigrateError", "AuthKeyUnregisteredError", "AuthKeyInvalidError", "SessionRevokedError",
37
+ "UserDeactivatedError", "UserDeactivatedBanError",
38
+ }
39
+
40
+ ProgressCb = Callable[[int, int], None]
41
+
42
+
43
+ class Transport(Protocol):
44
+ async def get_file(self, location: Any, offset: int, limit: int) -> bytes: ...
45
+
46
+ async def save_part(self, file_id: int, part: int, total_parts: int, data: bytes) -> None: ...
47
+
48
+ async def close(self) -> None: ...
49
+
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # positional file I/O that also works where os.pread/os.pwrite are missing
53
+ # ---------------------------------------------------------------------------
54
+
55
+
56
+ class _File:
57
+ def __init__(self, path: Path | str, mode: str):
58
+ flags = {"r": os.O_RDONLY, "w": os.O_RDWR | os.O_CREAT | os.O_TRUNC}[mode]
59
+ flags |= getattr(os, "O_BINARY", 0)
60
+ self.fd = os.open(str(path), flags, 0o644)
61
+ self._lock = threading.Lock()
62
+ self._positional = hasattr(os, "pwrite") and hasattr(os, "pread")
63
+
64
+ def pwrite(self, data: bytes, offset: int) -> None:
65
+ view = memoryview(data)
66
+ if self._positional:
67
+ written = 0
68
+ while written < len(view):
69
+ written += os.pwrite(self.fd, view[written:], offset + written)
70
+ return
71
+ with self._lock:
72
+ os.lseek(self.fd, offset, os.SEEK_SET)
73
+ written = 0
74
+ while written < len(view):
75
+ written += os.write(self.fd, view[written:])
76
+
77
+ def pread(self, size: int, offset: int) -> bytes:
78
+ if self._positional:
79
+ return os.pread(self.fd, size, offset)
80
+ with self._lock:
81
+ os.lseek(self.fd, offset, os.SEEK_SET)
82
+ return os.read(self.fd, size)
83
+
84
+ def close(self) -> None:
85
+ os.close(self.fd)
86
+
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # generic engines
90
+ # ---------------------------------------------------------------------------
91
+
92
+
93
+ async def _retry(op: Callable[[], Awaitable[Any]], *, cancel: Optional[asyncio.Event], what: str) -> Any:
94
+ delay = RETRY_BASE_DELAY
95
+ for attempt in range(MAX_RETRIES + 1):
96
+ if cancel is not None and cancel.is_set():
97
+ raise Cancelled()
98
+ try:
99
+ return await asyncio.wait_for(op(), PART_TIMEOUT)
100
+ except (asyncio.CancelledError, Cancelled):
101
+ raise
102
+ except Exception as exc: # noqa: BLE001 - classified below
103
+ if type(exc).__name__ in _FATAL_ERROR_NAMES or attempt >= MAX_RETRIES:
104
+ raise
105
+ log.warning("%s failed (%s: %s) - retry %d/%d", what, type(exc).__name__, exc, attempt + 1, MAX_RETRIES)
106
+ await asyncio.sleep(delay)
107
+ delay = min(delay * 2, 8.0)
108
+ raise RuntimeError("unreachable") # pragma: no cover
109
+
110
+
111
+ async def _run_workers(workers: Sequence[Callable[[], Awaitable[None]]]) -> None:
112
+ tasks = [asyncio.ensure_future(w()) for w in workers]
113
+ try:
114
+ pending = set(tasks)
115
+ while pending:
116
+ done, pending = await asyncio.wait(pending, return_when=asyncio.FIRST_EXCEPTION)
117
+ for task in done:
118
+ if not task.cancelled() and task.exception() is not None:
119
+ raise task.exception() # type: ignore[misc]
120
+ finally:
121
+ for task in tasks:
122
+ task.cancel()
123
+ await asyncio.gather(*tasks, return_exceptions=True)
124
+
125
+
126
+ async def download_parts(
127
+ transports: Sequence[Transport],
128
+ location: Any,
129
+ size: int,
130
+ dest: Path | str,
131
+ *,
132
+ part_size: int = DOWNLOAD_PART,
133
+ progress: Optional[ProgressCb] = None,
134
+ cancel: Optional[asyncio.Event] = None,
135
+ max_bytes: Optional[int] = None,
136
+ ) -> int:
137
+ """Download ``size`` bytes (or the first ``max_bytes``) into ``dest``; returns bytes written."""
138
+ total = size if max_bytes is None else min(size, max_bytes)
139
+ out = _File(dest, "w")
140
+ try:
141
+ if total <= 0:
142
+ return 0
143
+ n_parts = math.ceil(total / part_size)
144
+ counter = itertools.count()
145
+ done = 0
146
+
147
+ async def worker(transport: Transport) -> None:
148
+ nonlocal done
149
+ while True:
150
+ if cancel is not None and cancel.is_set():
151
+ raise Cancelled()
152
+ idx = next(counter)
153
+ if idx >= n_parts:
154
+ return
155
+ offset = idx * part_size
156
+ want = min(part_size, total - offset)
157
+
158
+ async def fetch() -> bytes:
159
+ data = await transport.get_file(location, offset, part_size)
160
+ if len(data) < want:
161
+ raise IOError(f"short read: got {len(data)} of {want} bytes at offset {offset}")
162
+ return data
163
+
164
+ data = await _retry(fetch, cancel=cancel, what=f"download part {idx + 1}/{n_parts}")
165
+ await asyncio.to_thread(out.pwrite, data[:want], offset)
166
+ done += want
167
+ if progress is not None:
168
+ progress(done, total)
169
+
170
+ await _run_workers([lambda t=t: worker(t) for t in transports])
171
+ if done != total:
172
+ raise IOError(f"download incomplete: {done} of {total} bytes")
173
+ return total
174
+ finally:
175
+ out.close()
176
+
177
+
178
+ async def upload_parts(
179
+ transports: Sequence[Transport],
180
+ path: Path | str,
181
+ size: int,
182
+ file_id: int,
183
+ *,
184
+ part_size: int = UPLOAD_PART,
185
+ progress: Optional[ProgressCb] = None,
186
+ cancel: Optional[asyncio.Event] = None,
187
+ ) -> int:
188
+ """Upload ``path`` with ``saveBigFilePart``; returns the number of parts."""
189
+ n_parts = max(1, math.ceil(size / part_size))
190
+ src = _File(path, "r")
191
+ try:
192
+ counter = itertools.count()
193
+ done = 0
194
+
195
+ async def worker(transport: Transport) -> None:
196
+ nonlocal done
197
+ while True:
198
+ if cancel is not None and cancel.is_set():
199
+ raise Cancelled()
200
+ idx = next(counter)
201
+ if idx >= n_parts:
202
+ return
203
+ offset = idx * part_size
204
+ want = min(part_size, size - offset)
205
+ data = await asyncio.to_thread(src.pread, want, offset)
206
+ if len(data) != want:
207
+ raise IOError(f"read {len(data)} of {want} bytes at offset {offset}; was the file modified?")
208
+ await _retry(
209
+ lambda d=data, i=idx: transport.save_part(file_id, i, n_parts, d),
210
+ cancel=cancel,
211
+ what=f"upload part {idx + 1}/{n_parts}",
212
+ )
213
+ done += want
214
+ if progress is not None:
215
+ progress(done, size)
216
+
217
+ await _run_workers([lambda t=t: worker(t) for t in transports])
218
+ if done != size:
219
+ raise IOError(f"upload incomplete: {done} of {size} bytes")
220
+ return n_parts
221
+ finally:
222
+ src.close()
223
+
224
+
225
+ async def close_all(transports: Sequence[Transport]) -> None:
226
+ await asyncio.gather(*(t.close() for t in transports), return_exceptions=True)
227
+
228
+
229
+ # ---------------------------------------------------------------------------
230
+ # Telethon glue
231
+ # ---------------------------------------------------------------------------
232
+
233
+
234
+ class TelethonTransport:
235
+ def __init__(self, client: Any, sender: Any):
236
+ self._client = client
237
+ self._sender = sender
238
+
239
+ async def get_file(self, location: Any, offset: int, limit: int) -> bytes:
240
+ from telethon.tl.functions.upload import GetFileRequest
241
+
242
+ result = await self._client._call(self._sender, GetFileRequest(location=location, offset=offset, limit=limit))
243
+ if not hasattr(result, "bytes"):
244
+ raise IOError(f"unexpected upload.getFile result: {type(result).__name__}")
245
+ return result.bytes
246
+
247
+ async def save_part(self, file_id: int, part: int, total_parts: int, data: bytes) -> None:
248
+ from telethon.tl.functions.upload import SaveBigFilePartRequest
249
+
250
+ ok = await self._client._call(self._sender, SaveBigFilePartRequest(file_id, part, total_parts, data))
251
+ if not ok:
252
+ raise IOError("upload.saveBigFilePart returned False")
253
+
254
+ async def close(self) -> None:
255
+ await self._sender.disconnect()
256
+
257
+
258
+ async def _open_sender(client: Any, dc_id: int, auth_key: Any) -> Any:
259
+ from telethon.network import MTProtoSender
260
+
261
+ dc = await client._get_dc(dc_id)
262
+ sender = MTProtoSender(auth_key, loggers=client._log)
263
+ await sender.connect(
264
+ client._connection(
265
+ dc.ip_address, dc.port, dc.id, loggers=client._log, proxy=client._proxy, local_addr=client._local_addr
266
+ )
267
+ )
268
+ return sender
269
+
270
+
271
+ async def open_transports(client: Any, dc_id: int, connections: int) -> List[TelethonTransport]:
272
+ """Open up to ``connections`` extra MTProto connections to ``dc_id``."""
273
+ from telethon.tl.alltlobjects import LAYER
274
+ from telethon.tl.functions import InvokeWithLayerRequest
275
+ from telethon.tl.functions.auth import ExportAuthorizationRequest, ImportAuthorizationRequest
276
+
277
+ if dc_id == client.session.dc_id:
278
+ auth_key = client.session.auth_key
279
+ first = await _open_sender(client, dc_id, auth_key)
280
+ else:
281
+ # a different DC needs its own key, plus our authorization exported/imported once
282
+ first = await _open_sender(client, dc_id, None)
283
+ try:
284
+ exported = await client(ExportAuthorizationRequest(dc_id))
285
+ client._init_request.query = ImportAuthorizationRequest(id=exported.id, bytes=exported.bytes)
286
+ await first.send(InvokeWithLayerRequest(LAYER, client._init_request))
287
+ except BaseException:
288
+ await first.disconnect()
289
+ raise
290
+ auth_key = first.auth_key
291
+ senders = [first]
292
+ if connections > 1:
293
+ results = await asyncio.gather(
294
+ *(_open_sender(client, dc_id, auth_key) for _ in range(connections - 1)), return_exceptions=True
295
+ )
296
+ for res in results:
297
+ if isinstance(res, BaseException):
298
+ log.warning("could not open an extra connection to DC %s: %s", dc_id, res)
299
+ else:
300
+ senders.append(res)
301
+ log.info("opened %d connection(s) to DC %s", len(senders), dc_id)
302
+ return [TelethonTransport(client, s) for s in senders]
303
+
304
+
305
+ async def fast_download(
306
+ client: Any,
307
+ document: Any,
308
+ dest: Path | str,
309
+ *,
310
+ connections: int = 8,
311
+ progress: Optional[ProgressCb] = None,
312
+ cancel: Optional[asyncio.Event] = None,
313
+ max_bytes: Optional[int] = None,
314
+ ) -> int:
315
+ from telethon import utils
316
+
317
+ dc_id, location = utils.get_input_location(document)
318
+ transports = await open_transports(client, dc_id, connections)
319
+ try:
320
+ return await download_parts(
321
+ transports, location, int(document.size), dest, progress=progress, cancel=cancel, max_bytes=max_bytes
322
+ )
323
+ finally:
324
+ await close_all(transports)
325
+
326
+
327
+ async def fast_upload(
328
+ client: Any,
329
+ path: Path | str,
330
+ file_name: str,
331
+ *,
332
+ connections: int = 8,
333
+ progress: Optional[ProgressCb] = None,
334
+ cancel: Optional[asyncio.Event] = None,
335
+ ) -> Any:
336
+ from telethon.tl.types import InputFileBig
337
+
338
+ size = os.path.getsize(path)
339
+ if size <= BIG_FILE_THRESHOLD:
340
+ return await client.upload_file(str(path), file_name=file_name, progress_callback=_checked(progress, cancel))
341
+ file_id = int.from_bytes(os.urandom(8), "big", signed=True)
342
+ transports = await open_transports(client, client.session.dc_id, connections)
343
+ try:
344
+ parts = await upload_parts(transports, path, size, file_id, progress=progress, cancel=cancel)
345
+ finally:
346
+ await close_all(transports)
347
+ return InputFileBig(id=file_id, parts=parts, name=file_name)
348
+
349
+
350
+ # ---------------------------------------------------------------------------
351
+ # public wrappers with fallback
352
+ # ---------------------------------------------------------------------------
353
+
354
+
355
+ def _checked(progress: Optional[ProgressCb], cancel: Optional[asyncio.Event]) -> ProgressCb:
356
+ """Progress callback for Telethon's own transfers; raising aborts them."""
357
+
358
+ def cb(current: int, total: int) -> None:
359
+ if cancel is not None and cancel.is_set():
360
+ raise Cancelled()
361
+ if progress is not None:
362
+ progress(current, total)
363
+
364
+ return cb
365
+
366
+
367
+ def _unlink(path: Path | str) -> None:
368
+ try:
369
+ os.unlink(path)
370
+ except FileNotFoundError:
371
+ pass
372
+
373
+
374
+ async def download(
375
+ client: Any,
376
+ message: Any,
377
+ dest: Path | str,
378
+ *,
379
+ fast: bool = True,
380
+ connections: int = 8,
381
+ progress: Optional[ProgressCb] = None,
382
+ cancel: Optional[asyncio.Event] = None,
383
+ max_bytes: Optional[int] = None,
384
+ ) -> None:
385
+ """Download a message's media to ``dest`` (optionally only the first ``max_bytes``)."""
386
+ if fast and getattr(message, "document", None) is not None:
387
+ try:
388
+ await fast_download(
389
+ client, message.document, dest, connections=connections, progress=progress, cancel=cancel,
390
+ max_bytes=max_bytes,
391
+ )
392
+ return
393
+ except Cancelled:
394
+ raise
395
+ except Exception as exc: # noqa: BLE001
396
+ log.warning("fast download failed (%s: %s); using Telethon's standard download", type(exc).__name__, exc)
397
+ _unlink(dest)
398
+ if max_bytes is not None:
399
+ request = 512 * 1024
400
+ with open(dest, "wb") as fh:
401
+ async for chunk in client.iter_download(
402
+ message.document, request_size=request, limit=max(1, math.ceil(max_bytes / request))
403
+ ):
404
+ fh.write(chunk)
405
+ if cancel is not None and cancel.is_set():
406
+ raise Cancelled()
407
+ fh.truncate(min(fh.tell(), max_bytes))
408
+ return
409
+ await client.download_media(message, file=str(dest), progress_callback=_checked(progress, cancel))
410
+
411
+
412
+ async def upload(
413
+ client: Any,
414
+ path: Path | str,
415
+ file_name: str,
416
+ *,
417
+ fast: bool = True,
418
+ connections: int = 8,
419
+ progress: Optional[ProgressCb] = None,
420
+ cancel: Optional[asyncio.Event] = None,
421
+ ) -> Any:
422
+ """Upload ``path``; returns an ``InputFile``/``InputFileBig`` for ``send_file``."""
423
+ if fast:
424
+ try:
425
+ return await fast_upload(client, path, file_name, connections=connections, progress=progress, cancel=cancel)
426
+ except Cancelled:
427
+ raise
428
+ except Exception as exc: # noqa: BLE001
429
+ log.warning("fast upload failed (%s: %s); using Telethon's standard upload", type(exc).__name__, exc)
430
+ return await client.upload_file(str(path), file_name=file_name, progress_callback=_checked(progress, cancel))
bot/main.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Entry point: ``python -m bot``."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import logging
6
+ import shutil
7
+ import signal
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Awaitable, Callable
11
+
12
+ from . import __version__
13
+ from .config import Config, ConfigError, load_config
14
+ 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 (child.name.startswith("job") or child.name.startswith("tracks")):
25
+ shutil.rmtree(child, ignore_errors=True)
26
+
27
+
28
+ async def build_client(cfg: Config, db: Database):
29
+ from telethon import TelegramClient
30
+ from telethon.sessions import StringSession
31
+
32
+ token_id = cfg.bot_token.split(":")[0]
33
+ session = cfg.session_string
34
+ if not session and await db.kv_get("session_bot") == token_id:
35
+ session = await db.kv_get("session") or "" # keeps the login across Space restarts
36
+ client = TelegramClient(
37
+ StringSession(session or None), cfg.api_id, cfg.api_hash,
38
+ connection_retries=-1, retry_delay=2, auto_reconnect=True, flood_sleep_threshold=60,
39
+ )
40
+ await client.start(bot_token=cfg.bot_token)
41
+ await db.kv_set("session", client.session.save())
42
+ await db.kv_set("session_bot", token_id)
43
+ return client
44
+
45
+
46
+ async def amain() -> int:
47
+ from .app import App
48
+ from .web import start_web
49
+
50
+ cfg = load_config()
51
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
52
+ logging.getLogger("telethon").setLevel(logging.WARNING)
53
+ logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
54
+ log.info("Auto Rename Bot v%s starting", __version__)
55
+
56
+ clean_temp_dir(cfg.temp_dir)
57
+ db = Database(cfg.database_url)
58
+ await db.init()
59
+ if db.url.startswith("sqlite"):
60
+ log.warning("DATABASE_URL is not set: using local SQLite, so settings are lost when the Space restarts")
61
+
62
+ runner = await start_web(cfg.port) # up first: the Space must answer its health check while we log in
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")
69
+ if cfg.log_channel:
70
+ try:
71
+ await app._log_send(f"✅ <b>Bot started</b> · v{__version__} · DC {client.session.dc_id}")
72
+ except Exception as exc: # noqa: BLE001
73
+ log.warning("could not post to LOG_CHANNEL: %s", exc)
74
+
75
+ stop = asyncio.Event()
76
+ loop = asyncio.get_running_loop()
77
+ for sig in (signal.SIGINT, signal.SIGTERM):
78
+ try:
79
+ loop.add_signal_handler(sig, stop.set)
80
+ except NotImplementedError: # Windows
81
+ pass
82
+ disconnected = asyncio.ensure_future(client.run_until_disconnected())
83
+ stopper = asyncio.ensure_future(stop.wait())
84
+ await asyncio.wait({disconnected, stopper}, return_when=asyncio.FIRST_COMPLETED)
85
+ crashed = disconnected.done() and not stop.is_set()
86
+
87
+ log.info("shutting down")
88
+ await app.shutdown()
89
+ await runner.cleanup()
90
+ try:
91
+ await client.disconnect()
92
+ except Exception: # noqa: BLE001
93
+ pass
94
+ await db.close()
95
+ return 1 if crashed else 0
96
+
97
+
98
+ def _run(main: Callable[[], Awaitable[int]]) -> int:
99
+ try:
100
+ import uvloop
101
+ except ImportError:
102
+ return asyncio.run(main())
103
+ if sys.version_info >= (3, 12):
104
+ return asyncio.run(main(), loop_factory=uvloop.new_event_loop)
105
+ uvloop.install()
106
+ return asyncio.run(main())
107
+
108
+
109
+ def main() -> None:
110
+ try:
111
+ code = _run(amain)
112
+ except ConfigError as exc:
113
+ print(f"Configuration error: {exc}", file=sys.stderr)
114
+ sys.exit(2)
115
+ except KeyboardInterrupt:
116
+ code = 0
117
+ sys.exit(code)
118
+
119
+
120
+ if __name__ == "__main__":
121
+ main()
bot/media.py ADDED
@@ -0,0 +1,442 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ffprobe / ffmpeg helpers: probing, track selection, metadata, thumbnails."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import io
6
+ import json
7
+ import re
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+ from typing import Any, Dict, List, Optional, Sequence, Tuple
11
+
12
+ from .errors import Cancelled, MediaError
13
+ from .utils import esc
14
+
15
+ VIDEO_CONTAINERS = {".mkv", ".mp4", ".m4v", ".mov", ".webm", ".mka"}
16
+ AUDIO_TAG_EXTS = {".mp3", ".m4a", ".flac", ".ogg", ".opus"}
17
+
18
+ # canonical name -> accepted aliases (ISO 639-1, 639-2/B, 639-2/T, common names)
19
+ LANGUAGES: Dict[str, Tuple[str, ...]] = {
20
+ "english": ("en", "eng"),
21
+ "japanese": ("ja", "jpn", "jap", "jp"),
22
+ "hindi": ("hi", "hin"),
23
+ "tamil": ("ta", "tam"),
24
+ "telugu": ("te", "tel"),
25
+ "malayalam": ("ml", "mal"),
26
+ "kannada": ("kn", "kan"),
27
+ "bengali": ("bn", "ben", "bangla"),
28
+ "marathi": ("mr", "mar"),
29
+ "punjabi": ("pa", "pan"),
30
+ "gujarati": ("gu", "guj"),
31
+ "urdu": ("ur", "urd"),
32
+ "korean": ("ko", "kor"),
33
+ "chinese": ("zh", "zho", "chi", "mandarin", "cantonese", "cmn", "yue"),
34
+ "spanish": ("es", "spa", "esp"),
35
+ "portuguese": ("pt", "por", "ptbr"),
36
+ "french": ("fr", "fra", "fre"),
37
+ "german": ("de", "deu", "ger"),
38
+ "italian": ("it", "ita"),
39
+ "russian": ("ru", "rus"),
40
+ "arabic": ("ar", "ara"),
41
+ "turkish": ("tr", "tur"),
42
+ "indonesian": ("id", "ind"),
43
+ "thai": ("th", "tha"),
44
+ "vietnamese": ("vi", "vie"),
45
+ "malay": ("ms", "msa", "may"),
46
+ "filipino": ("fil", "tl", "tgl", "tagalog"),
47
+ "polish": ("pl", "pol"),
48
+ "dutch": ("nl", "nld", "dut"),
49
+ "swedish": ("sv", "swe"),
50
+ }
51
+ _ALIAS: Dict[str, str] = {}
52
+ for _name, _aliases in LANGUAGES.items():
53
+ _ALIAS[_name] = _name
54
+ for _a in _aliases:
55
+ _ALIAS[_a] = _name
56
+ _UNDETERMINED = {"", "und", "unk", "zxx", "mul", "mis"}
57
+
58
+
59
+ def canonical_lang(code: str) -> Optional[str]:
60
+ code = (code or "").strip().lower()
61
+ code = re.split(r"[-_]", code)[0]
62
+ if code in _UNDETERMINED:
63
+ return None
64
+ return _ALIAS.get(code)
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # probing
69
+ # ---------------------------------------------------------------------------
70
+
71
+
72
+ @dataclass
73
+ class Stream:
74
+ index: int
75
+ kind: str # video | audio | subtitle | attachment | data
76
+ codec: str = ""
77
+ lang: str = ""
78
+ title: str = ""
79
+ default: bool = False
80
+ forced: bool = False
81
+ attached_pic: bool = False
82
+ channels: int = 0
83
+ width: int = 0
84
+ height: int = 0
85
+
86
+
87
+ @dataclass
88
+ class MediaInfo:
89
+ duration: float = 0.0
90
+ width: int = 0
91
+ height: int = 0
92
+ format_name: str = ""
93
+ tags: Dict[str, str] = field(default_factory=dict)
94
+ streams: List[Stream] = field(default_factory=list)
95
+
96
+ def of(self, kind: str) -> List[Stream]:
97
+ return [s for s in self.streams if s.kind == kind]
98
+
99
+ @property
100
+ def audio(self) -> List[Stream]:
101
+ return self.of("audio")
102
+
103
+ @property
104
+ def subtitles(self) -> List[Stream]:
105
+ return self.of("subtitle")
106
+
107
+ @property
108
+ def video(self) -> List[Stream]:
109
+ return [s for s in self.of("video") if not s.attached_pic]
110
+
111
+
112
+ def _parse_hms(value: str) -> float:
113
+ m = re.fullmatch(r"(\d+):(\d+):(\d+(?:\.\d+)?)", value or "")
114
+ if not m:
115
+ return 0.0
116
+ return int(m.group(1)) * 3600 + int(m.group(2)) * 60 + float(m.group(3))
117
+
118
+
119
+ def parse_probe(data: Dict[str, Any]) -> MediaInfo:
120
+ fmt = data.get("format", {}) or {}
121
+ info = MediaInfo(
122
+ format_name=fmt.get("format_name", ""),
123
+ tags={k.lower(): str(v) for k, v in (fmt.get("tags") or {}).items()},
124
+ )
125
+ stream_durations: List[float] = []
126
+ for raw in data.get("streams", []) or []:
127
+ tags = {k.lower(): str(v) for k, v in (raw.get("tags") or {}).items()}
128
+ disp = raw.get("disposition") or {}
129
+ kind = raw.get("codec_type") or "data"
130
+ stream = Stream(
131
+ index=int(raw.get("index", len(info.streams))),
132
+ kind=kind if kind in {"video", "audio", "subtitle", "attachment", "data"} else "data",
133
+ codec=raw.get("codec_name", ""),
134
+ lang=tags.get("language", ""),
135
+ title=tags.get("title", ""),
136
+ default=bool(disp.get("default")),
137
+ forced=bool(disp.get("forced")),
138
+ attached_pic=bool(disp.get("attached_pic")),
139
+ channels=int(raw.get("channels") or 0),
140
+ width=int(raw.get("width") or 0),
141
+ height=int(raw.get("height") or 0),
142
+ )
143
+ info.streams.append(stream)
144
+ for cand in (raw.get("duration"), tags.get("duration")):
145
+ try:
146
+ value = float(cand) if cand and ":" not in str(cand) else _parse_hms(str(cand))
147
+ except ValueError:
148
+ value = 0.0
149
+ if value:
150
+ stream_durations.append(value)
151
+ break
152
+ try:
153
+ info.duration = float(fmt.get("duration") or 0)
154
+ except ValueError:
155
+ info.duration = 0.0
156
+ if not info.duration and stream_durations:
157
+ info.duration = max(stream_durations)
158
+ for s in info.video:
159
+ info.width, info.height = s.width, s.height
160
+ break
161
+ return info
162
+
163
+
164
+ async def _run(cmd: Sequence[str], cancel: Optional[asyncio.Event] = None) -> Tuple[int, bytes, bytes]:
165
+ try:
166
+ proc = await asyncio.create_subprocess_exec(
167
+ *cmd, stdin=asyncio.subprocess.DEVNULL, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
168
+ )
169
+ except FileNotFoundError as exc:
170
+ raise MediaError(f"{cmd[0]} is not installed") from exc
171
+ comm = asyncio.ensure_future(proc.communicate())
172
+ waiters = [comm]
173
+ cancel_task = None
174
+ if cancel is not None:
175
+ cancel_task = asyncio.ensure_future(cancel.wait())
176
+ waiters.append(cancel_task)
177
+ try:
178
+ await asyncio.wait(waiters, return_when=asyncio.FIRST_COMPLETED)
179
+ if not comm.done():
180
+ proc.kill()
181
+ await proc.wait()
182
+ comm.cancel()
183
+ raise Cancelled()
184
+ out, err = comm.result()
185
+ return proc.returncode or 0, out, err
186
+ finally:
187
+ if cancel_task is not None:
188
+ cancel_task.cancel()
189
+ if not comm.done():
190
+ comm.cancel()
191
+ if proc.returncode is None:
192
+ try:
193
+ proc.kill()
194
+ except ProcessLookupError:
195
+ pass
196
+
197
+
198
+ async def probe(path: Path | str, cancel: Optional[asyncio.Event] = None) -> MediaInfo:
199
+ code, out, err = await _run(
200
+ ["ffprobe", "-v", "error", "-print_format", "json", "-show_format", "-show_streams", str(path)], cancel
201
+ )
202
+ try:
203
+ data = json.loads(out.decode("utf-8", "replace") or "{}")
204
+ except ValueError:
205
+ data = {}
206
+ if not data.get("streams") and code != 0:
207
+ raise MediaError("ffprobe failed: " + err.decode("utf-8", "replace").strip()[-300:])
208
+ return parse_probe(data)
209
+
210
+
211
+ # ---------------------------------------------------------------------------
212
+ # track selection
213
+ # ---------------------------------------------------------------------------
214
+
215
+
216
+ def parse_track_tokens(text: str) -> List[str]:
217
+ """``"English, Japanese"`` / ``"2 1"`` -> ``["english", "japanese"]`` / ``["2", "1"]``."""
218
+ tokens: List[str] = []
219
+ for raw in re.split(r"[\s,;>]+", (text or "").strip().lower()):
220
+ if raw and raw not in tokens:
221
+ tokens.append(raw)
222
+ return tokens
223
+
224
+
225
+ def validate_track_tokens(tokens: Sequence[str]) -> Optional[str]:
226
+ """Return an error message when a token is not a number or a language word."""
227
+ for token in tokens:
228
+ if token.isdigit():
229
+ if int(token) < 1:
230
+ return f"Track numbers start at 1 (got {token})."
231
+ elif not re.fullmatch(r"[a-z][a-z\-_]{0,23}", token):
232
+ return f"“{token}” is not a track number or language name."
233
+ return None
234
+
235
+
236
+ def _matches_language(stream: Stream, token: str) -> bool:
237
+ wanted = _ALIAS.get(token)
238
+ have = canonical_lang(stream.lang)
239
+ if wanted is None: # a language we do not know by name: compare raw codes / title words
240
+ raw = re.split(r"[-_]", stream.lang.lower())[0]
241
+ return raw == token or bool(re.search(rf"\b{re.escape(token)}\b", stream.title.lower()))
242
+ if have is not None:
243
+ return have == wanted
244
+ # untagged track: fall back to words in its title ("English [Dub]")
245
+ names = (wanted,) + tuple(a for a in LANGUAGES[wanted] if len(a) > 3)
246
+ title = stream.title.lower()
247
+ return any(re.search(rf"\b{re.escape(n)}\b", title) for n in names)
248
+
249
+
250
+ def resolve_tokens(streams: Sequence[Stream], tokens: Sequence[str]) -> List[Stream]:
251
+ """Pick streams for ``tokens`` in token order.
252
+
253
+ Numbers are 1-based positions among ``streams``; language words select every
254
+ matching stream (file order). A stream is only returned once.
255
+ """
256
+ chosen: List[Stream] = []
257
+ for token in tokens:
258
+ if token.isdigit():
259
+ pos = int(token)
260
+ matches = [streams[pos - 1]] if 1 <= pos <= len(streams) else []
261
+ else:
262
+ matches = [s for s in streams if _matches_language(s, token)]
263
+ for s in matches:
264
+ if s not in chosen:
265
+ chosen.append(s)
266
+ return chosen
267
+
268
+
269
+ def lang_label(stream: Stream, position: int) -> str:
270
+ canon = canonical_lang(stream.lang)
271
+ if canon:
272
+ return canon.title()
273
+ return stream.lang.upper() if stream.lang and stream.lang.lower() not in _UNDETERMINED else f"Track {position}"
274
+
275
+
276
+ # ---------------------------------------------------------------------------
277
+ # ffmpeg command planning
278
+ # ---------------------------------------------------------------------------
279
+
280
+
281
+ @dataclass
282
+ class Plan:
283
+ args: List[str]
284
+ kept_audio: List[Stream] = field(default_factory=list)
285
+ kept_subtitles: List[Stream] = field(default_factory=list)
286
+ warnings: List[str] = field(default_factory=list)
287
+
288
+
289
+ def _stream_title(template: str, stream: Stream, position: int) -> str:
290
+ return template.replace("{lang}", lang_label(stream, position))
291
+
292
+
293
+ def build_plan(info: MediaInfo, settings: Dict[str, Any], ext: str) -> Optional[Plan]:
294
+ """Work out the ffmpeg options for a file. ``None`` means "nothing to do"."""
295
+ if not settings.get("metadata_on"):
296
+ return None
297
+ ext = (ext or "").lower()
298
+ meta = settings.get("meta") or {}
299
+ keep_audio = list(settings.get("keep_audio") or [])
300
+ keep_subs = list(settings.get("keep_subtitle") or [])
301
+
302
+ if ext in AUDIO_TAG_EXTS:
303
+ args = ["-map", "0", "-c", "copy", "-map_metadata", "0"]
304
+ if meta.get("title"):
305
+ args += ["-metadata", f"title={meta['title']}"]
306
+ artist = meta.get("artist") or meta.get("author")
307
+ if artist:
308
+ args += ["-metadata", f"artist={artist}"]
309
+ return Plan(args) if len(args) > 6 else None
310
+
311
+ if ext not in VIDEO_CONTAINERS:
312
+ return None
313
+
314
+ audio, subs = info.of("audio"), info.of("subtitle")
315
+ warnings: List[str] = []
316
+
317
+ if keep_audio:
318
+ sel_a = resolve_tokens(audio, keep_audio)
319
+ if not sel_a and audio:
320
+ warnings.append("No audio track matched your Keep Audio list, so the original audio was kept.")
321
+ sel_a = list(audio)
322
+ else:
323
+ sel_a = list(audio)
324
+ sel_s = resolve_tokens(subs, keep_subs) if keep_subs else list(subs)
325
+
326
+ filtering = bool(keep_audio or keep_subs)
327
+ has_edits = filtering or any(meta.get(k) for k in ("title", "author", "artist", "audio", "subtitle", "video"))
328
+ if not has_edits:
329
+ return None
330
+
331
+ out_video = info.of("video")
332
+ maps = out_video + sel_a + sel_s + info.of("attachment")
333
+ if not filtering:
334
+ maps += info.of("data")
335
+
336
+ args: List[str] = ["-map_metadata", "0", "-map_chapters", "0"]
337
+ for s in maps:
338
+ args += ["-map", f"0:{s.index}"]
339
+ args += ["-c", "copy"]
340
+
341
+ for key in ("title", "author", "artist"):
342
+ if meta.get(key):
343
+ args += ["-metadata", f"{key}={meta[key]}"]
344
+ if meta.get("video"):
345
+ for j, s in enumerate(out_video):
346
+ if not s.attached_pic:
347
+ args += [f"-metadata:s:v:{j}", f"title={meta['video']}"]
348
+ if meta.get("audio"):
349
+ for j, s in enumerate(sel_a):
350
+ args += [f"-metadata:s:a:{j}", f"title={_stream_title(meta['audio'], s, j + 1)}"]
351
+ if meta.get("subtitle"):
352
+ for j, s in enumerate(sel_s):
353
+ args += [f"-metadata:s:s:{j}", f"title={_stream_title(meta['subtitle'], s, j + 1)}"]
354
+
355
+ if keep_audio and sel_a:
356
+ for j in range(len(sel_a)):
357
+ args += [f"-disposition:a:{j}", "+default" if j == 0 else "-default"]
358
+ if keep_subs and sel_s:
359
+ for j in range(len(sel_s)):
360
+ args += [f"-disposition:s:{j}", "+default" if j == 0 else "-default"]
361
+
362
+ return Plan(args, kept_audio=sel_a, kept_subtitles=sel_s, warnings=warnings)
363
+
364
+
365
+ async def apply_plan(src: Path, dst: Path, plan: Plan, cancel: Optional[asyncio.Event] = None) -> None:
366
+ cmd = [
367
+ "ffmpeg", "-hide_banner", "-nostdin", "-y", "-loglevel", "error",
368
+ "-i", str(src), *plan.args, "-max_muxing_queue_size", "9999", str(dst),
369
+ ]
370
+ code, _out, err = await _run(cmd, cancel)
371
+ if code != 0 or not dst.exists() or dst.stat().st_size == 0:
372
+ raise MediaError("ffmpeg failed: " + err.decode("utf-8", "replace").strip()[-500:])
373
+
374
+
375
+ # ---------------------------------------------------------------------------
376
+ # thumbnails
377
+ # ---------------------------------------------------------------------------
378
+
379
+
380
+ async def make_video_thumbnail(src: Path, dst: Path, duration: float, cancel: Optional[asyncio.Event] = None) -> bool:
381
+ """Grab a frame (~10% in) as a small JPEG. Failure is non-fatal."""
382
+ seek = 1.0 if duration <= 0 else min(max(duration * 0.1, 0.0), 60.0)
383
+ cmd = [
384
+ "ffmpeg", "-hide_banner", "-nostdin", "-y", "-loglevel", "error", "-ss", f"{seek:.2f}", "-i", str(src),
385
+ "-frames:v", "1", "-vf", "scale='min(320,iw)':-2", "-q:v", "4", str(dst),
386
+ ]
387
+ try:
388
+ code, _o, _e = await _run(cmd, cancel)
389
+ except MediaError:
390
+ return False
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
397
+
398
+ with Image.open(io.BytesIO(data)) as img:
399
+ img = img.convert("RGB")
400
+ img.thumbnail((320, 320))
401
+ for quality in (88, 80, 70, 60, 50, 40):
402
+ buf = io.BytesIO()
403
+ img.save(buf, "JPEG", quality=quality, optimize=True)
404
+ if buf.tell() <= 200 * 1024:
405
+ break
406
+ return buf.getvalue()
407
+
408
+
409
+ # ---------------------------------------------------------------------------
410
+ # /tracks output
411
+ # ---------------------------------------------------------------------------
412
+
413
+
414
+ def format_tracks(info: MediaInfo) -> str:
415
+ lines: List[str] = []
416
+ vids = info.video
417
+ if vids:
418
+ v = vids[0]
419
+ res = f" {v.width}x{v.height}" if v.width and v.height else ""
420
+ lines.append(f"🎞 <b>Video</b>: {esc(v.codec)}{res}")
421
+ for label, icon, streams in (("Audio", "🔊", info.audio), ("Subtitles", "💬", info.subtitles)):
422
+ lines.append("")
423
+ if not streams:
424
+ lines.append(f"{icon} <b>{label}</b>: none")
425
+ continue
426
+ lines.append(f"{icon} <b>{label}</b> (use these numbers)")
427
+ for pos, s in enumerate(streams, start=1):
428
+ bits = [lang_label(s, pos)]
429
+ if s.lang and s.lang.lower() not in _UNDETERMINED:
430
+ bits[0] += f" ({esc(s.lang)})"
431
+ if s.codec:
432
+ bits.append(esc(s.codec))
433
+ if s.kind == "audio" and s.channels:
434
+ bits.append(f"{s.channels}ch")
435
+ if s.title:
436
+ bits.append(f"“{esc(s.title)}”")
437
+ if s.default:
438
+ bits.append("default")
439
+ if s.forced:
440
+ bits.append("forced")
441
+ lines.append(f"{pos}. " + " · ".join(bits))
442
+ return "\n".join(lines).strip()
bot/panels.py ADDED
@@ -0,0 +1,265 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Inline-menu panels: pure functions returning ``(html_text, button_rows)``.
2
+
3
+ Button rows are lists of ``(label, callback_data)``; ``to_buttons`` converts them
4
+ for Telethon. Panel texts stay well below Telegram's 1024-character caption
5
+ limit because the home banner makes every panel a photo caption.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, Dict, Iterable, List, Sequence, Tuple
10
+
11
+ from .media import LANGUAGES, canonical_lang
12
+ from .utils import clip, esc
13
+
14
+ Row = List[Tuple[str, str]]
15
+ Panel = Tuple[str, List[Row]]
16
+
17
+ DOT = "◈"
18
+
19
+
20
+ def to_buttons(rows: Sequence[Row]):
21
+ from telethon.tl.custom import Button
22
+
23
+ return [[Button.inline(label, data.encode()) for label, data in row] for row in rows]
24
+
25
+
26
+ def nav(back: str = "", *, home: bool = True) -> List[Row]:
27
+ row: Row = []
28
+ if back:
29
+ row.append(("◀ Back", f"m:{back}"))
30
+ if home and back != "home":
31
+ row.append(("🏠 Home", "m:home"))
32
+ row.append(("✖ Close", "x:close"))
33
+ return [row]
34
+
35
+
36
+ def _check(label: str, active: bool) -> str:
37
+ return f"{label} ✅" if active else label
38
+
39
+
40
+ def _code(value: str, limit: int = 100) -> str:
41
+ return f"<code>{esc(clip(value, limit))}</code>" if value else "—"
42
+
43
+
44
+ def fmt_tokens(tokens: Iterable[str]) -> str:
45
+ """``["english", "2"]`` -> ``English, 2``"""
46
+ out = []
47
+ for t in tokens:
48
+ out.append(t if t.isdigit() else (canonical_lang(t) or t).title())
49
+ return ", ".join(out) if out else "—"
50
+
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # panels
54
+ # ---------------------------------------------------------------------------
55
+
56
+
57
+ def home(s: Dict[str, Any], *, owner: bool) -> Panel:
58
+ channel = f"channel <code>{s['target_channel']}</code>" if s.get("target_channel") else "this chat"
59
+ text = (
60
+ "<b>🏷 Auto Rename Bot</b>\n\n"
61
+ "Send me a file (document, video or audio) and I'll rename it with your template and send it back.\n\n"
62
+ f"<b>Mode:</b> <code>{s['mode'].upper()}</code> · <b>Type:</b> <code>{s['file_type'].upper()}</code>"
63
+ f" · <b>Metadata:</b> <code>{'ON' if s['metadata_on'] else 'OFF'}</code>\n"
64
+ f"<b>Template:</b> {_code(s['template'], 120)}\n"
65
+ f"<b>Send to:</b> {channel}"
66
+ )
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
74
+
75
+
76
+ def rename(s: Dict[str, Any]) -> Panel:
77
+ words = ", ".join(s["remove_words"]) or "—"
78
+ text = (
79
+ "<b>RENAME MODE</b>\n\n"
80
+ "Filename mode reads details from the file name.\n"
81
+ "Caption mode reads details from the message caption.\n\n"
82
+ f"Current mode: <code>{s['mode'].upper()}</code>\n"
83
+ "Command: /mode\n\n"
84
+ f"<b>Template:</b> {_code(s['template'], 120)}\n"
85
+ f"<b>Remove words:</b> {esc(clip(words, 100))}"
86
+ )
87
+ rows: List[Row] = [
88
+ [(_check("Filename", s["mode"] == "filename"), "set:mode:filename"),
89
+ (_check("Caption", s["mode"] == "caption"), "set:mode:caption")],
90
+ [("Template", "in:template"), ("Remove words", "in:remove")],
91
+ ] + nav("home")
92
+ return text, rows
93
+
94
+
95
+ def caption(s: Dict[str, Any], has_thumb: bool) -> Panel:
96
+ text = (
97
+ "<b>CAPTION &amp; THUMBNAIL</b>\n\n"
98
+ f"<b>Caption template:</b> {_code(s['caption'], 160)}\n"
99
+ "Variables: <code>{filename} {original} {title} {season} {episode} {quality} {year} {codec} {size} {duration}</code>\n"
100
+ "Telegram HTML works, e.g. <code>&lt;b&gt;{filename}&lt;/b&gt;</code>\n\n"
101
+ f"<b>Thumbnail:</b> {'✅ saved' if has_thumb else '❌ none (a frame of the video is used)'}\n"
102
+ "Send me a photo to set your thumbnail."
103
+ )
104
+ rows: List[Row] = [
105
+ [("Set Caption", "in:caption"), ("Reset Caption", "clr:caption")],
106
+ [("View Thumb", "th:view"), ("Delete Thumb", "th:del")],
107
+ ] + nav("home")
108
+ return text, rows
109
+
110
+
111
+ def metadata(s: Dict[str, Any]) -> Panel:
112
+ m = s["meta"]
113
+ on = s["metadata_on"]
114
+ lines = [f"<b>🏷 Your metadata is currently: {'On' if on else 'Off'}</b>", ""]
115
+ for label, key in (("Title", "title"), ("Author", "author"), ("Artist", "artist"),
116
+ ("Audio", "audio"), ("Subtitle", "subtitle"), ("Video", "video")):
117
+ lines.append(f"{DOT} <b>{label}</b> ▸ {_code(m.get(key, ''), 60)}")
118
+ lines.append(f"{DOT} <b>Keep Audio</b> ▸ {esc(fmt_tokens(s['keep_audio']))}")
119
+ lines.append(f"{DOT} <b>Keep Subtitle</b> ▸ {esc(fmt_tokens(s['keep_subtitle']))}")
120
+ rows: List[Row] = [
121
+ [(_check("On", on), "set:meta:on"), (_check("Off", not on), "set:meta:off")],
122
+ [("Title", "in:mt_title"), ("Author", "in:mt_author"), ("Artist", "in:mt_artist")],
123
+ [("Audio", "in:mt_audio"), ("Subtitle", "in:mt_subtitle"), ("Video", "in:mt_video")],
124
+ [("Tool", "m:tool")],
125
+ [("How to Set Metadata", "help:meta")],
126
+ ] + nav("home")
127
+ return "\n".join(lines), rows
128
+
129
+
130
+ def tool(s: Dict[str, Any]) -> Panel:
131
+ text = (
132
+ "<b>🛠 TOOL — keep tracks</b>\n\n"
133
+ "Only the tracks you list are kept, in the order you list them. Everything else is dropped.\n\n"
134
+ f"{DOT} <b>Keep Audio</b> ▸ {esc(fmt_tokens(s['keep_audio']))}\n"
135
+ f"{DOT} <b>Keep Subtitle</b> ▸ {esc(fmt_tokens(s['keep_subtitle']))}\n\n"
136
+ "Use language names (<code>English Japanese</code>) or track numbers (<code>2 1</code>).\n"
137
+ "Commands: /keepaudio · /keepsubtitle · /tracks (reply to a file)\n"
138
+ "Needs metadata to be On."
139
+ )
140
+ rows: List[Row] = [
141
+ [("Keep Audio", "in:keep_audio"), ("Keep Subtitle", "in:keep_subtitle")],
142
+ [("Clear Audio", "clr:keep_audio"), ("Clear Subtitle", "clr:keep_subtitle")],
143
+ ] + nav("meta")
144
+ return text, rows
145
+
146
+
147
+ def metadata_help() -> Panel:
148
+ text = (
149
+ "<b>How to set metadata</b>\n\n"
150
+ "Tap a field, then send the value — or use a command:\n"
151
+ "/settitle Tokyo Revengers\n"
152
+ "/setauthor @MyChannel\n"
153
+ "/setartist @MyChannel\n"
154
+ "/setaudio {lang} Audio\n"
155
+ "/setsubtitle {lang}\n"
156
+ "/setvideo @MyChannel\n\n"
157
+ "Send <code>off</code> instead of a value to clear a field. "
158
+ "<code>{lang}</code> becomes each track's language name.\n"
159
+ "Then switch metadata On. Works for mkv, mp4, m4v, mov and webm (title/artist tags for mp3, m4a, flac, ogg, opus)."
160
+ )
161
+ return text, nav("meta")
162
+
163
+
164
+ def filetype(s: Dict[str, Any]) -> Panel:
165
+ cur = s["file_type"]
166
+ text = (
167
+ "<b>FILE TYPE</b>\n\n"
168
+ "How renamed files are sent:\n"
169
+ "• <b>Same as original</b> — video stays video, audio stays audio, a document stays a document\n"
170
+ "• <b>Document / Video / Audio</b> — always send it that way\n\n"
171
+ "The file extension always stays the same.\n\n"
172
+ f"Current: <code>{cur.upper()}</code>\nCommand: /filetype"
173
+ )
174
+ rows: List[Row] = [
175
+ [(_check("Same as original", cur == "same"), "set:ftype:same"),
176
+ (_check("Document", cur == "document"), "set:ftype:document")],
177
+ [(_check("Video", cur == "video"), "set:ftype:video"), (_check("Audio", cur == "audio"), "set:ftype:audio")],
178
+ ] + nav("home")
179
+ return text, rows
180
+
181
+
182
+ 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. <code>/rename New Name</code> — reply to a file for a one-off name.\n"
187
+ "3. /template and /caption — set the naming and caption templates.\n"
188
+ "4. /mode — read details from the file name or the caption.\n"
189
+ "5. /metadata, /keepaudio, /keepsubtitle — tags and track clean-up.\n"
190
+ "6. /filetype — send as document, video or audio.\n"
191
+ "7. Send a photo — it becomes your thumbnail.\n"
192
+ "8. /queue and /cancel — manage running jobs.\n\n"
193
+ "Template variables: <code>{title} {season} {episode} {quality} {year} {codec} {filename} {ext}</code>\n"
194
+ "A [bracket] group is dropped when a variable inside it is empty."
195
+ )
196
+ return text, nav("home")
197
+
198
+
199
+ def admin(owner_id: int, admins: Sequence[int], running: int, waiting: int, log_channel: Any) -> Panel:
200
+ shown = ", ".join(f"<code>{a}</code>" for a in list(admins)[:10]) or "none"
201
+ more = f" (+{len(admins) - 10} more)" if len(admins) > 10 else ""
202
+ text = (
203
+ "<b>ADMIN</b>\n\n"
204
+ f"<b>Owner:</b> <code>{owner_id}</code>\n"
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 &lt;id&gt; · /deladmin &lt;id&gt; · /admins"
209
+ )
210
+ rows: List[Row] = [
211
+ [("📊 Stats", "adm:stats"), ("🟢 Status", "adm:status")],
212
+ [("🛑 Cancel all jobs", "adm:clear")],
213
+ ] + nav("home")
214
+ return text, rows
215
+
216
+
217
+ # ---------------------------------------------------------------------------
218
+ # input prompts
219
+ # ---------------------------------------------------------------------------
220
+
221
+ PROMPTS: Dict[str, str] = {
222
+ "template": (
223
+ "<b>Send the new filename template.</b>\n\n"
224
+ "Variables: <code>{title} {season} {episode} {quality} {year} {codec} {filename} {ext}</code>\n"
225
+ "A [bracket] or (paren) group disappears when a variable inside it is empty.\n"
226
+ "If only an episode number is found, the season defaults to 01.\n\n"
227
+ "Example: <code>[S{season}E{episode}] {title} [{quality}]</code>"
228
+ ),
229
+ "caption": (
230
+ "<b>Send the new caption template.</b>\n\n"
231
+ "Variables: <code>{filename} {original} {title} {season} {episode} {quality} {year} {codec} {size} {duration}</code>\n"
232
+ "Telegram HTML works (<code>&lt;b&gt;</code>, <code>&lt;i&gt;</code>, <code>&lt;code&gt;</code>)."
233
+ ),
234
+ "remove": (
235
+ "<b>Send the words to strip from names.</b>\n\n"
236
+ "Separate them with commas, e.g. <code>@SomeChannel, [Group], www.site.com</code>\n"
237
+ "Send <code>off</code> to clear the list."
238
+ ),
239
+ "mt_title": "<b>Send the Title</b> to write into the file's metadata (or <code>off</code> to clear).",
240
+ "mt_author": "<b>Send the Author</b> to write into the file's metadata (or <code>off</code> to clear).",
241
+ "mt_artist": "<b>Send the Artist</b> to write into the file's metadata (or <code>off</code> to clear).",
242
+ "mt_audio": "<b>Send the title for audio tracks</b> (or <code>off</code> to clear).\n<code>{lang}</code> becomes each track's language, e.g. <code>{lang} Audio</code>.",
243
+ "mt_subtitle": "<b>Send the title for subtitle tracks</b> (or <code>off</code> to clear).\n<code>{lang}</code> becomes each track's language.",
244
+ "mt_video": "<b>Send the title for the video track</b> (or <code>off</code> to clear).",
245
+ "keep_audio": (
246
+ "<b>Which audio tracks should I keep?</b>\n\n"
247
+ "Send language names (<code>English Japanese</code>) or track numbers (<code>2 1</code>). "
248
+ "The order you give is the output order; all other tracks are dropped.\n"
249
+ "Send <code>off</code> to keep everything."
250
+ ),
251
+ "keep_subtitle": (
252
+ "<b>Which subtitle tracks should I keep?</b>\n\n"
253
+ "Send language names (<code>English</code>) or track numbers (<code>2</code>). "
254
+ "The order you give is the output order; all other tracks are dropped.\n"
255
+ "Send <code>off</code> to keep everything."
256
+ ),
257
+ }
258
+
259
+
260
+ 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))
bot/parser.py ADDED
@@ -0,0 +1,396 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Filename parsing, template rendering and file-name sanitising.
2
+
3
+ Everything here is pure (no I/O) so it is easy to test.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ import re
9
+ from dataclasses import dataclass
10
+ from typing import Dict, Iterable, List, Optional, Tuple
11
+
12
+ DEFAULT_TEMPLATE = "[S{season}E{episode}] {title} [{quality}]"
13
+ TEMPLATE_VARS = ("title", "season", "episode", "quality", "year", "codec", "filename", "ext")
14
+ MAX_NAME_BYTES = 200
15
+
16
+ KNOWN_EXTS = {
17
+ "mkv", "mp4", "m4v", "mov", "avi", "webm", "ts", "flv", "wmv", "mpg", "mpeg", "3gp",
18
+ "mp3", "m4a", "flac", "ogg", "opus", "wav", "aac", "wma", "mka",
19
+ "srt", "ass", "ssa", "vtt", "sub",
20
+ "zip", "rar", "7z", "tar", "gz", "pdf", "epub", "cbz", "cbr", "apk", "iso",
21
+ "jpg", "jpeg", "png", "webp", "gif", "txt",
22
+ }
23
+
24
+ _ILLEGAL_RE = re.compile(r'[\x00-\x1f\x7f<>:"/\\|?*]')
25
+ _MULTISPACE_RE = re.compile(r"\s+")
26
+ # tokens that look like an extension but are really part of a release name
27
+ _NOT_EXT_RE = re.compile(r"^(?:\d{3,4}[pi]|[xh]26[45]|hevc|av1|s\d{1,2}e\d{1,4}|e\d{1,4})$", re.I)
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # extension handling
31
+ # ---------------------------------------------------------------------------
32
+
33
+
34
+ def split_ext(name: str, known_only: bool = False) -> Tuple[str, str]:
35
+ """Split ``name`` into ``(stem, ".ext")``. ``ext`` is "" when there is none.
36
+
37
+ ``known_only`` restricts extensions to a whitelist; it is used for names the
38
+ user typed, where something like ``Season 1.5`` must not lose its ``.5``.
39
+ """
40
+ stem, ext = os.path.splitext(name)
41
+ if not ext:
42
+ return name, ""
43
+ body = ext[1:]
44
+ if body.lower() in KNOWN_EXTS:
45
+ return stem, ext
46
+ if (
47
+ not known_only
48
+ and 1 <= len(body) <= 5
49
+ and body.isalnum()
50
+ and re.search(r"[A-Za-z]", body)
51
+ and not _NOT_EXT_RE.match(body)
52
+ ):
53
+ return stem, ext
54
+ return name, ""
55
+
56
+
57
+ def finalize_name(stem: str, ext: str, max_bytes: int = MAX_NAME_BYTES) -> str:
58
+ """Join stem and extension, truncating the stem so the result fits ``max_bytes``."""
59
+ stem = stem.strip()
60
+ if ext and stem.lower().endswith(ext.lower()):
61
+ stem = stem[: -len(ext)].rstrip(" .")
62
+ ext_bytes = len(ext.encode("utf-8"))
63
+ while stem and len(stem.encode("utf-8")) + ext_bytes > max_bytes:
64
+ stem = stem[:-1]
65
+ stem = stem.rstrip(" .-_") or "file"
66
+ return stem + ext
67
+
68
+
69
+ def sanitize(text: str) -> str:
70
+ """Remove characters that are illegal in file names and tidy whitespace."""
71
+ text = _ILLEGAL_RE.sub(" ", text)
72
+ text = _MULTISPACE_RE.sub(" ", text).strip()
73
+ return text.lstrip(".").strip()
74
+
75
+
76
+ # ---------------------------------------------------------------------------
77
+ # parsing
78
+ # ---------------------------------------------------------------------------
79
+
80
+
81
+ @dataclass
82
+ class Info:
83
+ title: str = ""
84
+ season: str = ""
85
+ episode: str = ""
86
+ quality: str = ""
87
+ year: str = ""
88
+ codec: str = ""
89
+
90
+ @property
91
+ def structured(self) -> bool:
92
+ return bool(self.season or self.episode or self.quality or self.year)
93
+
94
+
95
+ _Q = r"(?<![A-Za-z0-9])"
96
+ _QUALITY_RE = re.compile(_Q + r"(?:(2160|1440|1080|720|576|540|480|360|240)[pi]|(4k|uhd))(?![A-Za-z0-9])", re.I)
97
+ _RES_RE = re.compile(_Q + r"\d{3,4}x(\d{3,4})(?![A-Za-z0-9])", re.I)
98
+ _SE_RE = re.compile(_Q + r"S(\d{1,2})[ ._\-]?E(\d{1,4})(?!\d)", re.I)
99
+ _NXN_RE = re.compile(_Q + r"(\d{1,2})x(\d{2,3})(?![A-Za-z0-9])", re.I)
100
+ _SEASON_RE = re.compile(_Q + r"(?:Season|S)[ ._\-]?(\d{1,2})(?![A-Za-z0-9])", re.I)
101
+ _EP_WORD_RE = re.compile(_Q + r"(?:Episode|Ep)[ ._\-]?(\d{1,4})(?!\d)", re.I)
102
+ _E_RE = re.compile(_Q + r"E(\d{1,4})(?![A-Za-z0-9])", re.I)
103
+ _DASH_EP_RE = re.compile(r"\s-\s?(\d{1,4})(?:v\d+)?(?=[\s\[\(._\-]|$)")
104
+ _HASH_EP_RE = re.compile(r"#(\d{1,4})(?!\d)")
105
+ _YEAR_PAREN_RE = re.compile(r"[\(\[]((?:19|20)\d{2})[\)\]]")
106
+ _YEAR_BARE_RE = re.compile(_Q + r"((?:19|20)\d{2})(?![A-Za-z0-9])")
107
+ _CODEC_RE = re.compile(_Q + r"(HEVC|[xh]\.?26[45]|AV1|AVC|VP9)(?![A-Za-z0-9])", re.I)
108
+ _SOURCE_RE = re.compile(_Q + r"(?:WEB[ .\-]?DL|WEB[ .\-]?Rip|Blu[ .\-]?Ray|BDRip|BRRip|HDTV|DVDRip|HDRip|REMUX)(?![A-Za-z0-9])", re.I)
109
+ _LEADING_GROUPS_RE = re.compile(r"^(?:\s*[\[\(][^\]\)]*[\]\)])+\s*")
110
+
111
+ _CODEC_NAMES = {
112
+ "hevc": "HEVC", "x265": "x265", "x264": "x264", "h265": "H265",
113
+ "h264": "H264", "av1": "AV1", "avc": "AVC", "vp9": "VP9",
114
+ }
115
+ _COMMON_HEIGHTS = {240, 360, 480, 540, 576, 720, 1080, 1440, 2160}
116
+
117
+
118
+ def _normalize(text: str) -> str:
119
+ text = text.replace("_", " ")
120
+ if " " not in text.strip():
121
+ text = text.replace(".", " ")
122
+ return _MULTISPACE_RE.sub(" ", text).strip()
123
+
124
+
125
+ def _strip_markup(text: str) -> str:
126
+ return re.sub(r"[*`~​-‏⁠]", "", text)
127
+
128
+
129
+ def _clean_title(text: str) -> str:
130
+ text = re.sub(r"[\[\(]\s*$", "", text.strip())
131
+ text = _MULTISPACE_RE.sub(" ", text.replace("_", " ")).strip(" -._[]()")
132
+ return text
133
+
134
+
135
+ def _fmt_num(value: str) -> str:
136
+ return f"{int(value):02d}"
137
+
138
+
139
+ def parse_info(text: str) -> Info:
140
+ """Extract title / season / episode / quality / year / codec from a release name."""
141
+ raw = _strip_markup(text or "")
142
+ # only strip a *known* extension: callers usually pass a stem already
143
+ stem, _ext = split_ext(raw.strip().splitlines()[0] if raw.strip() else "", known_only=True)
144
+ raw = stem
145
+ norm = _normalize(raw)
146
+ info = Info()
147
+ spans: List[Tuple[int, int]] = [] # marker spans inside `norm`
148
+
149
+ # --- season / episode ---------------------------------------------------
150
+ season = episode = ""
151
+ m = _SE_RE.search(norm)
152
+ if m:
153
+ season, episode = m.group(1), m.group(2)
154
+ spans.append(m.span())
155
+ else:
156
+ m = _NXN_RE.search(norm)
157
+ if m:
158
+ season, episode = m.group(1), m.group(2)
159
+ spans.append(m.span())
160
+ else:
161
+ ms = _SEASON_RE.search(norm)
162
+ if ms:
163
+ season = ms.group(1)
164
+ spans.append(ms.span())
165
+ for rx in (_EP_WORD_RE, _E_RE, _DASH_EP_RE, _HASH_EP_RE):
166
+ me = rx.search(norm)
167
+ if me:
168
+ episode = me.group(1)
169
+ spans.append(me.span())
170
+ break
171
+ if episode and not season:
172
+ season = "1"
173
+ if episode:
174
+ info.episode = _fmt_num(episode)
175
+ if season:
176
+ info.season = _fmt_num(season)
177
+
178
+ # --- quality --------------------------------------------------------------
179
+ mq = _QUALITY_RE.search(norm)
180
+ if mq:
181
+ info.quality = "2160p" if mq.group(2) else f"{mq.group(1)}p"
182
+ spans.append(mq.span())
183
+ else:
184
+ mr = _RES_RE.search(norm)
185
+ if mr and int(mr.group(1)) in _COMMON_HEIGHTS:
186
+ info.quality = f"{int(mr.group(1))}p"
187
+ spans.append(mr.span())
188
+
189
+ # --- year -------------------------------------------------------------------
190
+ year_span: Optional[Tuple[int, int]] = None
191
+ paren_years = list(_YEAR_PAREN_RE.finditer(norm))
192
+ if paren_years:
193
+ mp = paren_years[-1]
194
+ info.year = mp.group(1)
195
+ year_span = mp.span()
196
+ else:
197
+ candidates = []
198
+ for my in _YEAR_BARE_RE.finditer(norm):
199
+ if my.start() == 0:
200
+ continue
201
+ if any(a <= my.start() < b for a, b in spans):
202
+ continue
203
+ candidates.append(my)
204
+ if mq:
205
+ before = [c for c in candidates if c.start() < mq.start()]
206
+ candidates = before or candidates
207
+ if candidates:
208
+ chosen = candidates[-1]
209
+ info.year = chosen.group(1)
210
+ year_span = chosen.span()
211
+ if year_span:
212
+ spans.append(year_span)
213
+
214
+ # --- codec --------------------------------------------------------------------
215
+ mc = _CODEC_RE.search(raw) or _CODEC_RE.search(norm)
216
+ if mc:
217
+ info.codec = _CODEC_NAMES.get(mc.group(1).lower().replace(".", ""), mc.group(1))
218
+ mcn = _CODEC_RE.search(norm)
219
+ if mcn:
220
+ spans.append(mcn.span())
221
+ ms2 = _SOURCE_RE.search(norm)
222
+ if ms2:
223
+ spans.append(ms2.span())
224
+
225
+ # --- title ------------------------------------------------------------------------
226
+ body = _LEADING_GROUPS_RE.sub("", norm)
227
+ shift = len(norm) - len(body)
228
+ local = [(a - shift, b - shift) for a, b in spans if b - shift > 0]
229
+ # markers sitting at the very start ("S01E05 Some Title") are dropped first
230
+ while True:
231
+ trimmed = body.lstrip(" -._")
232
+ lead = len(body) - len(trimmed)
233
+ if lead:
234
+ body = trimmed
235
+ local = [(a - lead, b - lead) for a, b in local if b - lead > 0]
236
+ continue
237
+ hit = next(((a, b) for a, b in local if a <= 0 < b), None)
238
+ if hit is None:
239
+ break
240
+ body = body[hit[1]:]
241
+ local = [(a - hit[1], b - hit[1]) for a, b in local if b - hit[1] > 0]
242
+ cut = len(body)
243
+ for a, _b in local:
244
+ if 0 < a < cut:
245
+ cut = a
246
+ # generic bracket groups after the title also end it
247
+ mb = re.search(r"\s[\[\(]", body)
248
+ if mb and mb.start() < cut:
249
+ cut = mb.start()
250
+ info.title = _clean_title(body[:cut])
251
+ return info
252
+
253
+
254
+ def parse_caption(caption: str) -> Info:
255
+ """Parse a message caption: title from the first line, details from anywhere."""
256
+ lines = [ln.strip() for ln in _strip_markup(caption or "").splitlines() if ln.strip()]
257
+ if not lines:
258
+ return Info()
259
+ first = parse_info(lines[0])
260
+ if len(lines) == 1:
261
+ return first
262
+ full = parse_info(" ".join(lines))
263
+ for field in ("season", "episode", "quality", "year", "codec"):
264
+ if not getattr(first, field) and getattr(full, field):
265
+ setattr(first, field, getattr(full, field))
266
+ return first
267
+
268
+
269
+ # ---------------------------------------------------------------------------
270
+ # templates
271
+ # ---------------------------------------------------------------------------
272
+
273
+ _TEMPLATE_RE = re.compile(r"\[[^\[\]]*\]|\([^()]*\)|\{(\w+)\}")
274
+ _TOKEN_RE = re.compile(r"\{(\w+)\}")
275
+
276
+
277
+ def render_template(template: str, values: Dict[str, str]) -> str:
278
+ """Render ``template``.
279
+
280
+ A bracketed ``[...]`` / ``(...)`` group is dropped completely when any known
281
+ variable inside it is empty, so ``[S{season}E{episode}]`` vanishes instead of
282
+ leaving ``[SE]`` behind.
283
+ """
284
+
285
+ def sub_tokens(text: str, group: bool) -> Optional[str]:
286
+ empty = False
287
+
288
+ def repl(m: "re.Match[str]") -> str:
289
+ nonlocal empty
290
+ name = m.group(1)
291
+ if name not in values:
292
+ return m.group(0)
293
+ val = values.get(name, "")
294
+ if not val:
295
+ empty = True
296
+ return val
297
+
298
+ out = _TOKEN_RE.sub(repl, text)
299
+ return None if (group and empty) else out
300
+
301
+ def repl_all(m: "re.Match[str]") -> str:
302
+ whole = m.group(0)
303
+ if m.group(1) is not None: # bare {token}
304
+ return sub_tokens(whole, group=False) or ""
305
+ out = sub_tokens(whole, group=True)
306
+ return out if out is not None else ""
307
+
308
+ rendered = _TEMPLATE_RE.sub(repl_all, template)
309
+ rendered = re.sub(r"\[\s*\]|\(\s*\)", "", rendered)
310
+ return _MULTISPACE_RE.sub(" ", rendered).strip(" -._")
311
+
312
+
313
+ def apply_remove_words(text: str, words: Iterable[str]) -> str:
314
+ for word in words:
315
+ word = word.strip()
316
+ if word:
317
+ text = re.sub(re.escape(word), "", text, flags=re.I)
318
+ text = re.sub(r"\[\s*\]|\(\s*\)", "", text)
319
+ return _MULTISPACE_RE.sub(" ", text).strip(" -._")
320
+
321
+
322
+ def sample_values() -> Tuple[str, Dict[str, str]]:
323
+ sample = "[SubsPlease] Tokyo Revengers - 05 (1080p) [ABCD1234].mkv"
324
+ info = parse_info(sample)
325
+ return sample, build_values(info, "[SubsPlease] Tokyo Revengers - 05 (1080p) [ABCD1234]", ".mkv")
326
+
327
+
328
+ def build_values(info: Info, original_stem: str, ext: str) -> Dict[str, str]:
329
+ return {
330
+ "title": info.title or _clean_title(original_stem),
331
+ "season": info.season,
332
+ "episode": info.episode,
333
+ "quality": info.quality,
334
+ "year": info.year,
335
+ "codec": info.codec,
336
+ "filename": original_stem,
337
+ "ext": ext.lstrip("."),
338
+ }
339
+
340
+
341
+ def compute_new_name(
342
+ *,
343
+ template: str,
344
+ mode: str,
345
+ original_name: str,
346
+ original_ext: str,
347
+ caption: str = "",
348
+ remove_words: Iterable[str] = (),
349
+ ) -> Tuple[str, Info]:
350
+ """Return ``(new_file_name, info)`` for an incoming file."""
351
+ stem, ext = split_ext(original_name)
352
+ if not ext:
353
+ ext = original_ext or ""
354
+ info = parse_info(stem)
355
+ if mode == "caption" and caption.strip():
356
+ cinfo = parse_caption(caption)
357
+ if cinfo.structured:
358
+ for field in ("title", "season", "episode", "quality", "year", "codec"):
359
+ if not getattr(cinfo, field) and getattr(info, field):
360
+ setattr(cinfo, field, getattr(info, field))
361
+ info = cinfo
362
+ values = build_values(info, stem, ext)
363
+ rendered = render_template(template or DEFAULT_TEMPLATE, values)
364
+ rendered = apply_remove_words(rendered, remove_words)
365
+ out_stem = sanitize(rendered) or sanitize(stem)
366
+ return finalize_name(out_stem, ext), info
367
+
368
+
369
+ def build_manual_name(text: str, original_ext: str) -> str:
370
+ """Name typed by the user (``/rename``): the original extension is always kept."""
371
+ text = sanitize(text)
372
+ stem, typed_ext = split_ext(text, known_only=True)
373
+ ext = original_ext or typed_ext
374
+ return finalize_name(stem or "file", ext)
375
+
376
+
377
+ # ---------------------------------------------------------------------------
378
+ # captions
379
+ # ---------------------------------------------------------------------------
380
+
381
+ CAPTION_VARS = ("filename", "original", "title", "season", "episode", "quality", "year", "codec", "ext", "size", "duration")
382
+
383
+
384
+ def render_caption(template: str, values: Dict[str, str]) -> str:
385
+ """Fill ``{variables}`` in a caption template.
386
+
387
+ The template is Telegram-HTML supplied by the user; the *values* are escaped.
388
+ Newlines are preserved, unknown ``{tokens}`` are left alone.
389
+ """
390
+ import html
391
+
392
+ def repl(m: "re.Match[str]") -> str:
393
+ name = m.group(1)
394
+ return html.escape(values[name], quote=False) if name in values else m.group(0)
395
+
396
+ return _TOKEN_RE.sub(repl, template)
bot/pipeline.py ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The rename pipeline: download -> (ffmpeg) -> upload."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import mimetypes
6
+ import shutil
7
+ import time
8
+ import traceback
9
+ from pathlib import Path
10
+ from typing import Any, Awaitable, Callable, Dict, Optional
11
+
12
+ from telethon import errors, types
13
+
14
+ from . import fasttransfer as transfer
15
+ 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
22
+ from .utils import clip, esc, human_duration, human_size
23
+
24
+ log = logging.getLogger(__name__)
25
+
26
+ MIME_OVERRIDES = {
27
+ ".mkv": "video/x-matroska", ".mka": "audio/x-matroska", ".webm": "video/webm", ".mp4": "video/mp4",
28
+ ".m4v": "video/x-m4v", ".mov": "video/quicktime", ".avi": "video/x-msvideo", ".ts": "video/mp2t",
29
+ ".mp3": "audio/mpeg", ".m4a": "audio/mp4", ".flac": "audio/flac", ".ogg": "audio/ogg", ".opus": "audio/ogg",
30
+ ".wav": "audio/x-wav", ".srt": "application/x-subrip", ".ass": "text/x-ssa", ".zip": "application/zip",
31
+ }
32
+ # containers worth running ffprobe on
33
+ PROBE_EXTS = media.VIDEO_CONTAINERS | media.AUDIO_TAG_EXTS | {".avi", ".ts", ".flv", ".wmv", ".mpg", ".mpeg", ".3gp", ".wav", ".aac", ".wma"}
34
+
35
+
36
+ def guess_mime(name: str, fallback: Optional[str]) -> str:
37
+ ext = split_ext(name)[1].lower()
38
+ return MIME_OVERRIDES.get(ext) or mimetypes.guess_type(name)[0] or fallback or "application/octet-stream"
39
+
40
+
41
+ def decide_send_type(setting: str, message: Any) -> str:
42
+ """How the renamed file is sent: ``document`` | ``video`` | ``audio``."""
43
+ if setting in ("document", "video", "audio"):
44
+ return setting
45
+ if getattr(message, "video", None) is not None:
46
+ return "video"
47
+ if getattr(message, "audio", None) is not None:
48
+ return "audio"
49
+ return "document"
50
+
51
+
52
+ def source_ext(message: Any, original_name: str) -> str:
53
+ ext = split_ext(original_name)[1]
54
+ if not ext:
55
+ ext = getattr(message.file, "ext", None) or ""
56
+ return ext
57
+
58
+
59
+ def original_name_of(message: Any) -> str:
60
+ f = message.file
61
+ if f is not None and f.name:
62
+ return f.name
63
+ kind = "video" if getattr(message, "video", None) is not None else "audio" if getattr(message, "audio", None) is not None else "file"
64
+ return f"{kind}_{message.id}{(f.ext if f is not None else '') or ''}"
65
+
66
+
67
+ class Renamer:
68
+ def __init__(self, client: Any, cfg: Config, db: Database, log_channel_send: Optional[Callable[[str], Awaitable[None]]] = None):
69
+ self.client = client
70
+ self.cfg = cfg
71
+ self.db = db
72
+ self._log_send = log_channel_send
73
+
74
+ # ------------------------------------------------------------------
75
+ async def _notify_log(self, text: str) -> None:
76
+ if self._log_send is not None:
77
+ try:
78
+ await self._log_send(text)
79
+ except Exception as exc: # noqa: BLE001
80
+ log.warning("could not write to the log channel: %s", exc)
81
+
82
+ async def _fresh_message(self, job: Job) -> Any:
83
+ """Re-fetch the message so its file reference is valid even after a long queue wait."""
84
+ msg = await self.client.get_messages(job.chat_id, ids=job.msg_id)
85
+ if msg is None or msg.file is None:
86
+ raise RuntimeError("the original message was deleted")
87
+ return msg
88
+
89
+ def _check_space(self, work: Path, size: int, will_remux: bool) -> None:
90
+ need = int(size * (2.2 if will_remux else 1.1)) + 64 * 1024 * 1024
91
+ free = shutil.disk_usage(work).free
92
+ if free < need:
93
+ raise RuntimeError(f"not enough free disk space ({human_size(free)} free, about {human_size(need)} needed)")
94
+
95
+ # ------------------------------------------------------------------
96
+ async def process(self, job: Job) -> None:
97
+ s = await self.db.get_settings(job.user_id)
98
+ work = Path(self.cfg.temp_dir) / f"job{job.id}"
99
+ work.mkdir(parents=True, exist_ok=True)
100
+ reporter = ProgressReporter(self.client, job.status_msg, job.id, job.new_name, self.cfg.progress_interval)
101
+ started = time.monotonic()
102
+ try:
103
+ await self._run(job, s, work, reporter, started)
104
+ except Cancelled:
105
+ job.state = "cancelled"
106
+ await reporter.finish("❌ <b>Cancelled</b>\n<code>" + esc(clip(job.new_name, 120)) + "</code>")
107
+ except Exception as exc: # noqa: BLE001
108
+ job.state = "failed"
109
+ log.exception("job %s failed", job.id)
110
+ reason = clip(f"{type(exc).__name__}: {exc}", 300)
111
+ await reporter.finish(f"❌ <b>Failed</b>\n<code>{esc(clip(job.new_name, 120))}</code>\n\n{esc(reason)}")
112
+ tb = "".join(traceback.format_exception_only(type(exc), exc)).strip()
113
+ await self._notify_log(f"❌ <b>Job failed</b> (user <code>{job.user_id}</code>)\n<code>{esc(clip(job.file_name, 100))}</code>\n<code>{esc(clip(tb, 600))}</code>")
114
+ finally:
115
+ shutil.rmtree(work, ignore_errors=True)
116
+
117
+ async def _run(self, job: Job, s: Dict[str, Any], work: Path, reporter: ProgressReporter, started: float) -> None:
118
+ client, cfg = self.client, self.cfg
119
+ msg = await self._fresh_message(job)
120
+ orig_name = original_name_of(msg)
121
+ ext = source_ext(msg, orig_name)
122
+ send_type = decide_send_type(s["file_type"], msg)
123
+ size = int(msg.file.size or job.size or 0)
124
+ src = work / f"src{ext}"
125
+
126
+ self._check_space(work, size, will_remux=bool(s["metadata_on"]))
127
+
128
+ # ---- download ------------------------------------------------------------
129
+ reporter.set_stage("⬇️ Downloading", size)
130
+ await transfer.download(
131
+ client, msg, src, fast=cfg.fast_transfer, connections=cfg.connections, progress=reporter, cancel=job.cancel
132
+ )
133
+ if job.cancel.is_set():
134
+ raise Cancelled()
135
+
136
+ # ---- probe / ffmpeg -----------------------------------------------------------
137
+ info: Optional[media.MediaInfo] = None
138
+ current = src
139
+ if ext.lower() in PROBE_EXTS or send_type in ("video", "audio"):
140
+ try:
141
+ info = await media.probe(src, job.cancel)
142
+ except MediaError as exc:
143
+ log.info("ffprobe could not read %s: %s", orig_name, exc)
144
+ warnings = []
145
+ plan = media.build_plan(info, s, ext) if (info is not None and s["metadata_on"]) else None
146
+ if s["metadata_on"] and plan is None and info is not None and ext.lower() not in (media.VIDEO_CONTAINERS | media.AUDIO_TAG_EXTS):
147
+ warnings.append(f"Metadata is not supported for {ext or 'this file type'}, so it was skipped.")
148
+ if plan is not None:
149
+ warnings.extend(plan.warnings)
150
+ reporter.set_stage("⚙️ Applying metadata & tracks", 0)
151
+ out = work / f"out{ext}"
152
+ await media.apply_plan(src, out, plan, job.cancel)
153
+ src.unlink(missing_ok=True)
154
+ current = out
155
+ info = await media.probe(current, job.cancel)
156
+
157
+ final_size = current.stat().st_size
158
+
159
+ # ---- how it will be sent -------------------------------------------------------
160
+ if send_type == "video" and (info is None or not info.of("video")):
161
+ send_type = "document"
162
+ if send_type == "audio" and (info is None or not info.of("audio")):
163
+ send_type = "document"
164
+ meta = s["meta"] if s["metadata_on"] else {}
165
+ attrs = [types.DocumentAttributeFilename(job.new_name)]
166
+ if send_type == "video":
167
+ attrs.append(
168
+ types.DocumentAttributeVideo(
169
+ duration=info.duration or 0, w=info.width or 1, h=info.height or 1, supports_streaming=True
170
+ )
171
+ )
172
+ elif send_type == "audio":
173
+ attrs.append(
174
+ types.DocumentAttributeAudio(
175
+ duration=int(info.duration or 0),
176
+ title=(meta.get("title") or info.tags.get("title") or None),
177
+ performer=(meta.get("artist") or meta.get("author") or info.tags.get("artist") or None),
178
+ )
179
+ )
180
+
181
+ thumb = await self.db.get_thumb(job.user_id)
182
+ if thumb is None and ext.lower() in media.VIDEO_CONTAINERS and info is not None and info.of("video"):
183
+ thumb_path = work / "thumb.jpg"
184
+ if await media.make_video_thumbnail(current, thumb_path, info.duration, job.cancel):
185
+ thumb = thumb_path.read_bytes()
186
+
187
+ # ---- caption ---------------------------------------------------------------------
188
+ parsed = parse_info(split_ext(orig_name)[0])
189
+ values = build_values(parsed, split_ext(orig_name)[0], ext)
190
+ values.update(
191
+ filename=split_ext(job.new_name)[0], original=orig_name, ext=ext.lstrip("."),
192
+ size=human_size(final_size), duration=human_duration(info.duration) if info and info.duration else "",
193
+ )
194
+ caption = render_caption(s["caption"], values)[:1000] or None
195
+
196
+ # ---- upload ---------------------------------------------------------------------------
197
+ reporter.set_stage("⬆️ Uploading", final_size)
198
+ input_file = await transfer.upload(
199
+ client, current, job.new_name, fast=cfg.fast_transfer, connections=cfg.connections,
200
+ progress=reporter, cancel=job.cancel,
201
+ )
202
+ if job.cancel.is_set():
203
+ raise Cancelled()
204
+ target = s.get("target_channel") or job.chat_id
205
+ kwargs: Dict[str, Any] = dict(
206
+ caption=caption, attributes=attrs, mime_type=guess_mime(job.new_name, getattr(msg.file, "mime_type", None)),
207
+ force_document=(send_type == "document"), thumb=thumb, parse_mode="html",
208
+ )
209
+ if target == job.chat_id:
210
+ kwargs["reply_to"] = job.msg_id
211
+ try:
212
+ await client.send_file(target, input_file, **kwargs)
213
+ except (errors.EntityBoundsInvalidError, errors.MediaCaptionTooLongError, errors.MessageTooLongError):
214
+ # the caption template produced something Telegram rejects: send the file without a caption
215
+ kwargs.update(caption=None)
216
+ await client.send_file(target, input_file, **kwargs)
217
+ warnings.append("The caption was rejected by Telegram (check your caption template), so it was left out.")
218
+
219
+ await self.db.record_job(job.user_id, final_size)
220
+ job.state = "done"
221
+ took = time.monotonic() - started
222
+ note = "".join(f"\n⚠️ {esc(w)}" for w in warnings)
223
+ where = "" if target == job.chat_id else "\n📣 Sent to your channel."
224
+ await reporter.finish(
225
+ f"✅ <b>Done</b> in {human_duration(took)}\n<code>{esc(clip(job.new_name, 120))}</code>\n{human_size(final_size)}{where}{note}"
226
+ )
227
+ await self._notify_log(
228
+ f"✅ <code>{job.user_id}</code>: <code>{esc(clip(job.file_name, 80))}</code> → "
229
+ f"<code>{esc(clip(job.new_name, 80))}</code> ({human_size(final_size)}, {human_duration(took)})"
230
+ )
bot/progress.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Throttled progress messages (speed, ETA, cancel button)."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import logging
6
+ import time
7
+ from typing import Any, Optional
8
+
9
+ from .utils import clip, esc, human_duration, human_size, progress_bar
10
+
11
+ log = logging.getLogger(__name__)
12
+
13
+
14
+ class ProgressReporter:
15
+ """Callable ``(current, total)`` that edits a status message at most every ``interval`` seconds."""
16
+
17
+ def __init__(self, client: Any, message: Any, job_id: int, name: str, interval: float = 4.0):
18
+ self.client = client
19
+ self.message = message
20
+ self.job_id = job_id
21
+ self.name = name
22
+ self.interval = interval
23
+ self.stage = ""
24
+ self._last_edit = 0.0
25
+ self._muted_until = 0.0
26
+ self._inflight: Optional[asyncio.Future] = None
27
+ self._speed = 0.0
28
+ self._last_t = 0.0
29
+ self._last_bytes = 0
30
+
31
+ # ---- helpers -------------------------------------------------------------
32
+ def _buttons(self):
33
+ from telethon.tl.custom import Button
34
+
35
+ return [[Button.inline("✖ Cancel", f"job:x:{self.job_id}".encode())]]
36
+
37
+ def render(self, current: int, total: int) -> str:
38
+ head = f"<b>{self.stage}</b>\n<code>{esc(clip(self.name, 120))}</code>\n"
39
+ if total <= 0:
40
+ return head + "\nWorking…"
41
+ frac = current / total
42
+ eta = (total - current) / self._speed if self._speed > 0 else 0
43
+ speed = f"{human_size(self._speed)}/s" if self._speed > 0 else "…"
44
+ eta_text = human_duration(eta) if self._speed > 0 else "…"
45
+ return (
46
+ f"{head}\n{progress_bar(frac)} {frac * 100:.1f}%\n"
47
+ f"{human_size(current)} / {human_size(total)} · {speed} · ETA {eta_text}"
48
+ )
49
+
50
+ async def _edit(self, text: str, buttons: Any, after: Optional[asyncio.Future] = None) -> None:
51
+ from telethon import errors
52
+
53
+ if after is not None: # keep edits in order: wait for the one still in flight
54
+ try:
55
+ await after
56
+ except Exception: # noqa: BLE001
57
+ pass
58
+ try:
59
+ await self.client.edit_message(self.message, text, buttons=buttons, parse_mode="html")
60
+ except errors.MessageNotModifiedError:
61
+ pass
62
+ except errors.FloodWaitError as exc:
63
+ self._muted_until = time.monotonic() + exc.seconds + 1
64
+ except Exception as exc: # noqa: BLE001 - progress is best effort
65
+ log.debug("progress edit failed: %s", exc)
66
+
67
+ # ---- public API -----------------------------------------------------------
68
+ def set_stage(self, stage: str, total: int = 0) -> None:
69
+ """Switch stage and show it right away."""
70
+ self.stage = stage
71
+ self._speed = 0.0
72
+ self._last_t = time.monotonic()
73
+ self._last_bytes = 0
74
+ self._last_edit = time.monotonic()
75
+ self._schedule(self.render(0, total), force=True)
76
+
77
+ def _schedule(self, text: str, force: bool = False) -> None:
78
+ """Queue an edit; ordinary progress ticks are dropped while one is in flight, a stage change is not."""
79
+ busy = self._inflight is not None and not self._inflight.done()
80
+ if (busy and not force) or time.monotonic() < self._muted_until:
81
+ return
82
+ self._inflight = asyncio.ensure_future(self._edit(text, self._buttons(), after=self._inflight if busy else None))
83
+
84
+ def __call__(self, current: int, total: int) -> None:
85
+ now = time.monotonic()
86
+ dt = now - self._last_t
87
+ if dt >= 1.0:
88
+ inst = max(0, current - self._last_bytes) / dt
89
+ self._speed = inst if self._speed == 0 else self._speed * 0.6 + inst * 0.4
90
+ self._last_t, self._last_bytes = now, current
91
+ if now - self._last_edit < self.interval and current < total:
92
+ return
93
+ self._last_edit = now
94
+ self._schedule(self.render(current, total))
95
+
96
+ async def finish(self, text: str) -> None:
97
+ """Final text, buttons removed."""
98
+ if self._inflight is not None:
99
+ try:
100
+ await self._inflight
101
+ except Exception: # noqa: BLE001
102
+ pass
103
+ self._muted_until = 0.0
104
+ await self._edit(text, None)
bot/queue.py ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FIFO job queue with a small pool of workers."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import itertools
6
+ import logging
7
+ import time
8
+ from dataclasses import dataclass, field
9
+ from typing import Any, Awaitable, Callable, Dict, List, Optional
10
+
11
+ log = logging.getLogger(__name__)
12
+
13
+
14
+ @dataclass
15
+ class Job:
16
+ id: int
17
+ user_id: int
18
+ chat_id: int
19
+ msg_id: int
20
+ file_name: str
21
+ size: int
22
+ new_name: str
23
+ manual: bool = False
24
+ status_msg: Any = None
25
+ state: str = "queued" # queued | running | done | failed | cancelled
26
+ cancel: asyncio.Event = field(default_factory=asyncio.Event)
27
+ created: float = field(default_factory=time.time)
28
+ started: float = 0.0
29
+
30
+
31
+ Runner = Callable[[Job], Awaitable[None]]
32
+
33
+
34
+ class JobQueue:
35
+ def __init__(self, runner: Runner, workers: int = 1, on_cancelled: Optional[Runner] = None):
36
+ self._runner = runner
37
+ self._on_cancelled = on_cancelled
38
+ self._workers = max(1, workers)
39
+ self._queue: "asyncio.Queue[Job]" = asyncio.Queue()
40
+ self._ids = itertools.count(1)
41
+ self._tasks: List[asyncio.Task] = []
42
+ self.jobs: Dict[int, Job] = {}
43
+
44
+ def new_id(self) -> int:
45
+ return next(self._ids)
46
+
47
+ def start(self) -> None:
48
+ for _ in range(self._workers):
49
+ self._tasks.append(asyncio.ensure_future(self._worker()))
50
+
51
+ async def stop(self) -> None:
52
+ for job in list(self.jobs.values()):
53
+ job.cancel.set()
54
+ for task in self._tasks:
55
+ task.cancel()
56
+ await asyncio.gather(*self._tasks, return_exceptions=True)
57
+ self._tasks.clear()
58
+
59
+ def add(self, job: Job) -> int:
60
+ """Queue a job; returns how many jobs are ahead of it (running + waiting)."""
61
+ ahead = self.running + self.waiting
62
+ self.jobs[job.id] = job
63
+ self._queue.put_nowait(job)
64
+ return ahead
65
+
66
+ @property
67
+ def running(self) -> int:
68
+ return sum(1 for j in self.jobs.values() if j.state == "running")
69
+
70
+ @property
71
+ def waiting(self) -> int:
72
+ return sum(1 for j in self.jobs.values() if j.state == "queued")
73
+
74
+ def position(self, job: Job) -> int:
75
+ """0 = running now, 1 = next up, ..."""
76
+ if job.state == "running":
77
+ return 0
78
+ waiting = sorted((j for j in self.jobs.values() if j.state == "queued"), key=lambda j: j.id)
79
+ try:
80
+ return waiting.index(job) + 1
81
+ except ValueError:
82
+ return 0
83
+
84
+ def for_user(self, user_id: int) -> List[Job]:
85
+ return sorted((j for j in self.jobs.values() if j.user_id == user_id), key=lambda j: j.id)
86
+
87
+ def cancel(self, job_id: int, user_id: Optional[int] = None) -> bool:
88
+ job = self.jobs.get(job_id)
89
+ if job is None or (user_id is not None and job.user_id != user_id):
90
+ return False
91
+ return self._cancel_job(job)
92
+
93
+ def cancel_user(self, user_id: int) -> int:
94
+ return sum(self._cancel_job(j) for j in self.for_user(user_id))
95
+
96
+ def cancel_all(self) -> int:
97
+ return sum(self._cancel_job(j) for j in list(self.jobs.values()))
98
+
99
+ def _cancel_job(self, job: Job) -> bool:
100
+ if job.state in ("done", "failed", "cancelled") or job.cancel.is_set():
101
+ return False # finished, or a cancel is already on its way
102
+ job.cancel.set()
103
+ if job.state == "queued":
104
+ job.state = "cancelled"
105
+ self.jobs.pop(job.id, None)
106
+ if self._on_cancelled is not None:
107
+ asyncio.ensure_future(self._safe(self._on_cancelled, job))
108
+ return True
109
+
110
+ async def _safe(self, fn: Runner, job: Job) -> None:
111
+ try:
112
+ await fn(job)
113
+ except Exception: # noqa: BLE001
114
+ log.exception("job callback failed")
115
+
116
+ async def _worker(self) -> None:
117
+ while True:
118
+ job = await self._queue.get()
119
+ try:
120
+ if job.state != "queued": # cancelled while waiting
121
+ continue
122
+ job.state = "running"
123
+ job.started = time.time()
124
+ try:
125
+ await self._runner(job)
126
+ except asyncio.CancelledError:
127
+ raise
128
+ except Exception: # noqa: BLE001 - the runner reports its own failures
129
+ log.exception("job %s crashed", job.id)
130
+ job.state = "failed"
131
+ finally:
132
+ if job.state == "running":
133
+ job.state = "done"
134
+ self.jobs.pop(job.id, None)
135
+ finally:
136
+ self._queue.task_done()
bot/settings_ops.py ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Validation + persistence for every user-editable setting.
2
+
3
+ Used by both the slash commands and the inline "send me the value" flow, so the
4
+ two can never disagree. Functions return ``(ok, html_message)``.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ from typing import Optional, Tuple
10
+
11
+ from .db import DEFAULT_SETTINGS, Database
12
+ from .media import parse_track_tokens, validate_track_tokens
13
+ from .parser import (
14
+ CAPTION_VARS,
15
+ TEMPLATE_VARS,
16
+ finalize_name,
17
+ render_template,
18
+ sample_values,
19
+ sanitize,
20
+ )
21
+ from .panels import fmt_tokens
22
+ from .utils import clip, esc
23
+
24
+ CLEAR_WORDS = {"off", "none", "clear", "reset", "-", "remove"}
25
+ META_KINDS = {
26
+ "mt_title": "title", "mt_author": "author", "mt_artist": "artist",
27
+ "mt_audio": "audio", "mt_subtitle": "subtitle", "mt_video": "video",
28
+ }
29
+ META_COMMANDS = {
30
+ "settitle": "mt_title", "setauthor": "mt_author", "setartist": "mt_artist",
31
+ "setaudio": "mt_audio", "setsubtitle": "mt_subtitle", "setvideo": "mt_video",
32
+ }
33
+ _TOKEN_RE = re.compile(r"\{(\w+)\}")
34
+
35
+
36
+ def preview_template(template: str) -> str:
37
+ sample, values = sample_values()
38
+ out = finalize_name(sanitize(render_template(template, values)), ".mkv")
39
+ return f"Preview for <code>{esc(sample)}</code>:\n<code>{esc(out)}</code>"
40
+
41
+
42
+ def _is_clear(text: str) -> bool:
43
+ return text.strip().lower() in CLEAR_WORDS
44
+
45
+
46
+ async def apply_setting(db: Database, uid: int, kind: str, text: str) -> Tuple[bool, str]:
47
+ text = (text or "").strip()
48
+
49
+ if kind == "template":
50
+ if not text:
51
+ return False, "❌ The template can't be empty."
52
+ if _is_clear(text):
53
+ text = DEFAULT_SETTINGS["template"]
54
+ if len(text) > 200:
55
+ return False, "❌ That template is too long (200 characters max)."
56
+ unknown = sorted(set(_TOKEN_RE.findall(text)) - set(TEMPLATE_VARS))
57
+ await db.update_settings(uid, template=text)
58
+ msg = "✅ Template saved.\n" + preview_template(text)
59
+ if unknown:
60
+ msg += "\n⚠️ Unknown variables (kept as plain text): " + ", ".join("{%s}" % u for u in unknown)
61
+ return True, msg
62
+
63
+ if kind == "caption":
64
+ if not text:
65
+ return False, "❌ The caption can't be empty. Send <code>reset</code> to restore the default."
66
+ if _is_clear(text):
67
+ text = DEFAULT_SETTINGS["caption"]
68
+ if len(text) > 800:
69
+ return False, "❌ That caption is too long (800 characters max)."
70
+ unknown = sorted(set(_TOKEN_RE.findall(text)) - set(CAPTION_VARS))
71
+ await db.update_settings(uid, caption=text)
72
+ msg = "✅ Caption template saved."
73
+ if unknown:
74
+ msg += "\n⚠️ Unknown variables (kept as plain text): " + ", ".join("{%s}" % u for u in unknown)
75
+ return True, msg
76
+
77
+ if kind == "remove":
78
+ if _is_clear(text):
79
+ await db.update_settings(uid, remove_words=[])
80
+ return True, "✅ Remove-words list cleared."
81
+ words = [w.strip() for w in re.split(r"[,\n]", text) if w.strip()]
82
+ if not words:
83
+ return False, "❌ Send at least one word, separated by commas."
84
+ if len(words) > 50 or any(len(w) > 60 for w in words):
85
+ return False, "❌ Up to 50 words of 60 characters each."
86
+ await db.update_settings(uid, remove_words=words)
87
+ return True, "✅ I'll strip: " + esc(", ".join(words))
88
+
89
+ if kind in META_KINDS:
90
+ field = META_KINDS[kind]
91
+ if not text:
92
+ return False, "❌ Send a value, or <code>off</code> to clear it."
93
+ if _is_clear(text):
94
+ await db.update_settings(uid, meta={field: ""})
95
+ return True, f"✅ {field.title()} cleared."
96
+ if len(text) > 200:
97
+ return False, "❌ That's too long (200 characters max)."
98
+ await db.update_settings(uid, meta={field: text})
99
+ note = ""
100
+ if not (await db.get_settings(uid))["metadata_on"]:
101
+ note = "\nℹ️ Metadata is Off — switch it On in /metadata for this to apply."
102
+ return True, f"✅ {field.title()} set to <code>{esc(clip(text, 80))}</code>.{note}"
103
+
104
+ if kind in ("keep_audio", "keep_subtitle"):
105
+ label = "Keep Audio" if kind == "keep_audio" else "Keep Subtitle"
106
+ if _is_clear(text):
107
+ await db.update_settings(uid, **{kind: []})
108
+ return True, f"✅ {label} cleared — all tracks are kept."
109
+ tokens = parse_track_tokens(text)
110
+ if not tokens:
111
+ return False, "❌ Send language names (<code>English Japanese</code>) or numbers (<code>2 1</code>)."
112
+ problem = validate_track_tokens(tokens)
113
+ if problem:
114
+ return False, "❌ " + esc(problem)
115
+ if len(tokens) > 12:
116
+ return False, "❌ That's too many tracks (12 max)."
117
+ await db.update_settings(uid, **{kind: tokens})
118
+ msg = f"✅ {label}: {esc(fmt_tokens(tokens))}"
119
+ if not (await db.get_settings(uid))["metadata_on"]:
120
+ await db.update_settings(uid, metadata_on=True)
121
+ msg += "\nℹ️ Metadata was Off, so I turned it On. Use /metadata to switch it off."
122
+ return True, msg
123
+
124
+ raise ValueError(f"unknown setting kind {kind!r}")
125
+
126
+
127
+ def parse_channel(text: str) -> Tuple[bool, Optional[int], str]:
128
+ """``-1001234567890`` -> id; ``off`` -> None."""
129
+ text = text.strip()
130
+ if _is_clear(text):
131
+ return True, None, ""
132
+ if re.fullmatch(r"-?\d{5,20}", text):
133
+ return True, int(text), ""
134
+ return False, None, "❌ Send the numeric chat id, like <code>-1001234567890</code> (not @username)."
bot/sysinfo.py ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Uptime / ping / resource numbers for /status and /stats.
2
+
3
+ Inside a container psutil reports the *host's* CPU and RAM, so the cgroup files
4
+ are read first and psutil is only the fallback.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import os
10
+ import platform
11
+ import shutil
12
+ import time
13
+ from typing import Optional, Tuple
14
+
15
+ import psutil
16
+
17
+ from . import __version__
18
+ from .utils import human_size, human_uptime
19
+
20
+ START_TIME = time.time()
21
+ CGROUP_ROOT = "/sys/fs/cgroup"
22
+ _NET_BASE = psutil.net_io_counters()
23
+
24
+
25
+ def _read(path: str) -> Optional[str]:
26
+ try:
27
+ with open(path, "r", encoding="utf-8") as fh:
28
+ return fh.read().strip()
29
+ except OSError:
30
+ return None
31
+
32
+
33
+ def parse_cpu_max(text: Optional[str]) -> Optional[float]:
34
+ """cgroup v2 ``cpu.max`` -> number of cores, ``None`` when unlimited."""
35
+ if not text:
36
+ return None
37
+ parts = text.split()
38
+ if len(parts) != 2 or parts[0] == "max":
39
+ return None
40
+ try:
41
+ quota, period = int(parts[0]), int(parts[1])
42
+ except ValueError:
43
+ return None
44
+ return quota / period if quota > 0 and period > 0 else None
45
+
46
+
47
+ def parse_mem_limit(text: Optional[str]) -> Optional[int]:
48
+ """cgroup ``memory.max`` / v1 ``limit_in_bytes`` -> bytes, ``None`` when unlimited."""
49
+ if not text or text.strip() == "max":
50
+ return None
51
+ try:
52
+ value = int(text.strip())
53
+ except ValueError:
54
+ return None
55
+ return value if value < 2 ** 60 else None
56
+
57
+
58
+ def parse_stat_value(text: Optional[str], key: str) -> Optional[int]:
59
+ for line in (text or "").splitlines():
60
+ parts = line.split()
61
+ if len(parts) == 2 and parts[0] == key:
62
+ try:
63
+ return int(parts[1])
64
+ except ValueError:
65
+ return None
66
+ return None
67
+
68
+
69
+ def cpu_cores() -> float:
70
+ quota = parse_cpu_max(_read(f"{CGROUP_ROOT}/cpu.max"))
71
+ return quota if quota else float(psutil.cpu_count() or 1)
72
+
73
+
74
+ def _cgroup_cpu_usec() -> Optional[int]:
75
+ return parse_stat_value(_read(f"{CGROUP_ROOT}/cpu.stat"), "usage_usec")
76
+
77
+
78
+ async def cpu_percent(interval: float = 0.5) -> float:
79
+ """CPU use of this container as a percentage of its own limit."""
80
+ first = _cgroup_cpu_usec()
81
+ if first is None:
82
+ return psutil.cpu_percent(interval=None)
83
+ t0 = time.monotonic()
84
+ await asyncio.sleep(interval)
85
+ second = _cgroup_cpu_usec()
86
+ elapsed = time.monotonic() - t0
87
+ if second is None or elapsed <= 0:
88
+ return psutil.cpu_percent(interval=None)
89
+ return max(0.0, min(100.0, (second - first) / (elapsed * 1_000_000) / cpu_cores() * 100))
90
+
91
+
92
+ def memory_usage() -> Tuple[int, int]:
93
+ """(used, total) bytes for this container."""
94
+ current = _read(f"{CGROUP_ROOT}/memory.current")
95
+ limit = parse_mem_limit(_read(f"{CGROUP_ROOT}/memory.max"))
96
+ if current is None: # cgroup v1
97
+ current = _read(f"{CGROUP_ROOT}/memory/memory.usage_in_bytes")
98
+ limit = parse_mem_limit(_read(f"{CGROUP_ROOT}/memory/memory.limit_in_bytes"))
99
+ host_total = psutil.virtual_memory().total
100
+ if current is None or not current.isdigit():
101
+ vm = psutil.virtual_memory()
102
+ return vm.used, vm.total
103
+ used = int(current)
104
+ inactive = parse_stat_value(_read(f"{CGROUP_ROOT}/memory.stat"), "inactive_file") or 0
105
+ used = max(0, used - inactive) # do not count reclaimable page cache
106
+ return used, min(limit, host_total) if limit else host_total
107
+
108
+
109
+ def disk_usage(path: str) -> Tuple[int, int, int]:
110
+ os.makedirs(path, exist_ok=True)
111
+ du = shutil.disk_usage(path)
112
+ return du.used, du.total, du.free
113
+
114
+
115
+ def net_since_start() -> Tuple[int, int]:
116
+ now = psutil.net_io_counters()
117
+ return max(0, now.bytes_recv - _NET_BASE.bytes_recv), max(0, now.bytes_sent - _NET_BASE.bytes_sent)
118
+
119
+
120
+ def uptime() -> float:
121
+ return time.time() - START_TIME
122
+
123
+
124
+ def format_status(ping_ms: Optional[float], dc_id: Optional[int], telethon_version: str) -> str:
125
+ ping = f"{ping_ms:.0f} ms" if ping_ms is not None else "n/a"
126
+ dc = f" (DC {dc_id})" if dc_id else ""
127
+ return (
128
+ "🟢 <b>Bot status</b>\n\n"
129
+ f"⏱ <b>Uptime:</b> {human_uptime(uptime())}\n"
130
+ f"🏓 <b>Ping:</b> {ping}{dc}\n"
131
+ f"🤖 <b>Version:</b> v{__version__}\n"
132
+ f"🐍 Python {platform.python_version()} · Telethon {telethon_version}"
133
+ )
134
+
135
+
136
+ async def format_stats(users: int, files: int, total_bytes: int, running: int, waiting: int, temp_dir: str) -> str:
137
+ cpu = await cpu_percent()
138
+ used, total = memory_usage()
139
+ d_used, d_total, d_free = disk_usage(temp_dir)
140
+ rx, tx = net_since_start()
141
+ rss = psutil.Process().memory_info().rss
142
+ ram_pct = used / total * 100 if total else 0.0
143
+ disk_pct = d_used / d_total * 100 if d_total else 0.0
144
+ return (
145
+ "📊 <b>System stats</b>\n\n"
146
+ f"👥 <b>Users:</b> {users}\n"
147
+ f"📁 <b>Files renamed:</b> {files} ({human_size(total_bytes)})\n"
148
+ f"🧵 <b>Queue:</b> {running} running · {waiting} waiting\n\n"
149
+ f"🖥 <b>CPU:</b> {cpu:.1f}% of {cpu_cores():g} core(s)\n"
150
+ f"🧠 <b>RAM:</b> {human_size(used)} / {human_size(total)} ({ram_pct:.1f}%) · bot {human_size(rss)}\n"
151
+ f"💾 <b>Disk:</b> {human_size(d_used)} / {human_size(d_total)} ({disk_pct:.1f}%) · {human_size(d_free)} free\n"
152
+ f"🌐 <b>Network since start:</b> ↓ {human_size(rx)} · ↑ {human_size(tx)}"
153
+ )
bot/utils.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Small formatting helpers."""
2
+ from __future__ import annotations
3
+
4
+ import html
5
+
6
+
7
+ def esc(value: object) -> str:
8
+ return html.escape(str(value), quote=False)
9
+
10
+
11
+ def human_size(num: float) -> str:
12
+ num = float(num)
13
+ for unit in ("B", "KB", "MB", "GB", "TB"):
14
+ if abs(num) < 1024.0 or unit == "TB":
15
+ return f"{num:.0f} {unit}" if unit == "B" else f"{num:.2f} {unit}"
16
+ num /= 1024.0
17
+ return f"{num:.2f} TB"
18
+
19
+
20
+ def human_duration(seconds: float) -> str:
21
+ seconds = int(max(0, seconds))
22
+ h, rem = divmod(seconds, 3600)
23
+ m, s = divmod(rem, 60)
24
+ return f"{h}:{m:02d}:{s:02d}" if h else f"{m}:{s:02d}"
25
+
26
+
27
+ def human_uptime(seconds: float) -> str:
28
+ seconds = int(max(0, seconds))
29
+ d, rem = divmod(seconds, 86400)
30
+ h, rem = divmod(rem, 3600)
31
+ m, s = divmod(rem, 60)
32
+ parts = []
33
+ if d:
34
+ parts.append(f"{d}d")
35
+ if d or h:
36
+ parts.append(f"{h}h")
37
+ parts.append(f"{m}m")
38
+ parts.append(f"{s}s")
39
+ return " ".join(parts)
40
+
41
+
42
+ def progress_bar(fraction: float, width: int = 14) -> str:
43
+ fraction = min(1.0, max(0.0, fraction))
44
+ filled = int(round(fraction * width))
45
+ return "█" * filled + "░" * (width - filled)
46
+
47
+
48
+ def clip(text: str, limit: int) -> str:
49
+ return text if len(text) <= limit else text[: max(0, limit - 1)] + "…"
bot/web.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tiny health-check server.
2
+
3
+ A Hugging Face Docker Space expects something listening on its app port; your
4
+ cron pinger can hit ``/`` or ``/health`` to keep the Space awake.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+
10
+ from aiohttp import web
11
+
12
+ from . import __version__, sysinfo
13
+ from .utils import human_uptime
14
+
15
+ log = logging.getLogger(__name__)
16
+
17
+
18
+ def make_app() -> web.Application:
19
+ async def index(_request: web.Request) -> web.Response:
20
+ return web.Response(text=f"Auto Rename Bot v{__version__} is running ✅\nuptime: {human_uptime(sysinfo.uptime())}\n")
21
+
22
+ async def health(_request: web.Request) -> web.Response:
23
+ return web.json_response({"status": "ok", "version": __version__, "uptime_seconds": int(sysinfo.uptime())})
24
+
25
+ app = web.Application()
26
+ app.router.add_get("/", index)
27
+ app.router.add_get("/health", health)
28
+ return app
29
+
30
+
31
+ async def start_web(port: int, host: str = "0.0.0.0") -> web.AppRunner:
32
+ runner = web.AppRunner(make_app(), access_log=None)
33
+ await runner.setup()
34
+ await web.TCPSite(runner, host, port).start()
35
+ log.info("health server listening on %s:%s", host, port)
36
+ return runner
pytest.ini ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ [pytest]
2
+ asyncio_mode = auto
3
+ testpaths = tests
requirements-dev.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ -r requirements.txt
2
+ pytest>=8
3
+ pytest-asyncio>=0.23
4
+ pyflakes
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ telethon==1.45.0
2
+ cryptg>=0.5
3
+ SQLAlchemy[asyncio]>=2.0.30,<3
4
+ aiosqlite>=0.20
5
+ asyncpg>=0.29
6
+ aiohttp>=3.9
7
+ psutil>=5.9
8
+ Pillow>=10.0
9
+ uvloop>=0.19; sys_platform != "win32"
tests/__init__.py ADDED
File without changes
tests/conftest.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ import shutil
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ 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
+
15
+ SRT_A = "1\n00:00:00,000 --> 00:00:01,000\nkonnichiwa\n\n"
16
+ SRT_B = "1\n00:00:00,000 --> 00:00:01,000\nhello\n\n"
17
+ FFMETA = """;FFMETADATA1
18
+ title=Original Title
19
+ [CHAPTER]
20
+ TIMEBASE=1/1000
21
+ START=0
22
+ END=1000
23
+ title=Intro
24
+ [CHAPTER]
25
+ TIMEBASE=1/1000
26
+ START=1000
27
+ END=2000
28
+ title=Main
29
+ """
30
+
31
+
32
+ def run(cmd):
33
+ subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
34
+
35
+
36
+ def ffprobe_json(path, *extra):
37
+ out = subprocess.run(
38
+ ["ffprobe", "-v", "error", "-print_format", "json", "-show_format", "-show_streams", *extra, str(path)],
39
+ check=True, capture_output=True,
40
+ ).stdout
41
+ return json.loads(out)
42
+
43
+
44
+ @pytest.fixture(scope="session")
45
+ def media_dir(tmp_path_factory):
46
+ d = tmp_path_factory.mktemp("media")
47
+ if not HAVE_FFMPEG:
48
+ return d
49
+ (d / "a.srt").write_text(SRT_A)
50
+ (d / "b.srt").write_text(SRT_B)
51
+ (d / "meta.ffmeta").write_text(FFMETA)
52
+ (d / "font.ttf").write_bytes(os.urandom(2048))
53
+ lavfi_v = ["-f", "lavfi", "-i", "testsrc=duration=2:size=320x240:rate=15"]
54
+ lavfi_a = lambda f: ["-f", "lavfi", "-i", f"sine=frequency={f}:duration=2"] # noqa: E731
55
+
56
+ # MKV: 1 video, 3 audio (jpn, eng, hin), 2 subs (jpn, eng), 1 font attachment, 2 chapters
57
+ run(
58
+ ["ffmpeg", "-y", "-loglevel", "error", *lavfi_v, *lavfi_a(440), *lavfi_a(880), *lavfi_a(220),
59
+ "-i", str(d / "a.srt"), "-i", str(d / "b.srt"), "-i", str(d / "meta.ffmeta"),
60
+ "-attach", str(d / "font.ttf"), "-metadata:s:t:0", "mimetype=application/x-truetype-font",
61
+ "-map", "0:v", "-map", "1:a", "-map", "2:a", "-map", "3:a", "-map", "4:s", "-map", "5:s",
62
+ "-map_metadata", "6", "-map_chapters", "6",
63
+ "-c:v", "libx264", "-preset", "ultrafast", "-c:a", "aac", "-c:s", "srt",
64
+ "-metadata:s:a:0", "language=jpn", "-metadata:s:a:0", "title=Japanese Original",
65
+ "-metadata:s:a:1", "language=eng", "-metadata:s:a:2", "language=hin",
66
+ "-metadata:s:s:0", "language=jpn", "-metadata:s:s:1", "language=eng",
67
+ "-disposition:a:0", "default", str(d / "sample.mkv")]
68
+ )
69
+ # MP4: 1 video, 2 audio (jpn, eng), 1 eng subtitle (mov_text)
70
+ run(
71
+ ["ffmpeg", "-y", "-loglevel", "error", *lavfi_v, *lavfi_a(440), *lavfi_a(880), "-i", str(d / "b.srt"),
72
+ "-map", "0:v", "-map", "1:a", "-map", "2:a", "-map", "3:s",
73
+ "-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", "-c:a", "aac", "-c:s", "mov_text",
74
+ "-metadata:s:a:0", "language=jpn", "-metadata:s:a:1", "language=eng", "-metadata:s:s:0", "language=eng",
75
+ str(d / "sample.mp4")]
76
+ )
77
+ # MP3 with a title tag
78
+ run(
79
+ ["ffmpeg", "-y", "-loglevel", "error", *lavfi_a(440), "-c:a", "libmp3lame", "-metadata", "title=Old",
80
+ str(d / "sample.mp3")]
81
+ )
82
+ return d
tests/test_app.py ADDED
@@ -0,0 +1,822 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
15
+ from bot import panels
16
+ from bot.app import COMMANDS, OWNER_COMMANDS, PANEL_NAMES, App, Pending, cmd_pattern, is_renameable
17
+ from bot.config import load_config
18
+ from bot.db import Database
19
+
20
+ from .conftest import needs_ffmpeg
21
+ from .tgfakes import (
22
+ FakeCallback, FakeClient, FakeEvent, FakeMessage, dispatch, document_message, flat_buttons, make_file,
23
+ )
24
+
25
+ OWNER, ADMIN_CFG, ADMIN_DB, STRANGER = 1000001, 1000002, 1000003, 1999999
26
+ LOG = -1001111111111
27
+ EPISODE = "[SubsPlease] Tokyo Revengers - 05 (1080p) [ABCD1234].mkv"
28
+ EPISODE_NEW = "[S01E05] Tokyo Revengers [1080p].mkv"
29
+
30
+
31
+ def env_for(tmp_path, **extra):
32
+ return {
33
+ "API_ID": "1", "API_HASH": "h", "BOT_TOKEN": "1:t", "OWNER_ID": str(OWNER), "ADMIN_IDS": str(ADMIN_CFG),
34
+ "LOG_CHANNEL": str(LOG), "TEMP_DIR": str(tmp_path / "work"), "FAST_TRANSFER": "0", "MAX_FILE_MB": "100",
35
+ **extra,
36
+ }
37
+
38
+
39
+ @pytest.fixture
40
+ async def ctx(tmp_path):
41
+ cfg = load_config(env_for(tmp_path))
42
+ db = Database(f"sqlite+aiosqlite:///{tmp_path}/app.db")
43
+ await db.init()
44
+ client = FakeClient()
45
+ app = App(client, cfg, db)
46
+ app._register() # setup() minus the queue workers: jobs stay queued so they can be inspected
47
+ yield SimpleNamespace(app=app, client=client, db=db, cfg=cfg, tmp=tmp_path)
48
+ await app.queue.stop()
49
+ await db.close()
50
+
51
+
52
+ async def say(ctx, sender, text="", **kw):
53
+ ev = FakeEvent(sender, text, **kw)
54
+ await dispatch(ctx.client, ev)
55
+ return ev
56
+
57
+
58
+ async def send_doc(ctx, sender, name=EPISODE, size=50 * 1024 * 1024, **kw):
59
+ ev = FakeEvent(sender, message=document_message(name, size, **kw))
60
+ await dispatch(ctx.client, ev)
61
+ return ev
62
+
63
+
64
+ async def click(ctx, sender, data, **kw):
65
+ cb = FakeCallback(sender, data, **kw)
66
+ await dispatch(ctx.client, cb)
67
+ return cb
68
+
69
+
70
+ async def prefs(ctx, uid=OWNER):
71
+ return await ctx.db.get_settings(uid)
72
+
73
+
74
+ def jpeg(size=(640, 480)):
75
+ buf = io.BytesIO()
76
+ Image.new("RGB", size, (200, 30, 30)).save(buf, "JPEG")
77
+ return buf.getvalue()
78
+
79
+
80
+ def data_of(buttons):
81
+ return [d for _l, d in flat_buttons(buttons)]
82
+
83
+
84
+ # ---------------------------------------------------------------------------
85
+ # access control & routing
86
+ # ---------------------------------------------------------------------------
87
+
88
+
89
+ async def test_strangers_are_ignored_and_reported_once(ctx):
90
+ for text in ("/start", "hello", "/rename x", "/cancel"):
91
+ assert (await say(ctx, STRANGER, text)).out == []
92
+ assert (await send_doc(ctx, STRANGER)).out == []
93
+ cb = await click(ctx, STRANGER, "m:home")
94
+ assert cb.edits == [] and not ctx.app.queue.jobs
95
+ reports = [t for chat, t, _kw in ctx.client.sent if chat == LOG]
96
+ assert len(reports) == 1 and str(STRANGER) in reports[0] and f"/addadmin {STRANGER}" in reports[0]
97
+
98
+
99
+ async def test_a_plain_message_from_a_stranger_is_also_reported(ctx):
100
+ await say(ctx, STRANGER, "hi there")
101
+ assert len([1 for chat, _t, _k in ctx.client.sent if chat == LOG]) == 1
102
+
103
+
104
+ async def test_owner_and_admins_from_config_and_db_are_allowed(ctx):
105
+ await ctx.db.add_admin(ADMIN_DB, OWNER)
106
+ ctx.app.admins |= set(await ctx.db.list_admins())
107
+ for uid in (OWNER, ADMIN_CFG, ADMIN_DB):
108
+ assert (await say(ctx, uid, "/help")).out, uid
109
+ assert ctx.client.sent == [] # nobody was reported
110
+
111
+
112
+ async def test_group_chats_are_ignored(ctx):
113
+ assert (await say(ctx, OWNER, "/start", private=False)).out == []
114
+ assert (await say(ctx, STRANGER, "/start", private=False)).out == []
115
+ assert ctx.client.sent == []
116
+
117
+
118
+ async def test_owner_only_commands_are_refused_for_admins(ctx):
119
+ for text in ("/addadmin 123456", "/deladmin 123456", "/admins"):
120
+ ev = await say(ctx, ADMIN_CFG, text)
121
+ assert ev.texts == ["⛔ Only the owner can do that."], text
122
+ assert 123456 not in ctx.app.admins
123
+ assert (await click(ctx, ADMIN_CFG, "m:admin")).alerts == ["Owner only."]
124
+ assert (await click(ctx, ADMIN_CFG, "adm:stats")).alerts == ["Owner only."]
125
+ assert (await click(ctx, ADMIN_CFG, "adm:clear")).alerts == ["Owner only."]
126
+ assert ctx.client.sent == []
127
+
128
+
129
+ def test_cmd_pattern_accepts_args_bot_suffix_and_any_case():
130
+ p = cmd_pattern("rename")
131
+ assert p.match("/rename New Name").group(1) == "New Name"
132
+ assert p.match("/rename@MyBot New Name").group(1) == "New Name"
133
+ assert p.match("/RENAME").group(1) is None
134
+ assert p.match("/rename\nline one\nline two").group(1) == "line one\nline two"
135
+ assert not p.match("/renamex") and not p.match("rename") and not p.match("/re")
136
+
137
+
138
+ def test_every_menu_command_has_a_handler_and_telegram_compatible_text(ctx):
139
+ table = ctx.app._command_table()
140
+ for name, description in COMMANDS:
141
+ assert name in table
142
+ assert name.islower() and 1 <= len(name) <= 32
143
+ assert 3 <= len(description) <= 256
144
+ assert OWNER_COMMANDS <= set(table)
145
+ assert {"settitle", "setauthor", "setartist", "setaudio", "setsubtitle", "setvideo", "removewords", "setchannel"} <= set(table)
146
+ assert dict(COMMANDS)["status"] == "Show basic bot uptime, ping, and version information."
147
+ assert dict(COMMANDS)["stats"] == "Show detailed system stats such as users, CPU, RAM, and disk usage."
148
+
149
+
150
+ async def test_setup_registers_command_menu_loads_admins_and_survives_log_channel_errors(tmp_path):
151
+ cfg = load_config(env_for(tmp_path))
152
+ db = Database(f"sqlite+aiosqlite:///{tmp_path}/setup.db")
153
+ await db.init()
154
+ await db.add_admin(ADMIN_DB, OWNER)
155
+ client = FakeClient()
156
+ client.bad_entities.add(LOG)
157
+ app = App(client, cfg, db)
158
+ await app.setup()
159
+ try:
160
+ assert client.parse_mode == "html"
161
+ assert ADMIN_DB in app.admins and ADMIN_CFG in app.admins
162
+ (request,) = [r for r in client.requests if isinstance(r, functions.bots.SetBotCommandsRequest)]
163
+ assert [c.command for c in request.commands] == [c for c, _ in COMMANDS]
164
+ assert len(client.handlers) > 10
165
+ finally:
166
+ await app.shutdown()
167
+ await db.close()
168
+
169
+
170
+ # ---------------------------------------------------------------------------
171
+ # /start, /help and the one-line settings commands
172
+ # ---------------------------------------------------------------------------
173
+
174
+
175
+ async def test_start_shows_the_home_panel_and_hides_admin_from_admins(ctx):
176
+ ev = await say(ctx, OWNER, "/start")
177
+ text, buttons, kw = ev.out[0]
178
+ assert "Auto Rename Bot" in text and kw["parse_mode"] == "html"
179
+ assert {"m:rename", "m:cap", "m:meta", "m:ftype", "m:help", "m:admin"} <= set(data_of(buttons))
180
+ assert "m:admin" not in data_of((await say(ctx, ADMIN_CFG, "/start")).out[0][1])
181
+
182
+
183
+ @pytest.mark.parametrize("text", ["/start payload", "/start@SomeBot", "/START"])
184
+ async def test_start_variants(ctx, text):
185
+ assert (await say(ctx, OWNER, text)).out
186
+
187
+
188
+ async def test_start_image_is_sent_as_a_photo_with_the_panel_as_caption(ctx):
189
+ ctx.app.cfg = dataclasses.replace(ctx.cfg, start_image="https://example.com/banner.jpg")
190
+ ev = await say(ctx, OWNER, "/start")
191
+ assert ev.out == []
192
+ (chat, file, kw), = ctx.client.files
193
+ assert (chat, file) == (OWNER, "https://example.com/banner.jpg")
194
+ assert "Auto Rename Bot" in kw["caption"] and kw["buttons"] and kw["parse_mode"] == "html"
195
+ ctx.client.fail_send_file = True # a broken banner must not break /start
196
+ ev = await say(ctx, OWNER, "/start")
197
+ assert "Auto Rename Bot" in ev.texts[0]
198
+
199
+
200
+ async def test_start_and_help(ctx):
201
+ ctx.app.pending[OWNER] = Pending("template", OWNER, 1, "rename", False, time.time() + 100)
202
+ await say(ctx, OWNER, "/start")
203
+ assert OWNER not in ctx.app.pending
204
+ ev = await say(ctx, OWNER, "/help")
205
+ assert "How to use" in ev.texts[0] and "x:close" in data_of(ev.out[0][1])
206
+
207
+
208
+ async def test_mode_command(ctx):
209
+ ev = await say(ctx, OWNER, "/mode caption")
210
+ assert (await prefs(ctx))["mode"] == "caption" and "CAPTION" in ev.texts[0]
211
+ for text in ("/mode", "/mode nonsense"):
212
+ ev = await say(ctx, OWNER, text)
213
+ assert "RENAME MODE" in ev.texts[0] and "set:mode:filename" in data_of(ev.out[0][1])
214
+ assert (await prefs(ctx))["mode"] == "caption"
215
+ await say(ctx, OWNER, "/mode FILENAME")
216
+ assert (await prefs(ctx))["mode"] == "filename"
217
+
218
+
219
+ async def test_filetype_command_and_aliases(ctx):
220
+ for arg, expected in (("video", "video"), ("audio", "audio"), ("document", "document"), ("original", "same"),
221
+ ("doc", "document"), ("file", "document"), ("same", "same")):
222
+ await say(ctx, OWNER, f"/filetype {arg}")
223
+ assert (await prefs(ctx))["file_type"] == expected, arg
224
+ ev = await say(ctx, OWNER, "/filetype")
225
+ assert "FILE TYPE" in ev.texts[0]
226
+ ev = await say(ctx, OWNER, "/filetype bogus")
227
+ assert "FILE TYPE" in ev.texts[0] and (await prefs(ctx))["file_type"] == "same"
228
+
229
+
230
+ async def test_metadata_command(ctx):
231
+ await say(ctx, OWNER, "/metadata on")
232
+ assert (await prefs(ctx))["metadata_on"] is True
233
+ await say(ctx, OWNER, "/metadata off")
234
+ assert (await prefs(ctx))["metadata_on"] is False
235
+ ev = await say(ctx, OWNER, "/metadata")
236
+ assert "currently: Off" in ev.texts[0]
237
+
238
+
239
+ async def test_template_caption_and_removewords_commands(ctx):
240
+ ev = await say(ctx, OWNER, "/template [S{season}E{episode}] {title} [{quality}]")
241
+ assert "Template saved" in ev.texts[0]
242
+ assert (await prefs(ctx))["template"] == "[S{season}E{episode}] {title} [{quality}]"
243
+ assert "Current template" in (await say(ctx, OWNER, "/template")).texts[0]
244
+
245
+ await say(ctx, OWNER, "/caption <b>{filename}</b> {size}")
246
+ assert (await prefs(ctx))["caption"] == "<b>{filename}</b> {size}"
247
+ assert "Current caption template" in (await say(ctx, OWNER, "/caption")).texts[0]
248
+
249
+ await say(ctx, OWNER, "/removewords @Chan, [Group]")
250
+ assert (await prefs(ctx))["remove_words"] == ["@Chan", "[Group]"]
251
+ assert "@Chan, [Group]" in (await say(ctx, OWNER, "/removewords")).texts[0]
252
+ await say(ctx, OWNER, "/removewords off")
253
+ assert (await prefs(ctx))["remove_words"] == []
254
+
255
+
256
+ async def test_settings_are_per_user(ctx):
257
+ await say(ctx, OWNER, "/mode caption")
258
+ await say(ctx, ADMIN_CFG, "/filetype audio")
259
+ assert (await prefs(ctx, OWNER))["mode"] == "caption" and (await prefs(ctx, OWNER))["file_type"] == "same"
260
+ assert (await prefs(ctx, ADMIN_CFG))["mode"] == "filename" and (await prefs(ctx, ADMIN_CFG))["file_type"] == "audio"
261
+
262
+
263
+ @pytest.mark.parametrize("command,field", [
264
+ ("settitle", "title"), ("setauthor", "author"), ("setartist", "artist"),
265
+ ("setaudio", "audio"), ("setsubtitle", "subtitle"), ("setvideo", "video"),
266
+ ])
267
+ async def test_metadata_field_commands(ctx, command, field):
268
+ ev = await say(ctx, OWNER, f"/{command} {{lang}} Value")
269
+ assert (await prefs(ctx))["meta"][field] == "{lang} Value" and "✅" in ev.texts[0]
270
+ assert "{lang} Value" in (await say(ctx, OWNER, f"/{command}")).texts[0]
271
+ await say(ctx, OWNER, f"/{command} off")
272
+ assert (await prefs(ctx))["meta"][field] == ""
273
+
274
+
275
+ async def test_keepaudio_by_language_and_by_number(ctx):
276
+ ev = await say(ctx, OWNER, "/keepaudio English Japanese")
277
+ s = await prefs(ctx)
278
+ assert s["keep_audio"] == ["english", "japanese"] and s["metadata_on"] is True and "turned it On" in ev.texts[0]
279
+ await say(ctx, OWNER, "/keepaudio 2 1")
280
+ assert (await prefs(ctx))["keep_audio"] == ["2", "1"]
281
+ assert "2, 1" in (await say(ctx, OWNER, "/keepaudio")).texts[0]
282
+ await say(ctx, OWNER, "/keepaudio off")
283
+ assert (await prefs(ctx))["keep_audio"] == []
284
+ ev = await say(ctx, OWNER, "/keepaudio 0")
285
+ assert ev.texts[0].startswith("❌")
286
+
287
+
288
+ async def test_keepsubtitle_is_independent_of_keepaudio(ctx):
289
+ await say(ctx, OWNER, "/keepaudio 2 1")
290
+ await say(ctx, OWNER, "/keepsubtitle english")
291
+ s = await prefs(ctx)
292
+ assert s["keep_audio"] == ["2", "1"] and s["keep_subtitle"] == ["english"]
293
+ await say(ctx, OWNER, "/keepsubtitle off")
294
+ s = await prefs(ctx)
295
+ assert s["keep_audio"] == ["2", "1"] and s["keep_subtitle"] == []
296
+
297
+
298
+ async def test_setchannel(ctx):
299
+ assert "not set" in (await say(ctx, OWNER, "/setchannel")).texts[0]
300
+ ev = await say(ctx, OWNER, "/setchannel -1002223334445")
301
+ assert (await prefs(ctx))["target_channel"] == -1002223334445 and "✅" in ev.texts[0]
302
+ ev = await say(ctx, OWNER, "/setchannel @name")
303
+ assert ev.texts[0].startswith("❌") and (await prefs(ctx))["target_channel"] == -1002223334445
304
+ ctx.client.bad_entities.add(-1005556667778)
305
+ ev = await say(ctx, OWNER, "/setchannel -1005556667778")
306
+ assert "can't access" in ev.texts[0] and (await prefs(ctx))["target_channel"] == -1002223334445
307
+ await say(ctx, OWNER, "/setchannel off")
308
+ assert (await prefs(ctx))["target_channel"] is None
309
+
310
+
311
+ # ---------------------------------------------------------------------------
312
+ # thumbnails
313
+ # ---------------------------------------------------------------------------
314
+
315
+
316
+ async def test_photo_becomes_the_thumbnail_and_can_be_viewed_and_deleted(ctx):
317
+ ev = FakeEvent(OWNER, message=FakeMessage("", mid=20, photo=True), photo_bytes=jpeg())
318
+ await dispatch(ctx.client, ev)
319
+ assert "Thumbnail saved" in ev.texts[0] and "th:rm" in data_of(ev.out[0][1])
320
+ stored = await ctx.db.get_thumb(OWNER)
321
+ assert stored[:2] == b"\xff\xd8"
322
+ assert await ctx.db.get_thumb(ADMIN_CFG) is None # per user
323
+
324
+ await say(ctx, OWNER, "/thumb")
325
+ (chat, file, kw), = ctx.client.files
326
+ assert chat == OWNER and file.read() == stored and "th:rm" in data_of(kw["buttons"])
327
+
328
+ assert "deleted" in (await say(ctx, OWNER, "/delthumb")).texts[0]
329
+ assert await ctx.db.get_thumb(OWNER) is None
330
+ assert "no thumbnail" in (await say(ctx, OWNER, "/delthumb")).texts[0]
331
+ assert "no thumbnail" in (await say(ctx, OWNER, "/thumb")).texts[0]
332
+
333
+
334
+ async def test_unreadable_photos_are_reported(ctx):
335
+ ev = FakeEvent(OWNER, message=FakeMessage("", photo=True), photo_bytes=b"not an image")
336
+ await dispatch(ctx.client, ev)
337
+ assert "couldn't read" in ev.texts[0]
338
+ ev = FakeEvent(OWNER, message=FakeMessage("", photo=True), photo_bytes=None)
339
+ await dispatch(ctx.client, ev)
340
+ assert "couldn't download" in ev.texts[0]
341
+ assert await ctx.db.get_thumb(OWNER) is None
342
+ stranger = FakeEvent(STRANGER, message=FakeMessage("", photo=True), photo_bytes=jpeg())
343
+ await dispatch(ctx.client, stranger)
344
+ assert stranger.out == [] and await ctx.db.get_thumb(STRANGER) is None
345
+
346
+
347
+ async def test_thumbnail_buttons(ctx):
348
+ cb = await click(ctx, OWNER, "th:view")
349
+ assert cb.alerts and "no thumbnail" in cb.alerts[0]
350
+ await ctx.db.set_thumb(OWNER, b"\xff\xd8thumb")
351
+ cb = await click(ctx, OWNER, "th:view")
352
+ assert cb.answers == [(None, False)] and len(ctx.client.files) == 1
353
+
354
+ cb = await click(ctx, OWNER, "th:del") # inside the Caption & Thumbnail panel
355
+ assert await ctx.db.get_thumb(OWNER) is None and "CAPTION" in cb.edits[-1][0] and "none" in cb.edits[-1][0]
356
+ assert cb.toasts == ["Thumbnail deleted."] and not cb.deleted
357
+
358
+ await ctx.db.set_thumb(OWNER, b"\xff\xd8thumb")
359
+ cb = await click(ctx, OWNER, "th:rm") # under a standalone thumbnail message
360
+ assert await ctx.db.get_thumb(OWNER) is None and cb.deleted and cb.edits == []
361
+
362
+
363
+ # ---------------------------------------------------------------------------
364
+ # renaming
365
+ # ---------------------------------------------------------------------------
366
+
367
+
368
+ async def test_a_file_is_queued_with_the_template_name_and_a_cancel_button(ctx):
369
+ ev = await send_doc(ctx, OWNER, mid=10)
370
+ (job,) = ctx.app.queue.jobs.values()
371
+ assert job.new_name == EPISODE_NEW and (job.user_id, job.chat_id, job.msg_id) == (OWNER, OWNER, 10)
372
+ assert job.file_name == EPISODE and job.size == 50 * 1024 * 1024 and not job.manual and job.state == "queued"
373
+ text, buttons, kw = ev.out[0]
374
+ assert "Queued" in text and "starting soon" in text and EPISODE_NEW in text
375
+ assert flat_buttons(buttons) == [("✖ Cancel", f"job:x:{job.id}")]
376
+ assert job.status_msg is not None
377
+
378
+ ev = await send_doc(ctx, OWNER, "Another.File.mp4", mid=11)
379
+ assert "1 ahead of it" in ev.texts[0]
380
+
381
+
382
+ async def test_caption_mode_reads_the_caption(ctx):
383
+ await say(ctx, OWNER, "/mode caption")
384
+ await send_doc(ctx, OWNER, "xyz123.mkv", caption="Tokyo Revengers S02E07 720p", mid=12)
385
+ (job,) = ctx.app.queue.jobs.values()
386
+ assert job.new_name.endswith(".mkv") and "Tokyo Revengers" in job.new_name and "S02E07" in job.new_name
387
+
388
+
389
+ async def test_remove_words_are_applied_to_the_new_name(ctx):
390
+ await say(ctx, OWNER, "/removewords @SomeChannel")
391
+ await send_doc(ctx, OWNER, "[@SomeChannel] Tokyo Revengers - 05 (1080p).mkv")
392
+ (job,) = ctx.app.queue.jobs.values()
393
+ assert "SomeChannel" not in job.new_name and "Tokyo Revengers" in job.new_name
394
+
395
+
396
+ async def test_rename_command_uses_the_given_name_and_keeps_the_extension(ctx):
397
+ reply = document_message("old.name.mkv", 10 * 1024 * 1024, mid=33)
398
+ ev = await say(ctx, OWNER, "/rename My Custom Name", reply=reply, mid=34)
399
+ (job,) = ctx.app.queue.jobs.values()
400
+ assert job.new_name == "My Custom Name.mkv" and job.manual and job.msg_id == 33
401
+ assert "My Custom Name.mkv" in ev.texts[0]
402
+
403
+ ev = await say(ctx, OWNER, "/rename Movie.2024.mp4", reply=reply, mid=35) # a typed extension never wins
404
+ assert sorted(j.new_name for j in ctx.app.queue.jobs.values())[1].endswith(".mkv")
405
+
406
+
407
+ async def test_rename_usage_messages(ctx):
408
+ for kwargs in (
409
+ {"reply": None}, # no reply
410
+ {"reply": FakeMessage("just text", mid=5)}, # reply to text
411
+ {"reply": FakeMessage("", mid=6, file=make_file("s.webp"), sticker=True)},
412
+ ):
413
+ ev = await say(ctx, OWNER, "/rename New Name", **kwargs)
414
+ assert "Reply to a file" in ev.texts[0], kwargs
415
+ ev = await say(ctx, OWNER, "/rename", reply=document_message()) # reply but no name
416
+ assert "Reply to a file" in ev.texts[0]
417
+ assert not ctx.app.queue.jobs
418
+
419
+
420
+ async def test_oversized_files_are_refused(ctx):
421
+ ev = await send_doc(ctx, OWNER, size=101 * 1024 * 1024)
422
+ assert "limit is 100.00 MB" in ev.texts[0] and not ctx.app.queue.jobs
423
+ ev = await send_doc(ctx, OWNER, size=100 * 1024 * 1024) # exactly at the limit is fine
424
+ assert "Queued" in ev.texts[0]
425
+
426
+
427
+ async def test_stickers_voice_notes_and_gifs_are_not_renamed(ctx):
428
+ for flags in ({"sticker": True}, {"voice": True}, {"gif": True}, {"video_note": True}):
429
+ ev = FakeEvent(OWNER, message=FakeMessage("", file=make_file("x.webm"), **flags))
430
+ await dispatch(ctx.client, ev)
431
+ assert ev.out == [], flags
432
+ assert not ctx.app.queue.jobs
433
+ assert is_renameable(document_message()) and not is_renameable(FakeMessage("text"))
434
+
435
+
436
+ async def test_a_file_without_a_name_gets_a_generated_one(ctx):
437
+ ev = FakeEvent(OWNER, message=FakeMessage("", mid=77, file=make_file(None, 1024, "video/mp4", ext=".mp4"), video=True))
438
+ await dispatch(ctx.client, ev)
439
+ (job,) = ctx.app.queue.jobs.values()
440
+ assert job.file_name == "video_77.mp4" and job.new_name.endswith(".mp4")
441
+
442
+
443
+ # ---------------------------------------------------------------------------
444
+ # queue and cancel
445
+ # ---------------------------------------------------------------------------
446
+
447
+
448
+ async def test_queue_listing_is_scoped_to_the_user_unless_owner(ctx):
449
+ assert "Nothing in the queue" in (await say(ctx, OWNER, "/queue")).texts[0]
450
+ await send_doc(ctx, OWNER, "Owner.File.mkv")
451
+ await send_doc(ctx, ADMIN_CFG, "Admin.File.mkv")
452
+ admin_view = (await say(ctx, ADMIN_CFG, "/queue")).texts[0]
453
+ assert "Admin File" in admin_view and "Owner File" not in admin_view
454
+ owner_view = (await say(ctx, OWNER, "/queue")).texts[0]
455
+ assert "Admin File" in owner_view and "Owner File" in owner_view and str(ADMIN_CFG) in owner_view
456
+
457
+
458
+ async def test_cancel_commands_respect_ownership(ctx):
459
+ await send_doc(ctx, OWNER, "Owner.File.mkv") # job 1
460
+ await send_doc(ctx, ADMIN_CFG, "Admin.File.mkv") # job 2
461
+ await send_doc(ctx, ADMIN_CFG, "Admin.Two.mkv") # job 3
462
+
463
+ assert "couldn't find" in (await say(ctx, ADMIN_CFG, "/cancel 1")).texts[0] # not theirs
464
+ assert ctx.app.queue.jobs[1].state == "queued"
465
+ assert "Cancelling 2 job(s)" in (await say(ctx, ADMIN_CFG, "/cancel all")).texts[0] # only their own
466
+ assert set(ctx.app.queue.jobs) == {1}
467
+ assert "no active jobs" in (await say(ctx, ADMIN_CFG, "/cancel")).texts[0]
468
+
469
+ await send_doc(ctx, ADMIN_CFG, "Admin.Three.mkv") # job 4
470
+ assert "Cancelling 2 job(s)" in (await say(ctx, OWNER, "/cancel all")).texts[0] # owner: jobs 1 and 4
471
+ assert not ctx.app.queue.jobs
472
+
473
+
474
+ async def test_cancelled_queued_jobs_update_their_status_message(ctx):
475
+ await send_doc(ctx, OWNER)
476
+ await say(ctx, OWNER, "/cancel 1")
477
+ for _ in range(5):
478
+ await asyncio.sleep(0)
479
+ (entity, text, kw), = ctx.client.edited
480
+ assert "Cancelled" in text and kw["buttons"] is None
481
+
482
+
483
+ async def test_cancel_button_on_a_queued_file(ctx):
484
+ await send_doc(ctx, OWNER)
485
+ await send_doc(ctx, OWNER, "Second.mkv")
486
+ cb = await click(ctx, ADMIN_CFG, "job:x:1") # somebody else's job
487
+ assert cb.toasts == ["That job already finished."] and 1 in ctx.app.queue.jobs
488
+ cb = await click(ctx, OWNER, "job:x:1")
489
+ assert cb.toasts == ["Cancelling…"] and 1 not in ctx.app.queue.jobs
490
+ assert (await click(ctx, OWNER, "job:x:1")).toasts == ["That job already finished."]
491
+ admin_doc = await send_doc(ctx, ADMIN_CFG, "Admin.mkv")
492
+ assert admin_doc.out
493
+ assert (await click(ctx, OWNER, "job:x:3")).toasts == ["Cancelling…"] # the owner may cancel anything
494
+
495
+
496
+ async def test_cancel_clears_a_pending_prompt_first(ctx):
497
+ ctx.app.pending[OWNER] = Pending("template", OWNER, 1, "rename", False, time.time() + 100)
498
+ await send_doc(ctx, OWNER)
499
+ ev = await say(ctx, OWNER, "/cancel")
500
+ assert "nothing was changed" in ev.texts[0] and OWNER not in ctx.app.pending
501
+ assert ctx.app.queue.jobs # the job is untouched
502
+ assert "Cancelling 1 job(s)" in (await say(ctx, OWNER, "/cancel")).texts[0]
503
+
504
+
505
+ # ---------------------------------------------------------------------------
506
+ # admin management, status and stats
507
+ # ---------------------------------------------------------------------------
508
+
509
+
510
+ async def test_owner_manages_admins(ctx):
511
+ ev = await say(ctx, OWNER, "/addadmin 555555")
512
+ assert "can now use the bot" in ev.texts[0] and 555555 in ctx.app.admins
513
+ assert 555555 in await ctx.db.list_admins()
514
+ assert (await say(ctx, 555555, "/help")).out # works immediately
515
+ assert "already have access" in (await say(ctx, OWNER, "/addadmin 555555")).texts[0]
516
+ assert "already have access" in (await say(ctx, OWNER, f"/addadmin {OWNER}")).texts[0]
517
+ assert "already have access" in (await say(ctx, OWNER, f"/addadmin {ADMIN_CFG}")).texts[0]
518
+ for bad in ("/addadmin", "/addadmin abc", "/addadmin 12", "/addadmin @user"):
519
+ assert "Usage" in (await say(ctx, OWNER, bad)).texts[0], bad
520
+
521
+ listing = (await say(ctx, OWNER, "/admins")).texts[0]
522
+ assert str(OWNER) in listing and "555555" in listing and str(ADMIN_CFG) in listing
523
+
524
+ assert "was removed" in (await say(ctx, OWNER, "/deladmin 555555")).texts[0]
525
+ assert 555555 not in ctx.app.admins and 555555 not in await ctx.db.list_admins()
526
+ assert (await say(ctx, 555555, "/help")).out == [] # access gone
527
+ assert "wasn't in the list" in (await say(ctx, OWNER, "/deladmin 555555")).texts[0]
528
+ assert "ADMIN_IDS" in (await say(ctx, OWNER, f"/deladmin {ADMIN_CFG}")).texts[0]
529
+ assert ADMIN_CFG in ctx.app.admins
530
+ assert "Usage" in (await say(ctx, OWNER, "/deladmin")).texts[0]
531
+
532
+
533
+ async def test_status_pings_telegram_and_reports_version_info(ctx):
534
+ ev = await say(ctx, OWNER, "/status")
535
+ text = ev.texts[0]
536
+ assert "Bot status" in text and "Uptime" in text and "Ping" in text and "Version" in text and "Telethon" in text
537
+ assert any(isinstance(r, functions.PingRequest) for r in ctx.client.requests)
538
+ assert (await say(ctx, ADMIN_CFG, "/status")).out # admins may use it too
539
+
540
+
541
+ async def test_status_survives_a_failed_ping(ctx):
542
+ async def broken(request):
543
+ raise ConnectionError("offline")
544
+
545
+ ctx.client.__class__ = type("Broken", (FakeClient,), {"__call__": lambda self, request: broken(request)})
546
+ assert "n/a" in (await say(ctx, OWNER, "/status")).texts[0]
547
+
548
+
549
+ async def test_stats_reports_users_files_and_system_usage(ctx):
550
+ await say(ctx, ADMIN_CFG, "/help")
551
+ await ctx.db.record_job(OWNER, 3 * 1024 ** 3)
552
+ text = (await say(ctx, OWNER, "/stats")).texts[0]
553
+ for expected in ("System stats", "Users", "Files renamed", "3.00 GB", "Queue", "CPU", "RAM", "Disk"):
554
+ assert expected in text, expected
555
+ assert (await say(ctx, ADMIN_CFG, "/stats")).out
556
+ assert (await say(ctx, STRANGER, "/stats")).out == []
557
+
558
+
559
+ # ---------------------------------------------------------------------------
560
+ # /tracks
561
+ # ---------------------------------------------------------------------------
562
+
563
+
564
+ async def test_tracks_needs_a_reply_to_a_file(ctx):
565
+ for reply in (None, FakeMessage("text", mid=3)):
566
+ assert "Reply to a video or audio file" in (await say(ctx, OWNER, "/tracks", reply=reply)).texts[0]
567
+
568
+
569
+ @needs_ffmpeg
570
+ async def test_tracks_lists_numbered_audio_and_subtitles_and_cleans_up(ctx, media_dir, monkeypatch):
571
+ calls = []
572
+
573
+ async def fake_download(client, message, dest, **kw):
574
+ calls.append(kw)
575
+ shutil.copy(media_dir / "sample.mkv", dest)
576
+
577
+ monkeypatch.setattr(appmod.transfer, "download", fake_download)
578
+ reply = document_message("Show.mkv", (media_dir / "sample.mkv").stat().st_size, mid=33)
579
+ ev = await say(ctx, OWNER, "/tracks", reply=reply, mid=34)
580
+ assert ev.texts == ["🔎 Reading the tracks…"]
581
+ (status, text, kw), = ctx.client.edited
582
+ assert status.id == 1001 and "Show.mkv" in text
583
+ assert "Audio" in text and "Subtitles" in text and "Japanese" in text and "English" in text and "use these numbers" in text
584
+ assert len(calls) == 1 and calls[0]["max_bytes"] == appmod.TRACKS_PROBE_BYTES
585
+ assert not list(Path(ctx.cfg.temp_dir).glob("tracks*"))
586
+
587
+
588
+ @needs_ffmpeg
589
+ async def test_tracks_downloads_the_whole_file_when_the_start_is_not_enough(ctx, media_dir, monkeypatch):
590
+ calls = []
591
+
592
+ async def fake_download(client, message, dest, **kw):
593
+ calls.append(kw)
594
+ if "max_bytes" in kw:
595
+ Path(dest).write_bytes(b"\x00" * 4096) # e.g. an mp4 whose index is at the end
596
+ else:
597
+ shutil.copy(media_dir / "sample.mp4", dest)
598
+
599
+ monkeypatch.setattr(appmod.transfer, "download", fake_download)
600
+ reply = document_message("Show.mp4", 40 * 1024 * 1024, mid=33)
601
+ await say(ctx, OWNER, "/tracks", reply=reply, mid=34)
602
+ assert len(calls) == 2 and "max_bytes" not in calls[1]
603
+ assert "Audio" in ctx.client.edited[-1][1] and "needs the whole file" in ctx.client.edited[0][1]
604
+
605
+
606
+ @needs_ffmpeg
607
+ async def test_tracks_gives_up_gracefully(ctx, monkeypatch):
608
+ async def junk_download(client, message, dest, **kw):
609
+ Path(dest).write_bytes(b"\x00" * 4096)
610
+
611
+ monkeypatch.setattr(appmod.transfer, "download", junk_download)
612
+ for size in (2 * 1024 * 1024, 900 * 1024 * 1024): # small file / too big for a full download
613
+ ctx.client.edited.clear()
614
+ reply = document_message("Bad.mkv", size, mid=33)
615
+ await say(ctx, OWNER, "/tracks", reply=reply, mid=34)
616
+ assert "couldn't read any tracks" in ctx.client.edited[-1][1], size
617
+ assert not list(Path(ctx.cfg.temp_dir).glob("tracks*"))
618
+
619
+
620
+ # ---------------------------------------------------------------------------
621
+ # menus (callbacks)
622
+ # ---------------------------------------------------------------------------
623
+
624
+
625
+ async def test_menu_navigation_edits_the_message_in_place(ctx):
626
+ cb = await click(ctx, OWNER, "m:meta")
627
+ text, buttons = cb.edits[-1]
628
+ assert "metadata is currently" in text and "m:tool" in data_of(buttons)
629
+ assert cb.answers == [(None, False)]
630
+ cb = await click(ctx, OWNER, "m:tool")
631
+ assert "TOOL" in cb.edits[-1][0] and "m:meta" in data_of(cb.edits[-1][1])
632
+ cb = await click(ctx, OWNER, "m:home")
633
+ assert "Auto Rename Bot" in cb.edits[-1][0]
634
+ cb = await click(ctx, OWNER, "m:admin")
635
+ assert "ADMIN" in cb.edits[-1][0]
636
+ cb = await click(ctx, OWNER, "m:bogus")
637
+ assert cb.edits == [] and cb.answers == [(None, False)]
638
+
639
+
640
+ async def test_toggles_update_settings_and_refresh_the_panel(ctx):
641
+ cb = await click(ctx, OWNER, "set:mode:caption")
642
+ assert (await prefs(ctx))["mode"] == "caption" and cb.toasts == ["Mode: Caption"]
643
+ assert "Caption ✅" in [label for label, _d in flat_buttons(cb.edits[-1][1])]
644
+ cb = await click(ctx, OWNER, "set:ftype:video")
645
+ assert (await prefs(ctx))["file_type"] == "video" and cb.toasts == ["File type: Video"]
646
+ cb = await click(ctx, OWNER, "set:ftype:same")
647
+ assert cb.toasts == ["File type: Same as original"]
648
+ cb = await click(ctx, OWNER, "set:meta:on")
649
+ assert (await prefs(ctx))["metadata_on"] is True and cb.toasts == ["Metadata ON"]
650
+ await click(ctx, OWNER, "set:meta:off")
651
+ assert (await prefs(ctx))["metadata_on"] is False
652
+
653
+ before = await prefs(ctx)
654
+ for junk in ("set:mode:bogus", "set:nothing:x", "set:ftype:zip", "set:meta:maybe", "set:mode"):
655
+ cb = await click(ctx, OWNER, junk)
656
+ assert cb.edits == [] and cb.answers == [(None, False)], junk
657
+ assert await prefs(ctx) == before
658
+
659
+
660
+ async def test_clear_buttons(ctx):
661
+ await say(ctx, OWNER, "/caption custom {filename}")
662
+ cb = await click(ctx, OWNER, "clr:caption")
663
+ assert (await prefs(ctx))["caption"] == "{filename}" and "CAPTION" in cb.edits[-1][0]
664
+ await say(ctx, OWNER, "/keepaudio 2 1")
665
+ await say(ctx, OWNER, "/keepsubtitle 1")
666
+ cb = await click(ctx, OWNER, "clr:keep_audio")
667
+ s = await prefs(ctx)
668
+ assert s["keep_audio"] == [] and s["keep_subtitle"] == ["1"] and "TOOL" in cb.edits[-1][0]
669
+ await click(ctx, OWNER, "clr:keep_subtitle")
670
+ assert (await prefs(ctx))["keep_subtitle"] == []
671
+ cb = await click(ctx, OWNER, "clr:whatever")
672
+ assert cb.edits == []
673
+
674
+
675
+ async def test_close_deletes_the_message_and_falls_back_to_editing(ctx):
676
+ ctx.app.pending[OWNER] = Pending("template", OWNER, 1, "rename", False, time.time() + 100)
677
+ cb = await click(ctx, OWNER, "x:close")
678
+ assert cb.deleted and OWNER not in ctx.app.pending
679
+ old = FakeCallback(OWNER, "x:close")
680
+ old.delete_error = RuntimeError("MESSAGE_DELETE_FORBIDDEN")
681
+ await dispatch(ctx.client, old)
682
+ assert old.edits == [("Closed.", None)] and not old.deleted
683
+
684
+
685
+ async def test_metadata_help_and_unknown_callbacks(ctx):
686
+ cb = await click(ctx, OWNER, "help:meta")
687
+ assert "How to set metadata" in cb.edits[-1][0]
688
+ for junk in ("", "zzz", "adm", "th:what", "job:x:abc", "in:nope"):
689
+ cb = await click(ctx, OWNER, junk)
690
+ assert cb.answers and not cb.alerts, junk
691
+
692
+
693
+ async def test_admin_panel_buttons(ctx):
694
+ await send_doc(ctx, OWNER)
695
+ await send_doc(ctx, OWNER, "Two.mkv")
696
+ cb = await click(ctx, OWNER, "adm:stats", chat=OWNER)
697
+ (chat, text, kw), = ctx.client.sent
698
+ assert chat == OWNER and "System stats" in text and "2 waiting" in text
699
+ await click(ctx, OWNER, "adm:status")
700
+ assert "Bot status" in ctx.client.sent[-1][1]
701
+ cb = await click(ctx, OWNER, "adm:clear")
702
+ assert cb.alerts == ["Cancelling 2 job(s)."] and not ctx.app.queue.jobs
703
+
704
+
705
+ @pytest.mark.parametrize("panel", sorted(PANEL_NAMES))
706
+ async def test_every_button_of_every_panel_is_handled(ctx, panel):
707
+ await ctx.db.set_thumb(OWNER, b"\xff\xd8thumb")
708
+ _text, rows = await ctx.app.panel(OWNER, panel)
709
+ clicked = 0
710
+ for row in rows:
711
+ for _label, data in row:
712
+ cb = await click(ctx, OWNER, data)
713
+ assert cb.answers or cb.deleted, (panel, data)
714
+ assert not any("went wrong" in a for a in cb.alerts), (panel, data)
715
+ clicked += 1
716
+ assert clicked >= 1
717
+
718
+
719
+ @pytest.mark.parametrize("kind", sorted(panels.PROMPTS))
720
+ async def test_every_prompt_opens_and_cancels(ctx, kind):
721
+ cb = await click(ctx, OWNER, f"in:{kind}")
722
+ p = ctx.app.pending[OWNER]
723
+ assert p.kind == kind and p.back in PANEL_NAMES and not p.is_photo
724
+ text, buttons = cb.edits[-1]
725
+ assert text == panels.PROMPTS[kind] and data_of(buttons) == [f"in:cancel:{p.back}"]
726
+ cb = await click(ctx, OWNER, f"in:cancel:{p.back}")
727
+ assert OWNER not in ctx.app.pending and cb.toasts == ["Cancelled."] and cb.edits
728
+
729
+
730
+ # ---------------------------------------------------------------------------
731
+ # "tap a button, then send the value"
732
+ # ---------------------------------------------------------------------------
733
+
734
+
735
+ async def test_template_prompt_flow(ctx):
736
+ await click(ctx, OWNER, "in:template", msg_id=500)
737
+ ev = await say(ctx, OWNER, "{title} [{quality}]", mid=60)
738
+ assert ev.deleted and OWNER not in ctx.app.pending # the typed value is removed to keep the chat tidy
739
+ assert (await prefs(ctx))["template"] == "{title} [{quality}]"
740
+ (chat, text, kw), = ctx.client.edited
741
+ assert chat == OWNER and kw["message"] == 500 and "Template saved" in text and "RENAME MODE" in text
742
+ assert "rename" == "rename" and "set:mode:filename" in data_of(kw["buttons"])
743
+
744
+
745
+ async def test_keep_audio_prompt_flow_enables_metadata_and_returns_to_the_tool_panel(ctx):
746
+ await click(ctx, OWNER, "in:keep_audio")
747
+ await say(ctx, OWNER, "English Japanese")
748
+ s = await prefs(ctx)
749
+ assert s["keep_audio"] == ["english", "japanese"] and s["metadata_on"] is True
750
+ (chat, text, kw), = ctx.client.edited
751
+ assert "TOOL" in text and "English, Japanese" in text and "in:keep_subtitle" in data_of(kw["buttons"])
752
+
753
+
754
+ async def test_invalid_input_keeps_the_prompt_open(ctx):
755
+ await click(ctx, OWNER, "in:template")
756
+ ev = await say(ctx, OWNER, "x" * 300)
757
+ assert "too long" in ev.texts[0] and "Try again" in ev.texts[0] and not ev.deleted
758
+ assert OWNER in ctx.app.pending and ctx.client.edited == []
759
+ ev = await say(ctx, OWNER, "{title}")
760
+ assert OWNER not in ctx.app.pending and (await prefs(ctx))["template"] == "{title}"
761
+
762
+
763
+ async def test_expired_prompts_and_other_users_text_are_ignored(ctx):
764
+ await click(ctx, OWNER, "in:template")
765
+ assert (await say(ctx, ADMIN_CFG, "{title}")).out == [] # not their prompt
766
+ assert OWNER in ctx.app.pending
767
+ ctx.app.pending[OWNER].expires = time.time() - 1
768
+ ev = await say(ctx, OWNER, "{title}")
769
+ assert ev.out == [] and not ev.deleted and OWNER not in ctx.app.pending
770
+ assert (await prefs(ctx))["template"] != "{title}"
771
+
772
+
773
+ async def test_plain_text_without_a_prompt_is_ignored(ctx):
774
+ ev = await say(ctx, OWNER, "hello bot")
775
+ assert ev.out == [] and not ev.deleted
776
+
777
+
778
+ async def test_prompt_on_a_photo_banner_message_is_flagged_and_long_updates_fall_back(ctx, monkeypatch):
779
+ await click(ctx, OWNER, "in:mt_title", photo=True)
780
+ assert ctx.app.pending[OWNER].is_photo and ctx.app.pending[OWNER].back == "meta"
781
+ monkeypatch.setattr(appmod, "visible_len", lambda _t: 5000) # pretend it would not fit a caption
782
+ await say(ctx, OWNER, "My Title")
783
+ (_chat, text, _kw), = ctx.client.edited
784
+ assert "Open the menu to see the update" in text and "My Title" in text
785
+ assert (await prefs(ctx))["meta"]["title"] == "My Title"
786
+
787
+
788
+ async def test_when_the_menu_message_cannot_be_edited_a_new_one_is_sent(ctx):
789
+ async def refuse(*a, **k):
790
+ raise RuntimeError("MESSAGE_ID_INVALID")
791
+
792
+ await click(ctx, OWNER, "in:remove")
793
+ ctx.client.edit_message = refuse
794
+ ev = await say(ctx, OWNER, "@Chan, [Group]")
795
+ assert (await prefs(ctx))["remove_words"] == ["@Chan", "[Group]"]
796
+ assert "I'll strip" in ev.texts[0] and ev.out[0][1] # sent as a fresh message with buttons
797
+
798
+
799
+ # ---------------------------------------------------------------------------
800
+ # robustness
801
+ # ---------------------------------------------------------------------------
802
+
803
+
804
+ async def test_handler_failures_are_reported_to_the_user(ctx, monkeypatch):
805
+ async def boom(*a, **k):
806
+ raise RuntimeError("database is down")
807
+
808
+ monkeypatch.setattr(ctx.db, "get_settings", boom)
809
+ ev = await say(ctx, OWNER, "/start")
810
+ assert ev.texts == ["⚠️ Something went wrong. Please try again."]
811
+ cb = await click(ctx, OWNER, "m:meta")
812
+ assert cb.alerts == ["⚠️ Something went wrong."]
813
+ # and the bot keeps working afterwards
814
+ monkeypatch.undo()
815
+ assert "Auto Rename Bot" in (await say(ctx, OWNER, "/start")).texts[0]
816
+
817
+
818
+ async def test_a_file_is_handled_by_exactly_one_handler(ctx):
819
+ ctx.app.pending[OWNER] = Pending("template", OWNER, 1, "rename", False, time.time() + 100)
820
+ ev = await send_doc(ctx, OWNER)
821
+ assert len(ev.out) == 1 and "Queued" in ev.texts[0]
822
+ assert OWNER in ctx.app.pending # a file does not feed the text prompt
tests/test_db.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+
3
+ from bot.db import DEFAULT_SETTINGS, Database, normalize_database_url
4
+
5
+
6
+ @pytest.fixture
7
+ async def db(tmp_path):
8
+ database = Database(f"sqlite+aiosqlite:///{tmp_path}/t.db")
9
+ await database.init()
10
+ yield database
11
+ await database.close()
12
+
13
+
14
+ def test_normalize_postgres_urls():
15
+ url, args = normalize_database_url("postgres://u:p@host/db?sslmode=require&channel_binding=require")
16
+ assert url == "postgresql+asyncpg://u:p@host/db"
17
+ assert args == {"ssl": "require"}
18
+ url, args = normalize_database_url("postgresql://u:p@host/db")
19
+ assert url == "postgresql+asyncpg://u:p@host/db" and args == {}
20
+ url, args = normalize_database_url("sqlite+aiosqlite:///x.db")
21
+ assert url == "sqlite+aiosqlite:///x.db" and args == {}
22
+
23
+
24
+ async def test_defaults_and_update_roundtrip(db):
25
+ s = await db.get_settings(1)
26
+ assert s == DEFAULT_SETTINGS
27
+ s["mode"] = "tampered" # returned copy must not leak into the cache
28
+ assert (await db.get_settings(1))["mode"] == "filename"
29
+
30
+ await db.update_settings(1, mode="caption", keep_audio=["2", "1"], meta={"title": "Hello"})
31
+ await db.update_settings(1, meta={"author": "me"})
32
+ # a fresh Database instance proves the values were persisted, not just cached
33
+ other = Database(db.url)
34
+ s2 = await other.get_settings(1)
35
+ assert s2["mode"] == "caption"
36
+ assert s2["keep_audio"] == ["2", "1"]
37
+ assert s2["meta"]["title"] == "Hello" and s2["meta"]["author"] == "me" and s2["meta"]["artist"] == ""
38
+ await other.close()
39
+
40
+
41
+ async def test_unknown_setting_rejected(db):
42
+ with pytest.raises(KeyError):
43
+ await db.update_settings(1, nope=1)
44
+
45
+
46
+ async def test_thumb_and_stats(db):
47
+ assert not await db.has_thumb(5)
48
+ await db.set_thumb(5, b"\xff\xd8jpeg")
49
+ assert await db.has_thumb(5)
50
+ assert await db.get_thumb(5) == b"\xff\xd8jpeg"
51
+ await db.set_thumb(5, None)
52
+ assert not await db.has_thumb(5)
53
+ assert await db.get_thumb(5) is None
54
+
55
+ await db.record_job(5, 1000)
56
+ await db.record_job(5, 24)
57
+ await db.get_settings(6)
58
+ assert await db.count_users() == 2
59
+ assert await db.totals() == (2, 1024)
60
+
61
+
62
+ async def test_admins_and_kv(db):
63
+ assert await db.add_admin(10, 1) is True
64
+ assert await db.add_admin(10, 1) is False
65
+ assert await db.add_admin(11, 1) is True
66
+ assert sorted(await db.list_admins()) == [10, 11]
67
+ assert await db.del_admin(10) is True
68
+ assert await db.del_admin(10) is False
69
+ assert await db.list_admins() == [11]
70
+
71
+ assert await db.kv_get("session") is None
72
+ await db.kv_set("session", "abc")
73
+ await db.kv_set("session", "def")
74
+ assert await db.kv_get("session") == "def"
75
+ await db.kv_delete("session")
76
+ assert await db.kv_get("session") is None
tests/test_fasttransfer.py ADDED
@@ -0,0 +1,335 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import random
3
+
4
+ import pytest
5
+
6
+ from bot import fasttransfer as ft
7
+ from bot.errors import Cancelled
8
+
9
+
10
+ @pytest.fixture(autouse=True)
11
+ def fast_retries(monkeypatch):
12
+ monkeypatch.setattr(ft, "RETRY_BASE_DELAY", 0.0)
13
+
14
+
15
+ class FakeTransport:
16
+ """Serves bytes from memory and records uploaded parts."""
17
+
18
+ def __init__(self, blob=b"", *, fail_first=0, jitter=True, short_once=False, fatal=None, store=None, hang_after=None):
19
+ self.blob = blob
20
+ self.fail_first = fail_first
21
+ self.jitter = jitter
22
+ self.short_once = short_once
23
+ self.fatal = fatal
24
+ self.calls = 0
25
+ self.closed = False
26
+ self.store = store if store is not None else {}
27
+ self.hang_after = hang_after
28
+ self.requests = []
29
+
30
+ async def _tick(self):
31
+ self.calls += 1
32
+ if self.jitter:
33
+ await asyncio.sleep(random.random() * 0.003)
34
+ if self.fatal:
35
+ raise self.fatal
36
+ if self.calls <= self.fail_first:
37
+ raise ConnectionError("boom")
38
+ if self.hang_after is not None and self.calls > self.hang_after:
39
+ await asyncio.sleep(3600)
40
+
41
+ async def get_file(self, location, offset, limit):
42
+ await self._tick()
43
+ self.requests.append((offset, limit))
44
+ assert offset % 4096 == 0 and limit % 4096 == 0
45
+ assert (offset % (1024 * 1024)) + limit <= 1024 * 1024 # Telegram: never cross a 1 MiB boundary
46
+ data = self.blob[offset: offset + limit]
47
+ if self.short_once and offset > 0 and len(data) == limit:
48
+ self.short_once = False
49
+ return data[: limit // 2]
50
+ return data
51
+
52
+ async def save_part(self, file_id, part, total_parts, data):
53
+ await self._tick()
54
+ self.store[part] = (file_id, total_parts, bytes(data))
55
+
56
+ async def close(self):
57
+ self.closed = True
58
+
59
+
60
+ def blob_of(n, seed=1):
61
+ return random.Random(seed).randbytes(n)
62
+
63
+
64
+ @pytest.mark.parametrize("size", [0, 1, 4095, 1024 * 1024 - 1, 1024 * 1024, 1024 * 1024 + 1, 5 * 1024 * 1024 + 12345])
65
+ async def test_download_matches_source(tmp_path, size):
66
+ blob = blob_of(size)
67
+ transports = [FakeTransport(blob) for _ in range(4)]
68
+ seen = []
69
+ n = await ft.download_parts(transports, "loc", size, tmp_path / "out.bin", progress=lambda c, t: seen.append((c, t)))
70
+ assert n == size
71
+ assert (tmp_path / "out.bin").read_bytes() == blob
72
+ if size:
73
+ assert seen[-1] == (size, size)
74
+ assert all(t == size for _c, t in seen)
75
+ assert max(c for c, _t in seen) == size
76
+
77
+
78
+ async def test_download_work_is_shared_between_connections(tmp_path):
79
+ size = 8 * 1024 * 1024
80
+ blob = blob_of(size)
81
+ transports = [FakeTransport(blob) for _ in range(4)]
82
+ await ft.download_parts(transports, "loc", size, tmp_path / "o", progress=None)
83
+ assert sum(t.calls for t in transports) == 8
84
+ assert sum(1 for t in transports if t.calls) > 1 # more than one connection did work
85
+ offsets = sorted(o for t in transports for o, _ in t.requests)
86
+ assert offsets == [i * 1024 * 1024 for i in range(8)] # every part exactly once
87
+
88
+
89
+ async def test_download_retries_transient_failures(tmp_path):
90
+ size = 3 * 1024 * 1024 + 7
91
+ blob = blob_of(size)
92
+ transports = [FakeTransport(blob, fail_first=2), FakeTransport(blob, fail_first=3)]
93
+ await ft.download_parts(transports, "loc", size, tmp_path / "o")
94
+ assert (tmp_path / "o").read_bytes() == blob
95
+
96
+
97
+ async def test_download_retries_short_reads(tmp_path):
98
+ size = 4 * 1024 * 1024
99
+ blob = blob_of(size)
100
+ transports = [FakeTransport(blob, short_once=True)]
101
+ await ft.download_parts(transports, "loc", size, tmp_path / "o")
102
+ assert (tmp_path / "o").read_bytes() == blob
103
+
104
+
105
+ async def test_download_gives_up_after_max_retries(tmp_path):
106
+ blob = blob_of(2 * 1024 * 1024)
107
+ transport = FakeTransport(blob, fail_first=10_000)
108
+ with pytest.raises(ConnectionError):
109
+ await ft.download_parts([transport], "loc", len(blob), tmp_path / "o")
110
+ assert transport.calls == ft.MAX_RETRIES + 1
111
+
112
+
113
+ async def test_download_fatal_error_is_not_retried(tmp_path):
114
+ class FileReferenceExpiredError(Exception):
115
+ pass
116
+
117
+ transport = FakeTransport(b"x" * 10, fatal=FileReferenceExpiredError("expired"))
118
+ with pytest.raises(FileReferenceExpiredError):
119
+ await ft.download_parts([transport], "loc", 10, tmp_path / "o")
120
+ assert transport.calls == 1
121
+
122
+
123
+ async def test_download_cancel_stops_workers(tmp_path):
124
+ size = 64 * 1024 * 1024
125
+ blob = blob_of(1024)
126
+ cancel = asyncio.Event()
127
+
128
+ class SlowTransport(FakeTransport):
129
+ async def get_file(self, location, offset, limit):
130
+ await asyncio.sleep(0.01)
131
+ if offset >= 3 * 1024 * 1024:
132
+ cancel.set()
133
+ return b"\0" * limit
134
+
135
+ transports = [SlowTransport(blob) for _ in range(4)]
136
+ with pytest.raises(Cancelled):
137
+ await ft.download_parts(transports, "loc", size, tmp_path / "o", cancel=cancel)
138
+
139
+
140
+ async def test_download_hung_connection_times_out_and_retries(tmp_path, monkeypatch):
141
+ monkeypatch.setattr(ft, "PART_TIMEOUT", 0.05)
142
+ blob = blob_of(2 * 1024 * 1024)
143
+ hanging = FakeTransport(blob, hang_after=0, jitter=False) # every request hangs
144
+ with pytest.raises(asyncio.TimeoutError):
145
+ await ft.download_parts([hanging], "loc", len(blob), tmp_path / "o")
146
+ assert hanging.calls == ft.MAX_RETRIES + 1
147
+
148
+
149
+ async def test_download_partial_max_bytes(tmp_path):
150
+ size = 6 * 1024 * 1024
151
+ blob = blob_of(size)
152
+ limit = int(2.5 * 1024 * 1024)
153
+ n = await ft.download_parts([FakeTransport(blob), FakeTransport(blob)], "loc", size, tmp_path / "o", max_bytes=limit)
154
+ assert n == limit
155
+ assert (tmp_path / "o").read_bytes() == blob[:limit]
156
+
157
+
158
+ @pytest.mark.parametrize("size", [1, ft.UPLOAD_PART - 1, ft.UPLOAD_PART, ft.UPLOAD_PART + 1, 3 * 1024 * 1024 + 321])
159
+ async def test_upload_parts_reassemble(tmp_path, size):
160
+ blob = blob_of(size, seed=7)
161
+ src = tmp_path / "in.bin"
162
+ src.write_bytes(blob)
163
+ store = {}
164
+ transports = [FakeTransport(store=store) for _ in range(3)]
165
+ seen = []
166
+ n = await ft.upload_parts(transports, src, size, 1234, progress=lambda c, t: seen.append((c, t)))
167
+ expected_parts = -(-size // ft.UPLOAD_PART)
168
+ assert n == expected_parts
169
+ assert sorted(store) == list(range(expected_parts))
170
+ assert all(fid == 1234 and total == expected_parts for fid, total, _d in store.values())
171
+ sizes = [len(store[i][2]) for i in range(expected_parts)]
172
+ assert all(s == ft.UPLOAD_PART for s in sizes[:-1]) # every part but the last is full size
173
+ assert b"".join(store[i][2] for i in range(expected_parts)) == blob
174
+ assert seen[-1] == (size, size)
175
+
176
+
177
+ async def test_upload_retries_and_cancel(tmp_path):
178
+ blob = blob_of(2 * 1024 * 1024)
179
+ src = tmp_path / "in.bin"
180
+ src.write_bytes(blob)
181
+ store = {}
182
+ await ft.upload_parts([FakeTransport(store=store, fail_first=2), FakeTransport(store=store)], src, len(blob), 1)
183
+ assert b"".join(store[i][2] for i in sorted(store)) == blob
184
+
185
+ cancel = asyncio.Event()
186
+ cancel.set()
187
+ with pytest.raises(Cancelled):
188
+ await ft.upload_parts([FakeTransport(store={})], src, len(blob), 1, cancel=cancel)
189
+
190
+
191
+ async def test_upload_detects_truncated_source(tmp_path):
192
+ src = tmp_path / "in.bin"
193
+ src.write_bytes(b"a" * 100)
194
+ with pytest.raises(IOError):
195
+ await ft.upload_parts([FakeTransport()], src, 5000, 1) # claims more bytes than the file holds
196
+
197
+
198
+ async def test_close_all_swallows_errors():
199
+ class Bad:
200
+ async def close(self):
201
+ raise RuntimeError("x")
202
+
203
+ good = FakeTransport()
204
+ await ft.close_all([Bad(), good])
205
+ assert good.closed
206
+
207
+
208
+ # ---------------------------------------------------------------------------
209
+ # wrappers / fallback
210
+ # ---------------------------------------------------------------------------
211
+
212
+
213
+ class FakeClient:
214
+ def __init__(self):
215
+ self.downloaded = []
216
+ self.uploaded = []
217
+
218
+ async def download_media(self, message, file, progress_callback):
219
+ progress_callback(5, 10)
220
+ with open(file, "wb") as fh:
221
+ fh.write(b"stock")
222
+ self.downloaded.append(file)
223
+
224
+ async def upload_file(self, path, file_name, progress_callback):
225
+ progress_callback(1, 1)
226
+ self.uploaded.append((path, file_name))
227
+ return ("stock-input-file", file_name)
228
+
229
+ async def iter_download(self, doc, request_size, limit):
230
+ for _ in range(limit):
231
+ yield b"z" * request_size
232
+
233
+
234
+ class Msg:
235
+ def __init__(self, document="doc"):
236
+ self.document = document
237
+
238
+
239
+ async def test_download_falls_back_to_stock_when_fast_path_breaks(tmp_path, monkeypatch):
240
+ async def broken(*a, **k):
241
+ raise RuntimeError("private API changed")
242
+
243
+ monkeypatch.setattr(ft, "fast_download", broken)
244
+ client = FakeClient()
245
+ dest = tmp_path / "x.bin"
246
+ dest.write_bytes(b"partial garbage")
247
+ await ft.download(client, Msg(), dest, fast=True)
248
+ assert dest.read_bytes() == b"stock"
249
+
250
+
251
+ async def test_download_cancel_is_not_swallowed_by_fallback(tmp_path, monkeypatch):
252
+ async def cancelled(*a, **k):
253
+ raise Cancelled()
254
+
255
+ monkeypatch.setattr(ft, "fast_download", cancelled)
256
+ client = FakeClient()
257
+ with pytest.raises(Cancelled):
258
+ await ft.download(client, Msg(), tmp_path / "x", fast=True)
259
+ assert client.downloaded == []
260
+
261
+
262
+ async def test_download_stock_progress_callback_can_cancel(tmp_path):
263
+ cancel = asyncio.Event()
264
+ cancel.set()
265
+ with pytest.raises(Cancelled):
266
+ await ft.download(FakeClient(), Msg(), tmp_path / "x", fast=False, cancel=cancel)
267
+
268
+
269
+ async def test_download_partial_fallback_truncates(tmp_path, monkeypatch):
270
+ async def broken(*a, **k):
271
+ raise RuntimeError("nope")
272
+
273
+ monkeypatch.setattr(ft, "fast_download", broken)
274
+ dest = tmp_path / "head.bin"
275
+ await ft.download(FakeClient(), Msg(), dest, fast=True, max_bytes=700 * 1024)
276
+ assert dest.stat().st_size == 700 * 1024
277
+
278
+
279
+ async def test_upload_small_file_uses_stock_upload(tmp_path):
280
+ src = tmp_path / "small.bin"
281
+ src.write_bytes(b"x" * 1000)
282
+ client = FakeClient()
283
+ result = await ft.fast_upload(client, src, "My Name.mkv")
284
+ assert result == ("stock-input-file", "My Name.mkv")
285
+ assert client.uploaded == [(str(src), "My Name.mkv")]
286
+
287
+
288
+ async def test_upload_wrapper_falls_back(tmp_path, monkeypatch):
289
+ async def broken(*a, **k):
290
+ raise RuntimeError("nope")
291
+
292
+ monkeypatch.setattr(ft, "fast_upload", broken)
293
+ src = tmp_path / "f.bin"
294
+ src.write_bytes(b"x")
295
+ client = FakeClient()
296
+ assert await ft.upload(client, src, "n.mkv", fast=True) == ("stock-input-file", "n.mkv")
297
+ assert await ft.upload(client, src, "n.mkv", fast=False) == ("stock-input-file", "n.mkv")
298
+
299
+
300
+ async def test_big_upload_builds_input_file_big(tmp_path, monkeypatch):
301
+ size = ft.BIG_FILE_THRESHOLD + 12345
302
+ src = tmp_path / "big.bin"
303
+ src.write_bytes(blob_of(size, seed=3))
304
+ store = {}
305
+
306
+ async def fake_open(client, dc_id, connections):
307
+ assert dc_id == 4 and connections == 3
308
+ return [FakeTransport(store=store, jitter=False) for _ in range(3)]
309
+
310
+ monkeypatch.setattr(ft, "open_transports", fake_open)
311
+
312
+ class C(FakeClient):
313
+ class session:
314
+ dc_id = 4
315
+
316
+ result = await ft.fast_upload(C(), src, "Big.mkv", connections=3)
317
+ from telethon.tl.types import InputFileBig
318
+
319
+ assert isinstance(result, InputFileBig)
320
+ assert result.name == "Big.mkv" and result.parts == len(store) == -(-size // ft.UPLOAD_PART)
321
+ assert result.id == next(iter(store.values()))[0]
322
+ assert b"".join(store[i][2] for i in sorted(store)) == src.read_bytes()
323
+
324
+
325
+ async def test_file_helper_without_positional_io(tmp_path):
326
+ """Windows has no os.pread/os.pwrite: the lock + seek path must work too."""
327
+ f = ft._File(tmp_path / "w.bin", "w")
328
+ f._positional = False
329
+ f.pwrite(b"world", 5)
330
+ f.pwrite(b"hello", 0)
331
+ f.close()
332
+ r = ft._File(tmp_path / "w.bin", "r")
333
+ r._positional = False
334
+ assert r.pread(10, 0) == b"helloworld"
335
+ r.close()
tests/test_media.py ADDED
@@ -0,0 +1,321 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+
3
+ import pytest
4
+
5
+ from bot.errors import Cancelled, MediaError
6
+ from bot.media import (
7
+ MediaInfo,
8
+ Stream,
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
+ )
21
+
22
+ from .conftest import ffprobe_json, needs_ffmpeg
23
+
24
+
25
+ def audio(index, lang="", title=""):
26
+ return Stream(index=index, kind="audio", codec="aac", lang=lang, title=title, channels=2)
27
+
28
+
29
+ def sub(index, lang="", title=""):
30
+ return Stream(index=index, kind="subtitle", codec="subrip", lang=lang, title=title)
31
+
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # pure logic
35
+ # ---------------------------------------------------------------------------
36
+
37
+
38
+ def test_parse_track_tokens():
39
+ assert parse_track_tokens("English, Japanese") == ["english", "japanese"]
40
+ assert parse_track_tokens("2 1") == ["2", "1"]
41
+ assert parse_track_tokens("eng,eng jpn") == ["eng", "jpn"]
42
+ assert parse_track_tokens("") == []
43
+
44
+
45
+ def test_validate_track_tokens():
46
+ assert validate_track_tokens(["2", "english", "pt-br"]) is None
47
+ assert validate_track_tokens(["0"]) is not None
48
+ assert validate_track_tokens(["en;rm"]) is not None
49
+
50
+
51
+ def test_canonical_lang():
52
+ assert canonical_lang("eng") == "english"
53
+ assert canonical_lang("EN-us") == "english"
54
+ assert canonical_lang("jpn") == "japanese"
55
+ assert canonical_lang("chi") == canonical_lang("zho") == "chinese"
56
+ assert canonical_lang("und") is None
57
+ assert canonical_lang("") is None
58
+
59
+
60
+ def test_resolve_by_language_name_and_code_in_token_order():
61
+ streams = [audio(1, "jpn"), audio(2, "eng"), audio(3, "hin")]
62
+ assert [s.index for s in resolve_tokens(streams, ["english", "japanese"])] == [2, 1]
63
+ assert [s.index for s in resolve_tokens(streams, ["eng", "jpn"])] == [2, 1]
64
+ assert [s.index for s in resolve_tokens(streams, ["hindi"])] == [3]
65
+
66
+
67
+ def test_resolve_by_number_is_one_based_position_not_stream_index():
68
+ streams = [audio(5, "jpn"), audio(9, "eng")]
69
+ assert [s.index for s in resolve_tokens(streams, ["2", "1"])] == [9, 5]
70
+ assert resolve_tokens(streams, ["3"]) == []
71
+
72
+
73
+ def test_resolve_dedupes_and_keeps_all_matches_of_a_language():
74
+ streams = [audio(1, "eng", "Dub"), audio(2, "jpn"), audio(3, "eng", "Commentary")]
75
+ assert [s.index for s in resolve_tokens(streams, ["english", "1"])] == [1, 3]
76
+ assert [s.index for s in resolve_tokens(streams, ["english", "japanese"])] == [1, 3, 2]
77
+
78
+
79
+ def test_resolve_untagged_track_uses_title_words():
80
+ streams = [audio(1, "und", "Japanese [Original]"), audio(2, "", "English Dub")]
81
+ assert [s.index for s in resolve_tokens(streams, ["english"])] == [2]
82
+ assert [s.index for s in resolve_tokens(streams, ["japanese"])] == [1]
83
+
84
+
85
+ def test_resolve_tagged_language_is_not_overridden_by_title():
86
+ streams = [audio(1, "jpn", "English commentary")]
87
+ assert resolve_tokens(streams, ["english"]) == []
88
+
89
+
90
+ def test_resolve_unknown_language_word_matches_raw_code():
91
+ streams = [audio(1, "xyz"), audio(2, "eng")]
92
+ assert [s.index for s in resolve_tokens(streams, ["xyz"])] == [1]
93
+
94
+
95
+ def make_info():
96
+ return MediaInfo(
97
+ duration=2,
98
+ streams=[
99
+ Stream(0, "video", codec="h264", width=320, height=240),
100
+ audio(1, "jpn", "Japanese Original"),
101
+ audio(2, "eng"),
102
+ audio(3, "hin"),
103
+ sub(4, "jpn"),
104
+ sub(5, "eng"),
105
+ Stream(6, "attachment", codec="ttf"),
106
+ ],
107
+ )
108
+
109
+
110
+ def test_build_plan_none_when_metadata_off_or_nothing_to_do():
111
+ info = make_info()
112
+ assert build_plan(info, {"metadata_on": False, "keep_audio": ["1"]}, ".mkv") is None
113
+ assert build_plan(info, {"metadata_on": True, "meta": {}}, ".mkv") is None
114
+ assert build_plan(info, {"metadata_on": True, "keep_audio": ["1"]}, ".avi") is None
115
+
116
+
117
+ def test_build_plan_maps_in_requested_order_and_drops_others():
118
+ plan = build_plan(
119
+ make_info(),
120
+ {"metadata_on": True, "keep_audio": ["english", "japanese"], "keep_subtitle": ["2"], "meta": {}},
121
+ ".mkv",
122
+ )
123
+ assert plan is not None
124
+ maps = [plan.args[i + 1] for i, a in enumerate(plan.args) if a == "-map"]
125
+ # video, audio eng(2) then jpn(1), subtitle #2 -> stream 5, attachment kept; hindi + jpn sub dropped
126
+ assert maps == ["0:0", "0:2", "0:1", "0:5", "0:6"]
127
+ assert plan.args[plan.args.index("-disposition:a:0") + 1] == "+default"
128
+ assert plan.args[plan.args.index("-disposition:a:1") + 1] == "-default"
129
+ assert plan.args[plan.args.index("-disposition:s:0") + 1] == "+default"
130
+
131
+
132
+ def test_build_plan_audio_safeguard():
133
+ plan = build_plan(make_info(), {"metadata_on": True, "keep_audio": ["klingon"], "meta": {}}, ".mkv")
134
+ assert plan is not None
135
+ assert [s.index for s in plan.kept_audio] == [1, 2, 3]
136
+ assert plan.warnings
137
+
138
+
139
+ def test_build_plan_subtitles_dropped_when_nothing_matches():
140
+ plan = build_plan(make_info(), {"metadata_on": True, "keep_subtitle": ["french"], "meta": {}}, ".mkv")
141
+ assert plan.kept_subtitles == []
142
+ maps = [plan.args[i + 1] for i, a in enumerate(plan.args) if a == "-map"]
143
+ assert "0:4" not in maps and "0:5" not in maps
144
+
145
+
146
+ def test_build_plan_metadata_fields_and_lang_placeholder():
147
+ plan = build_plan(
148
+ make_info(),
149
+ {
150
+ "metadata_on": True,
151
+ "keep_audio": ["japanese", "english"],
152
+ "meta": {"title": "Tokyo Revengers", "author": "@chan", "artist": "@chan",
153
+ "audio": "{lang} Audio", "subtitle": "English", "video": "@chan"},
154
+ },
155
+ ".mkv",
156
+ )
157
+ a = plan.args
158
+ assert ["-metadata", "title=Tokyo Revengers"] == a[a.index("title=Tokyo Revengers") - 1: a.index("title=Tokyo Revengers") + 1]
159
+ assert "author=@chan" in a and "artist=@chan" in a
160
+ assert "title=Japanese Audio" in a and "title=English Audio" in a
161
+ assert a.index("title=Japanese Audio") < a.index("title=English Audio")
162
+ assert "-metadata:s:v:0" in a and "-metadata:s:s:0" in a
163
+
164
+
165
+ def test_build_plan_mp3_tags_only():
166
+ plan = build_plan(MediaInfo(), {"metadata_on": True, "meta": {"title": "T", "author": "A"}}, ".mp3")
167
+ assert plan is not None
168
+ assert "title=T" in plan.args and "artist=A" in plan.args
169
+ assert build_plan(MediaInfo(), {"metadata_on": True, "meta": {}}, ".mp3") is None
170
+
171
+
172
+ def test_format_tracks():
173
+ text = format_tracks(make_info())
174
+ assert "1. Japanese (jpn)" in text and "2. English (eng)" in text and "3. Hindi (hin)" in text
175
+ assert "Subtitles" in text and "“Japanese Original”" in text
176
+
177
+
178
+ def test_parse_probe_duration_fallbacks():
179
+ info = parse_probe({"format": {"duration": "12.5"}, "streams": []})
180
+ assert info.duration == 12.5
181
+ info = parse_probe({"format": {}, "streams": [{"index": 0, "codec_type": "video", "tags": {"DURATION": "00:00:03.500000000"}}]})
182
+ assert info.duration == 3.5
183
+
184
+
185
+ # ---------------------------------------------------------------------------
186
+ # real ffprobe / ffmpeg
187
+ # ---------------------------------------------------------------------------
188
+
189
+
190
+ @needs_ffmpeg
191
+ async def test_probe_real_mkv(media_dir):
192
+ info = await probe(media_dir / "sample.mkv")
193
+ assert [s.kind for s in info.streams] == ["video", "audio", "audio", "audio", "subtitle", "subtitle", "attachment"]
194
+ assert [s.lang for s in info.audio] == ["jpn", "eng", "hin"]
195
+ assert [s.lang for s in info.subtitles] == ["jpn", "eng"]
196
+ assert info.width == 320 and info.height == 240
197
+ assert 1.5 < info.duration < 2.6
198
+
199
+
200
+ @needs_ffmpeg
201
+ async def test_probe_missing_file_raises(tmp_path):
202
+ with pytest.raises(MediaError):
203
+ await probe(tmp_path / "nope.mkv")
204
+
205
+
206
+ @needs_ffmpeg
207
+ async def test_apply_plan_mkv_reorders_drops_and_tags(media_dir, tmp_path):
208
+ src = media_dir / "sample.mkv"
209
+ info = await probe(src)
210
+ settings = {
211
+ "metadata_on": True,
212
+ "keep_audio": ["english", "japanese"],
213
+ "keep_subtitle": ["english"],
214
+ "meta": {"title": "Tokyo Revengers", "author": "@chan", "artist": "@chan",
215
+ "audio": "{lang} Audio", "subtitle": "{lang}", "video": "@chan video"},
216
+ }
217
+ plan = build_plan(info, settings, ".mkv")
218
+ dst = tmp_path / "out.mkv"
219
+ await apply_plan(src, dst, plan)
220
+
221
+ data = ffprobe_json(dst, "-show_chapters")
222
+ streams = data["streams"]
223
+ kinds = [s["codec_type"] for s in streams]
224
+ assert kinds == ["video", "audio", "audio", "subtitle", "attachment"]
225
+ audios = [s for s in streams if s["codec_type"] == "audio"]
226
+ assert [a["tags"]["language"] for a in audios] == ["eng", "jpn"] # reordered, hindi dropped
227
+ assert [a["tags"]["title"] for a in audios] == ["English Audio", "Japanese Audio"]
228
+ assert audios[0]["disposition"]["default"] == 1 and audios[1]["disposition"]["default"] == 0
229
+ sub_stream = next(s for s in streams if s["codec_type"] == "subtitle")
230
+ assert sub_stream["tags"]["language"] == "eng" and sub_stream["tags"]["title"] == "English"
231
+ assert sub_stream["disposition"]["default"] == 1
232
+ video = streams[0]
233
+ assert video["tags"]["title"] == "@chan video"
234
+ fmt_tags = {k.lower(): v for k, v in data["format"]["tags"].items()} # Matroska stores AUTHOR/ARTIST upper-case
235
+ assert fmt_tags["title"] == "Tokyo Revengers"
236
+ assert fmt_tags["author"] == "@chan" and fmt_tags["artist"] == "@chan"
237
+ assert len(data["chapters"]) == 2 # chapters survive
238
+ attach = next(s for s in streams if s["codec_type"] == "attachment")
239
+ assert attach["tags"]["filename"] == "font.ttf" # fonts survive
240
+
241
+
242
+ @needs_ffmpeg
243
+ async def test_apply_plan_numbers_follow_given_order(media_dir, tmp_path):
244
+ src = media_dir / "sample.mkv"
245
+ info = await probe(src)
246
+ plan = build_plan(info, {"metadata_on": True, "keep_audio": ["3", "1"], "keep_subtitle": ["2", "1"], "meta": {}}, ".mkv")
247
+ dst = tmp_path / "out.mkv"
248
+ await apply_plan(src, dst, plan)
249
+ out = await probe(dst)
250
+ assert [s.lang for s in out.audio] == ["hin", "jpn"]
251
+ assert [s.lang for s in out.subtitles] == ["eng", "jpn"]
252
+
253
+
254
+ @needs_ffmpeg
255
+ async def test_apply_plan_audio_safeguard_keeps_everything(media_dir, tmp_path):
256
+ src = media_dir / "sample.mkv"
257
+ info = await probe(src)
258
+ plan = build_plan(info, {"metadata_on": True, "keep_audio": ["french"], "meta": {}}, ".mkv")
259
+ dst = tmp_path / "out.mkv"
260
+ await apply_plan(src, dst, plan)
261
+ assert len((await probe(dst)).audio) == 3
262
+
263
+
264
+ @needs_ffmpeg
265
+ async def test_apply_plan_mp4(media_dir, tmp_path):
266
+ src = media_dir / "sample.mp4"
267
+ info = await probe(src)
268
+ plan = build_plan(info, {"metadata_on": True, "keep_audio": ["english", "japanese"], "keep_subtitle": ["1"],
269
+ "meta": {"title": "MP4 Title"}}, ".mp4")
270
+ dst = tmp_path / "out.mp4"
271
+ await apply_plan(src, dst, plan)
272
+ out = await probe(dst)
273
+ assert [s.lang for s in out.audio] == ["eng", "jpn"]
274
+ assert [s.kind for s in out.streams].count("subtitle") == 1
275
+ assert out.tags.get("title") == "MP4 Title"
276
+
277
+
278
+ @needs_ffmpeg
279
+ async def test_apply_plan_mp3_title(media_dir, tmp_path):
280
+ src = media_dir / "sample.mp3"
281
+ plan = build_plan(await probe(src), {"metadata_on": True, "meta": {"title": "New Title", "artist": "Me"}}, ".mp3")
282
+ dst = tmp_path / "out.mp3"
283
+ await apply_plan(src, dst, plan)
284
+ out = await probe(dst)
285
+ assert out.tags["title"] == "New Title" and out.tags["artist"] == "Me"
286
+
287
+
288
+ @needs_ffmpeg
289
+ async def test_apply_plan_failure_raises_media_error(tmp_path):
290
+ bad = tmp_path / "bad.mkv"
291
+ bad.write_bytes(b"not a video")
292
+ from bot.media import Plan
293
+
294
+ with pytest.raises(MediaError):
295
+ await apply_plan(bad, tmp_path / "out.mkv", Plan(["-map", "0:0", "-c", "copy"]))
296
+
297
+
298
+ @needs_ffmpeg
299
+ async def test_cancel_kills_ffmpeg(media_dir, tmp_path):
300
+ cancel = asyncio.Event()
301
+ cancel.set()
302
+ info = await probe(media_dir / "sample.mkv")
303
+ plan = build_plan(info, {"metadata_on": True, "keep_audio": ["1"], "meta": {}}, ".mkv")
304
+ with pytest.raises(Cancelled):
305
+ await apply_plan(media_dir / "sample.mkv", tmp_path / "out.mkv", plan, cancel)
306
+
307
+
308
+ @needs_ffmpeg
309
+ async def test_thumbnail_and_prepare_thumb(media_dir, tmp_path):
310
+ out = tmp_path / "t.jpg"
311
+ assert await make_video_thumbnail(media_dir / "sample.mkv", out, 2.0)
312
+ assert out.read_bytes()[:2] == b"\xff\xd8"
313
+ from PIL import Image
314
+ import io
315
+
316
+ big = io.BytesIO()
317
+ Image.effect_noise((1600, 1200), 80).convert("RGB").save(big, "PNG")
318
+ jpg = prepare_thumb(big.getvalue())
319
+ img = Image.open(io.BytesIO(jpg))
320
+ assert max(img.size) <= 320 and len(jpg) <= 200 * 1024 and img.format == "JPEG"
321
+ assert not await make_video_thumbnail(tmp_path / "missing.mkv", tmp_path / "x.jpg", 1.0)
tests/test_panels.py ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import copy
2
+ import html
3
+ import re
4
+
5
+ import pytest
6
+
7
+ from bot import panels
8
+ from bot.app import BACK, PANEL_NAMES
9
+ from bot.db import DEFAULT_SETTINGS
10
+ from bot.media import LANGUAGES
11
+
12
+ from .tgfakes import button_rows
13
+
14
+ CAPTION_LIMIT = 1000 # Telegram allows 1024 for a caption; keep a margin
15
+
16
+
17
+ def visible(text: str) -> str:
18
+ return html.unescape(re.sub(r"<[^>]+>", "", text))
19
+
20
+
21
+ def settings(**over):
22
+ s = copy.deepcopy(DEFAULT_SETTINGS)
23
+ s.update(over)
24
+ return s
25
+
26
+
27
+ def worst_case():
28
+ longest = sorted(LANGUAGES, key=len, reverse=True)[:12]
29
+ return settings(
30
+ mode="caption", file_type="document", metadata_on=True,
31
+ template="W" * 200, caption="C" * 800, remove_words=["R" * 60] * 50,
32
+ meta={k: "M" * 200 for k in DEFAULT_SETTINGS["meta"]},
33
+ keep_audio=list(longest), keep_subtitle=list(longest), target_channel=-1001234567890123,
34
+ )
35
+
36
+
37
+ def every_panel(s, has_thumb=True):
38
+ out = {
39
+ "home_owner": panels.home(s, owner=True),
40
+ "home_user": panels.home(s, owner=False),
41
+ "rename": panels.rename(s),
42
+ "cap": panels.caption(s, has_thumb),
43
+ "meta": panels.metadata(s),
44
+ "tool": panels.tool(s),
45
+ "ftype": panels.filetype(s),
46
+ "help": panels.help_panel(),
47
+ "metahelp": panels.metadata_help(),
48
+ "admin": panels.admin(1, list(range(1000, 1030)), 3, 4, -1001234567890),
49
+ }
50
+ out.update({f"prompt_{k}": panels.input_prompt(k, BACK[k]) for k in panels.PROMPTS})
51
+ return out
52
+
53
+
54
+ @pytest.mark.parametrize("s", [settings(), worst_case()], ids=["defaults", "worst-case"])
55
+ def test_every_panel_fits_in_a_photo_caption(s):
56
+ for name, (text, _rows) in every_panel(s).items():
57
+ assert len(visible(text)) <= CAPTION_LIMIT, f"{name} is {len(visible(text))} characters"
58
+
59
+
60
+ @pytest.mark.parametrize("s", [settings(), worst_case()], ids=["defaults", "worst-case"])
61
+ def test_callback_data_is_short_unique_per_panel_and_labels_are_present(s):
62
+ for name, (_text, rows) in every_panel(s).items():
63
+ seen = set()
64
+ for row in rows:
65
+ assert 1 <= len(row) <= 4, name
66
+ for label, data in row:
67
+ assert label.strip() and len(label) <= 32, (name, label)
68
+ assert 0 < len(data.encode()) <= 64, (name, data)
69
+ assert data not in seen, (name, data)
70
+ seen.add(data)
71
+
72
+
73
+ def test_every_panel_html_is_balanced():
74
+ for name, (text, _rows) in every_panel(worst_case()).items():
75
+ for tag in ("b", "code", "i"):
76
+ assert text.count(f"<{tag}>") == text.count(f"</{tag}>"), (name, tag)
77
+
78
+
79
+ def test_every_input_button_has_a_prompt_and_a_back_target():
80
+ assert set(panels.PROMPTS) == set(BACK)
81
+ assert set(BACK.values()) <= PANEL_NAMES
82
+ for name, (_text, rows) in every_panel(settings()).items():
83
+ for row in rows:
84
+ for _label, data in row:
85
+ if data.startswith("in:") and not data.startswith("in:cancel"):
86
+ assert data[3:] in panels.PROMPTS, (name, data)
87
+ if data.startswith("m:"):
88
+ assert data[2:] in PANEL_NAMES, (name, data)
89
+
90
+
91
+ def test_navigation_always_offers_a_way_out():
92
+ for name, (_text, rows) in every_panel(settings()).items():
93
+ data = [d for row in rows for _l, d in row]
94
+ if name.startswith("prompt_"):
95
+ assert any(d.startswith("in:cancel:") for d in data), name
96
+ else:
97
+ assert "x:close" in data, name
98
+
99
+
100
+ def test_home_shows_admin_button_only_to_the_owner():
101
+ owner = [d for row in panels.home(settings(), owner=True)[1] for _l, d in row]
102
+ user = [d for row in panels.home(settings(), owner=False)[1] for _l, d in row]
103
+ assert "m:admin" in owner and "m:admin" not in user
104
+ for expected in ("m:rename", "m:cap", "m:meta", "m:ftype", "m:help"):
105
+ assert expected in user
106
+
107
+
108
+ def test_active_choices_get_a_checkmark():
109
+ def labels(panel):
110
+ return {data: label for row in panel[1] for label, data in row}
111
+
112
+ r = labels(panels.rename(settings(mode="caption")))
113
+ assert r["set:mode:caption"].endswith("✅") and not r["set:mode:filename"].endswith("✅")
114
+ f = labels(panels.filetype(settings(file_type="audio")))
115
+ assert [k for k, v in f.items() if v.endswith("✅")] == ["set:ftype:audio"]
116
+ m = labels(panels.metadata(settings(metadata_on=True)))
117
+ assert m["set:meta:on"].endswith("✅") and not m["set:meta:off"].endswith("✅")
118
+
119
+
120
+ def test_metadata_panel_matches_the_reference_layout():
121
+ text, rows = panels.metadata(settings(metadata_on=True, keep_audio=["english", "2"], meta={"title": "T", "author": "", "artist": "", "audio": "", "subtitle": "", "video": ""}))
122
+ plain = visible(text)
123
+ assert "currently: On" in plain
124
+ for label in ("Title", "Author", "Artist", "Audio", "Subtitle", "Video", "Keep Audio", "Keep Subtitle"):
125
+ assert label in plain
126
+ assert "English, 2" in plain
127
+ data = [d for row in rows for _l, d in row]
128
+ for expected in ("set:meta:on", "set:meta:off", "in:mt_title", "in:mt_author", "in:mt_artist", "in:mt_audio",
129
+ "in:mt_subtitle", "in:mt_video", "m:tool", "help:meta"):
130
+ assert expected in data
131
+
132
+
133
+ def test_tool_panel_holds_keep_audio_and_keep_subtitle():
134
+ text, rows = panels.tool(settings(keep_subtitle=["english"]))
135
+ data = [d for row in rows for _l, d in row]
136
+ assert {"in:keep_audio", "in:keep_subtitle", "clr:keep_audio", "clr:keep_subtitle"} <= set(data)
137
+ assert "m:meta" in data # Back goes to the metadata panel
138
+ assert "English" in visible(text)
139
+
140
+
141
+ def test_fmt_tokens():
142
+ assert panels.fmt_tokens([]) == "—"
143
+ assert panels.fmt_tokens(["english", "2", "jpn"]) == "English, 2, Japanese"
144
+ assert panels.fmt_tokens(["klingon"]) == "Klingon"
145
+
146
+
147
+ def test_html_in_user_values_is_escaped():
148
+ s = settings(template="<b>{title}</b> & co", meta={**DEFAULT_SETTINGS["meta"], "title": "<i>x</i>"})
149
+ assert "<b>{title}</b>" not in panels.rename(s)[0]
150
+ assert "&lt;b&gt;" in panels.rename(s)[0]
151
+ assert "<i>x</i>" not in panels.metadata(s)[0]
152
+
153
+
154
+ def test_to_buttons_builds_callback_buttons():
155
+ rows = [[("A", "a:1"), ("B", "b:2")], [("C", "c:3")]]
156
+ assert button_rows(panels.to_buttons(rows)) == rows
157
+
158
+
159
+ def test_known_languages_lists_names():
160
+ assert "english" in panels.known_languages()
tests/test_parser.py ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+
3
+ from bot.parser import (
4
+ DEFAULT_TEMPLATE,
5
+ apply_remove_words,
6
+ build_manual_name,
7
+ compute_new_name,
8
+ finalize_name,
9
+ parse_caption,
10
+ parse_info,
11
+ render_template,
12
+ sanitize,
13
+ split_ext,
14
+ )
15
+
16
+
17
+ @pytest.mark.parametrize(
18
+ "name,title,season,episode,quality,year",
19
+ [
20
+ ("[SubsPlease] Tokyo Revengers - 05 (1080p) [ABCD1234].mkv", "Tokyo Revengers", "01", "05", "1080p", ""),
21
+ ("Show.Name.S02E10.Some.Episode.Title.720p.WEB-DL.x264-GRP.mkv", "Show Name", "02", "10", "720p", ""),
22
+ ("Naruto Shippuden Episode 123 Dual Audio 720p.mp4", "Naruto Shippuden", "01", "123", "720p", ""),
23
+ ("Inception.2010.1080p.BluRay.x264-GROUP.mkv", "Inception", "", "", "1080p", "2010"),
24
+ ("Blade.Runner.2049.2017.1080p.BluRay.mkv", "Blade Runner 2049", "", "", "1080p", "2017"),
25
+ ("One Piece - 1000 [1080p].mkv", "One Piece", "01", "1000", "1080p", ""),
26
+ ("Attack_on_Titan_S4E28_480p.mp4", "Attack on Titan", "04", "28", "480p", ""),
27
+ ("Show 1x05 720p.mkv", "Show", "01", "05", "720p", ""),
28
+ ("[S01E05] Title [720p].mkv", "Title", "01", "05", "720p", ""),
29
+ ("Title (2021) - 05 [720p].mkv", "Title", "01", "05", "720p", "2021"),
30
+ ("Some Movie 1920x1080.mkv", "Some Movie", "", "", "1080p", ""),
31
+ ("Plain Name.mkv", "Plain Name", "", "", "", ""),
32
+ ("Show S2 - 07 [1080p].mkv", "Show", "02", "07", "1080p", ""),
33
+ ("Movie 4K HDR.mkv", "Movie", "", "", "2160p", ""),
34
+ ],
35
+ )
36
+ def test_parse_info(name, title, season, episode, quality, year):
37
+ info = parse_info(name)
38
+ assert (info.title, info.season, info.episode, info.quality, info.year) == (
39
+ title, season, episode, quality, year
40
+ )
41
+
42
+
43
+ def test_resolution_is_not_an_episode():
44
+ info = parse_info("Some Movie 1920x1080.mkv")
45
+ assert info.episode == ""
46
+
47
+
48
+ def test_codec_detection():
49
+ assert parse_info("Show.S01E01.1080p.x265-GRP.mkv").codec == "x265"
50
+ assert parse_info("Show.S01E01.1080p.H.264.mkv").codec == "H264"
51
+ assert parse_info("Show - 01 [HEVC].mkv").codec == "HEVC"
52
+
53
+
54
+ def test_title_only_marker_at_start():
55
+ info = parse_info("S01E05 Some Title.mkv")
56
+ assert info.title == "Some Title"
57
+ assert (info.season, info.episode) == ("01", "05")
58
+
59
+
60
+ def test_split_ext():
61
+ assert split_ext("a.b.mkv") == ("a.b", ".mkv")
62
+ assert split_ext("Season 1.5", known_only=True) == ("Season 1.5", "")
63
+ assert split_ext("Season 1.5") == ("Season 1.5", "")
64
+ assert split_ext("noext") == ("noext", "")
65
+ assert split_ext("x.MKV", known_only=True) == ("x", ".MKV")
66
+ assert split_ext("weird.abc", known_only=True) == ("weird.abc", "")
67
+ assert split_ext("weird.abc") == ("weird", ".abc")
68
+
69
+
70
+ def test_render_template_drops_group_with_empty_variable():
71
+ values = {"title": "Show", "season": "", "episode": "", "quality": "720p", "year": "", "codec": "", "filename": "x", "ext": "mkv"}
72
+ assert render_template(DEFAULT_TEMPLATE, values) == "Show [720p]"
73
+ values["quality"] = ""
74
+ assert render_template(DEFAULT_TEMPLATE, values) == "Show"
75
+
76
+
77
+ def test_render_template_full():
78
+ values = {"title": "Show", "season": "01", "episode": "05", "quality": "720p", "year": "", "codec": "", "filename": "x", "ext": "mkv"}
79
+ assert render_template(DEFAULT_TEMPLATE, values) == "[S01E05] Show [720p]"
80
+ assert render_template("{title} - {episode}", values) == "Show - 05"
81
+ assert render_template("{title}.S{season}E{episode}", values) == "Show.S01E05"
82
+
83
+
84
+ def test_render_template_values_are_not_reparsed():
85
+ values = {"title": "{quality} [x]", "season": "", "episode": "", "quality": "720p", "year": "", "codec": "", "filename": "x", "ext": "mkv"}
86
+ assert render_template("{title} [{quality}]", values) == "{quality} [x] [720p]"
87
+
88
+
89
+ def test_render_template_unknown_token_kept():
90
+ values = {"title": "T", "season": "", "episode": "", "quality": "", "year": "", "codec": "", "filename": "x", "ext": "mkv"}
91
+ assert render_template("{title} {nope}", values) == "T {nope}"
92
+
93
+
94
+ def test_compute_new_name_filename_mode():
95
+ name, info = compute_new_name(
96
+ template=DEFAULT_TEMPLATE,
97
+ mode="filename",
98
+ original_name="[SubsPlease] Tokyo Revengers - 05 (1080p) [ABCD1234].mkv",
99
+ original_ext=".mkv",
100
+ )
101
+ assert name == "[S01E05] Tokyo Revengers [1080p].mkv"
102
+ assert info.episode == "05"
103
+
104
+
105
+ def test_compute_new_name_caption_mode_uses_caption():
106
+ name, _ = compute_new_name(
107
+ template=DEFAULT_TEMPLATE,
108
+ mode="caption",
109
+ original_name="random_file_123.mkv",
110
+ original_ext=".mkv",
111
+ caption="Bleach S01E12 720p",
112
+ )
113
+ assert name == "[S01E12] Bleach [720p].mkv"
114
+
115
+
116
+ def test_compute_new_name_caption_mode_falls_back_to_filename():
117
+ name, _ = compute_new_name(
118
+ template=DEFAULT_TEMPLATE,
119
+ mode="caption",
120
+ original_name="One.Piece.E1000.1080p.mkv",
121
+ original_ext=".mkv",
122
+ caption="Hello everyone, enjoy!",
123
+ )
124
+ assert name == "[S01E1000] One Piece [1080p].mkv"
125
+
126
+
127
+ def test_compute_new_name_caption_fills_missing_from_filename():
128
+ name, _ = compute_new_name(
129
+ template=DEFAULT_TEMPLATE,
130
+ mode="caption",
131
+ original_name="Show.S01E03.1080p.mkv",
132
+ original_ext=".mkv",
133
+ caption="Show Name S01E03",
134
+ )
135
+ assert name == "[S01E03] Show Name [1080p].mkv"
136
+
137
+
138
+ def test_caption_title_from_first_line_details_from_rest():
139
+ info = parse_caption("My Show\nEpisode: 5\nQuality: 720p")
140
+ assert info.title == "My Show"
141
+ assert info.quality == "720p"
142
+
143
+
144
+ def test_compute_new_name_remove_words_and_illegal_chars():
145
+ name, _ = compute_new_name(
146
+ template="{title} {quality}",
147
+ mode="filename",
148
+ original_name="Re:Zero - 03 [720p] @SomeChannel.mkv",
149
+ original_ext=".mkv",
150
+ remove_words=["@SomeChannel"],
151
+ )
152
+ assert name == "Re Zero 720p.mkv"
153
+
154
+
155
+ def test_compute_new_name_keeps_extension_and_no_double_ext():
156
+ name, _ = compute_new_name(
157
+ template="{title}.{ext}",
158
+ mode="filename",
159
+ original_name="Movie.2020.720p.mkv",
160
+ original_ext=".mkv",
161
+ )
162
+ assert name == "Movie.mkv"
163
+
164
+
165
+ def test_compute_new_name_missing_everything_falls_back_to_original():
166
+ name, _ = compute_new_name(
167
+ template="[S{season}E{episode}]",
168
+ mode="filename",
169
+ original_name="holiday video.mp4",
170
+ original_ext=".mp4",
171
+ )
172
+ assert name == "holiday video.mp4"
173
+
174
+
175
+ def test_compute_new_name_uses_original_ext_when_name_has_none():
176
+ name, _ = compute_new_name(
177
+ template="{title}", mode="filename", original_name="Movie", original_ext=".mp4"
178
+ )
179
+ assert name == "Movie.mp4"
180
+
181
+
182
+ def test_finalize_truncates_by_bytes_and_keeps_ext():
183
+ out = finalize_name("é" * 300, ".mkv")
184
+ assert out.endswith(".mkv")
185
+ assert len(out.encode("utf-8")) <= 200
186
+
187
+
188
+ def test_manual_name_keeps_original_ext():
189
+ assert build_manual_name("My Movie", ".mkv") == "My Movie.mkv"
190
+ assert build_manual_name("My Movie.mkv", ".mkv") == "My Movie.mkv"
191
+ assert build_manual_name("My Movie.mp4", ".mkv") == "My Movie.mkv"
192
+ assert build_manual_name("Season 1.5", ".mkv") == "Season 1.5.mkv"
193
+ assert build_manual_name("Fixed.mkv", "") == "Fixed.mkv"
194
+
195
+
196
+ def test_manual_name_sanitised():
197
+ assert build_manual_name('a/b:c*d?.mkv', ".mkv") == "a b c d.mkv"
198
+
199
+
200
+ def test_sanitize_and_remove_words():
201
+ assert sanitize(" ..hidden:name ") == "hidden name"
202
+ assert apply_remove_words("Show [Group] 720p", ["[group]"]) == "Show 720p"
tests/test_pipeline.py ADDED
@@ -0,0 +1,279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import shutil
2
+ from pathlib import Path
3
+ from types import SimpleNamespace
4
+
5
+ import pytest
6
+ from telethon import errors, types
7
+
8
+ from bot.config import load_config
9
+ from bot.db import Database
10
+ from bot.media import probe
11
+ from bot.parser import DEFAULT_TEMPLATE, compute_new_name
12
+ from bot.pipeline import Renamer, decide_send_type, guess_mime, original_name_of, source_ext
13
+ from bot.queue import Job
14
+
15
+ from .conftest import needs_ffmpeg
16
+
17
+
18
+ class FakeMessage:
19
+ def __init__(self, name, size, *, video=True, audio=False, mime="video/x-matroska", msg_id=7, ext=None):
20
+ self.id = msg_id
21
+ self.file = SimpleNamespace(name=name, size=size, ext=ext if ext is not None else Path(name or "").suffix, mime_type=mime)
22
+ self.video = object() if video else None
23
+ self.audio = object() if audio else None
24
+ self.document = object()
25
+
26
+
27
+ class FakeClient:
28
+ def __init__(self, source: Path, message: FakeMessage):
29
+ self.source = source
30
+ self.message = message
31
+ self.sent = []
32
+ self.edits = []
33
+ self.uploads = {}
34
+ self.download_error = None
35
+ self.send_errors = []
36
+
37
+ async def get_messages(self, chat_id, ids):
38
+ return self.message
39
+
40
+ async def download_media(self, message, file, progress_callback):
41
+ progress_callback(1, 2)
42
+ if self.download_error:
43
+ raise self.download_error
44
+ shutil.copy(self.source, file)
45
+ progress_callback(2, 2)
46
+
47
+ async def upload_file(self, path, file_name, progress_callback):
48
+ progress_callback(1, 1)
49
+ keep = Path(path).with_name("uploaded_copy_" + Path(path).name)
50
+ keep = Path(self.source).parent / ("upload-" + str(len(self.uploads)) + Path(path).suffix)
51
+ shutil.copy(path, keep)
52
+ self.uploads[len(self.uploads)] = keep
53
+ return types.InputFileBig(id=len(self.uploads), parts=1, name=file_name)
54
+
55
+ async def send_file(self, target, input_file, **kwargs):
56
+ if self.send_errors:
57
+ raise self.send_errors.pop(0)
58
+ self.sent.append((target, input_file, kwargs))
59
+
60
+ async def edit_message(self, message, text, buttons=None, parse_mode=None):
61
+ self.edits.append((text, buttons))
62
+
63
+
64
+ @pytest.fixture
65
+ async def env(tmp_path, media_dir):
66
+ cfg = load_config({
67
+ "API_ID": "1", "API_HASH": "h", "BOT_TOKEN": "1:t", "OWNER_ID": "42",
68
+ "TEMP_DIR": str(tmp_path / "work"), "FAST_TRANSFER": "0", "PROGRESS_INTERVAL": "2",
69
+ })
70
+ db = Database(f"sqlite+aiosqlite:///{tmp_path}/p.db")
71
+ await db.init()
72
+ logs = []
73
+
74
+ async def log_send(text):
75
+ logs.append(text)
76
+
77
+ ns = SimpleNamespace(cfg=cfg, db=db, logs=logs, log_send=log_send, media_dir=media_dir, tmp=tmp_path)
78
+ yield ns
79
+ await db.close()
80
+
81
+
82
+ def make(env, name="[SubsPlease] Tokyo Revengers - 05 (1080p) [ABCD1234].mkv", src="sample.mkv", **msg_kwargs):
83
+ source = env.media_dir / src
84
+ message = FakeMessage(name, source.stat().st_size, **msg_kwargs)
85
+ client = FakeClient(source, message)
86
+ renamer = Renamer(client, env.cfg, env.db, env.log_send)
87
+ new_name, _ = compute_new_name(template=DEFAULT_TEMPLATE, mode="filename", original_name=name, original_ext=Path(name).suffix)
88
+ job = Job(id=1, user_id=42, chat_id=42, msg_id=7, file_name=name, size=source.stat().st_size, new_name=new_name,
89
+ status_msg=SimpleNamespace(id=99))
90
+ return client, renamer, job
91
+
92
+
93
+ @needs_ffmpeg
94
+ async def test_plain_rename_sends_video_with_thumbnail(env):
95
+ client, renamer, job = make(env)
96
+ await renamer.process(job)
97
+
98
+ assert job.new_name == "[S01E05] Tokyo Revengers [1080p].mkv"
99
+ (target, input_file, kw), = client.sent
100
+ assert target == 42 and kw["reply_to"] == 7
101
+ by_type = {type(a): a for a in kw["attributes"]}
102
+ assert by_type[types.DocumentAttributeFilename].file_name == job.new_name
103
+ video = by_type[types.DocumentAttributeVideo]
104
+ assert (video.w, video.h) == (320, 240) and 1 < video.duration < 3 and video.supports_streaming
105
+ assert kw["force_document"] is False and kw["mime_type"] == "video/x-matroska"
106
+ assert kw["thumb"][:2] == b"\xff\xd8" # generated from a video frame
107
+ assert kw["caption"] == "[S01E05] Tokyo Revengers [1080p]"
108
+ assert input_file.name == job.new_name
109
+ assert client.uploads[0].read_bytes() == (env.media_dir / "sample.mkv").read_bytes() # untouched without metadata
110
+ assert "Done" in client.edits[-1][0] and client.edits[-1][1] is None
111
+ assert job.state == "done"
112
+ assert (await env.db.totals())[0] == 1
113
+ assert not (Path(env.cfg.temp_dir) / "job1").exists() # temp files cleaned
114
+ assert any("Tokyo Revengers" in t for t in env.logs)
115
+
116
+
117
+ @needs_ffmpeg
118
+ async def test_metadata_and_track_selection_are_applied_before_upload(env):
119
+ await env.db.update_settings(
120
+ 42, metadata_on=True, keep_audio=["english", "japanese"], keep_subtitle=["2"],
121
+ meta={"title": "Tokyo Revengers", "audio": "{lang}", "subtitle": "English"},
122
+ )
123
+ client, renamer, job = make(env)
124
+ await renamer.process(job)
125
+ out = await probe(client.uploads[0])
126
+ assert [s.lang for s in out.audio] == ["eng", "jpn"]
127
+ assert [s.lang for s in out.subtitles] == ["eng"]
128
+ assert out.tags.get("title") == "Tokyo Revengers"
129
+ assert [s.title for s in out.audio] == ["English", "Japanese"]
130
+ assert client.sent and job.state == "done"
131
+
132
+
133
+ @needs_ffmpeg
134
+ async def test_document_file_type_is_forced(env):
135
+ await env.db.update_settings(42, file_type="document")
136
+ client, renamer, job = make(env)
137
+ await renamer.process(job)
138
+ (_t, _f, kw), = client.sent
139
+ assert kw["force_document"] is True
140
+ assert types.DocumentAttributeVideo not in {type(a) for a in kw["attributes"]}
141
+
142
+
143
+ @needs_ffmpeg
144
+ async def test_same_type_keeps_document_as_document(env):
145
+ client, renamer, job = make(env, video=False) # original was sent as a file
146
+ await renamer.process(job)
147
+ (_t, _f, kw), = client.sent
148
+ assert kw["force_document"] is True
149
+ assert types.DocumentAttributeVideo not in {type(a) for a in kw["attributes"]}
150
+
151
+
152
+ @needs_ffmpeg
153
+ async def test_forced_video_for_non_video_falls_back_to_document(env, tmp_path):
154
+ blob = tmp_path / "data.zip"
155
+ blob.write_bytes(b"PK" + b"0" * 5000)
156
+ message = FakeMessage("stuff.zip", 5002, video=False, mime="application/zip")
157
+ client = FakeClient(blob, message)
158
+ renamer = Renamer(client, env.cfg, env.db, env.log_send)
159
+ await env.db.update_settings(42, file_type="video")
160
+ job = Job(id=2, user_id=42, chat_id=42, msg_id=7, file_name="stuff.zip", size=5002, new_name="Stuff.zip", status_msg=object())
161
+ await renamer.process(job)
162
+ (_t, _f, kw), = client.sent
163
+ assert kw["force_document"] is True and kw["mime_type"] == "application/zip"
164
+ assert kw["thumb"] is None
165
+
166
+
167
+ @needs_ffmpeg
168
+ async def test_custom_thumbnail_wins_over_generated(env):
169
+ await env.db.set_thumb(42, b"\xff\xd8custom")
170
+ client, renamer, job = make(env)
171
+ await renamer.process(job)
172
+ assert client.sent[0][2]["thumb"] == b"\xff\xd8custom"
173
+
174
+
175
+ @needs_ffmpeg
176
+ async def test_target_channel(env):
177
+ await env.db.update_settings(42, target_channel=-100123)
178
+ client, renamer, job = make(env)
179
+ await renamer.process(job)
180
+ (target, _f, kw), = client.sent
181
+ assert target == -100123 and "reply_to" not in kw
182
+ assert "channel" in client.edits[-1][0]
183
+
184
+
185
+ @needs_ffmpeg
186
+ async def test_audio_file_sent_as_audio_with_tags(env):
187
+ await env.db.update_settings(42, metadata_on=True, meta={"title": "Song", "artist": "Me"})
188
+ client, renamer, job = make(env, name="old name.mp3", src="sample.mp3", video=False, audio=True, mime="audio/mpeg")
189
+ job.new_name = "New Song.mp3"
190
+ await renamer.process(job)
191
+ (_t, _f, kw), = client.sent
192
+ audio = next(a for a in kw["attributes"] if isinstance(a, types.DocumentAttributeAudio))
193
+ assert audio.title == "Song" and audio.performer == "Me" and audio.duration >= 1
194
+ assert kw["force_document"] is False and kw["mime_type"] == "audio/mpeg"
195
+ assert (await probe(client.uploads[0])).tags["title"] == "Song"
196
+
197
+
198
+ @needs_ffmpeg
199
+ async def test_unsupported_container_with_metadata_warns_but_still_renames(env, tmp_path):
200
+ # an .avi has video streams but is outside the supported remux containers
201
+ avi = tmp_path / "x.avi"
202
+ import subprocess
203
+ subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc=duration=1:size=64x64:rate=5",
204
+ "-c:v", "mpeg4", str(avi)], check=True)
205
+ message = FakeMessage("x.avi", avi.stat().st_size, video=False, mime="video/x-msvideo")
206
+ client = FakeClient(avi, message)
207
+ await env.db.update_settings(42, metadata_on=True, meta={"title": "T"})
208
+ job = Job(id=3, user_id=42, chat_id=42, msg_id=7, file_name="x.avi", size=avi.stat().st_size, new_name="Y.avi", status_msg=object())
209
+ await Renamer(client, env.cfg, env.db, env.log_send).process(job)
210
+ assert client.sent and "not supported" in client.edits[-1][0]
211
+
212
+
213
+ @needs_ffmpeg
214
+ async def test_caption_rejected_is_retried_without_caption(env):
215
+ client, renamer, job = make(env)
216
+ client.send_errors = [errors.MediaCaptionTooLongError(request=None)]
217
+ await renamer.process(job)
218
+ (_t, _f, kw), = client.sent
219
+ assert kw["caption"] is None
220
+ assert "caption was rejected" in client.edits[-1][0] and job.state == "done"
221
+
222
+
223
+ @needs_ffmpeg
224
+ async def test_cancel_before_start(env):
225
+ client, renamer, job = make(env)
226
+ job.cancel.set()
227
+ await renamer.process(job)
228
+ assert job.state == "cancelled" and client.sent == []
229
+ assert "Cancelled" in client.edits[-1][0]
230
+ assert not (Path(env.cfg.temp_dir) / "job1").exists()
231
+
232
+
233
+ @needs_ffmpeg
234
+ async def test_failure_is_reported_and_logged(env):
235
+ client, renamer, job = make(env)
236
+ client.download_error = RuntimeError("network down")
237
+ await renamer.process(job)
238
+ assert job.state == "failed" and client.sent == []
239
+ assert "Failed" in client.edits[-1][0] and "network down" in client.edits[-1][0]
240
+ assert any("Job failed" in t and "network down" in t for t in env.logs)
241
+ assert not (Path(env.cfg.temp_dir) / "job1").exists()
242
+
243
+
244
+ @needs_ffmpeg
245
+ async def test_not_enough_disk_space(env, monkeypatch):
246
+ client, renamer, job = make(env)
247
+ monkeypatch.setattr(shutil, "disk_usage", lambda p: SimpleNamespace(total=100, used=100, free=10))
248
+ await renamer.process(job)
249
+ assert job.state == "failed" and "disk space" in client.edits[-1][0]
250
+
251
+
252
+ @needs_ffmpeg
253
+ async def test_deleted_original_message_fails_cleanly(env):
254
+ client, renamer, job = make(env)
255
+
256
+ async def gone(chat_id, ids):
257
+ return None
258
+
259
+ client.get_messages = gone
260
+ await renamer.process(job)
261
+ assert job.state == "failed" and "deleted" in client.edits[-1][0]
262
+
263
+
264
+ def test_helpers():
265
+ video = FakeMessage("a.mkv", 1)
266
+ doc = FakeMessage("a.mkv", 1, video=False)
267
+ audio = FakeMessage("a.mp3", 1, video=False, audio=True)
268
+ assert decide_send_type("same", video) == "video"
269
+ assert decide_send_type("same", doc) == "document"
270
+ assert decide_send_type("same", audio) == "audio"
271
+ assert decide_send_type("document", video) == "document"
272
+ assert decide_send_type("audio", doc) == "audio"
273
+ assert guess_mime("x.mkv", None) == "video/x-matroska"
274
+ assert guess_mime("x.unknown123", "foo/bar") == "foo/bar"
275
+ assert guess_mime("x.unknown123", None) == "application/octet-stream"
276
+ nameless = FakeMessage(None, 1, ext=".mp4", msg_id=55)
277
+ assert original_name_of(nameless) == "video_55.mp4"
278
+ assert source_ext(nameless, "video_55.mp4") == ".mp4"
279
+ assert source_ext(FakeMessage("noext", 1, ext=".mkv"), "noext") == ".mkv"
tests/test_queue_progress.py ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ from types import SimpleNamespace
3
+
4
+ from telethon import errors
5
+
6
+ from bot.progress import ProgressReporter
7
+ from bot.queue import Job, JobQueue
8
+
9
+
10
+ def job(queue, user=1, name="a.mkv"):
11
+ return Job(id=queue.new_id(), user_id=user, chat_id=user, msg_id=1, file_name=name, size=1, new_name=name)
12
+
13
+
14
+ async def test_runs_jobs_fifo_with_single_worker():
15
+ order = []
16
+ gate = asyncio.Event()
17
+
18
+ async def runner(j):
19
+ order.append(("start", j.id))
20
+ await gate.wait()
21
+ order.append(("end", j.id))
22
+
23
+ q = JobQueue(runner, workers=1)
24
+ q.start()
25
+ a, b, c = job(q), job(q), job(q)
26
+ assert q.add(a) == 0 and q.add(b) == 1 and q.add(c) == 2
27
+ await asyncio.sleep(0.05)
28
+ assert q.running == 1 and q.waiting == 2
29
+ assert q.position(a) == 0 and q.position(b) == 1 and q.position(c) == 2
30
+ gate.set()
31
+ await asyncio.sleep(0.1)
32
+ assert order == [("start", 1), ("end", 1), ("start", 2), ("end", 2), ("start", 3), ("end", 3)]
33
+ assert q.jobs == {} # finished jobs are forgotten
34
+ await q.stop()
35
+
36
+
37
+ async def test_two_workers_run_in_parallel():
38
+ running = []
39
+ peak = 0
40
+ gate = asyncio.Event()
41
+
42
+ async def runner(j):
43
+ nonlocal peak
44
+ running.append(j.id)
45
+ peak = max(peak, len(running))
46
+ await gate.wait()
47
+ running.remove(j.id)
48
+
49
+ q = JobQueue(runner, workers=2)
50
+ q.start()
51
+ for _ in range(4):
52
+ q.add(job(q))
53
+ await asyncio.sleep(0.05)
54
+ assert q.running == 2 and peak == 2
55
+ gate.set()
56
+ await asyncio.sleep(0.1)
57
+ await q.stop()
58
+
59
+
60
+ async def test_cancel_queued_job_skips_it_and_calls_back():
61
+ done, cancelled = [], []
62
+ gate = asyncio.Event()
63
+
64
+ async def runner(j):
65
+ done.append(j.id)
66
+ await gate.wait()
67
+
68
+ async def on_cancelled(j):
69
+ cancelled.append(j.id)
70
+
71
+ q = JobQueue(runner, workers=1, on_cancelled=on_cancelled)
72
+ q.start()
73
+ a, b, c = job(q), job(q), job(q)
74
+ for j in (a, b, c):
75
+ q.add(j)
76
+ await asyncio.sleep(0.05)
77
+ assert q.cancel(b.id) is True
78
+ assert q.cancel(b.id) is False # already gone
79
+ await asyncio.sleep(0.05)
80
+ assert cancelled == [b.id] and b.state == "cancelled"
81
+ gate.set()
82
+ await asyncio.sleep(0.1)
83
+ assert done == [a.id, c.id]
84
+ await q.stop()
85
+
86
+
87
+ async def test_cancel_running_job_sets_its_event_and_cancel_is_scoped_to_user():
88
+ seen = {}
89
+ started = asyncio.Event()
90
+
91
+ async def runner(j):
92
+ started.set()
93
+ await j.cancel.wait()
94
+ seen["cancelled"] = True
95
+
96
+ q = JobQueue(runner, workers=1)
97
+ q.start()
98
+ a = job(q, user=5)
99
+ q.add(a)
100
+ await started.wait()
101
+ assert q.cancel(a.id, user_id=99) is False # someone else's job
102
+ assert q.cancel(a.id, user_id=5) is True
103
+ await asyncio.sleep(0.05)
104
+ assert seen == {"cancelled": True}
105
+ await q.stop()
106
+
107
+
108
+ async def test_cancel_user_and_cancel_all():
109
+ gate = asyncio.Event()
110
+
111
+ async def runner(j):
112
+ await gate.wait()
113
+
114
+ q = JobQueue(runner, workers=1)
115
+ q.start()
116
+ jobs = [job(q, user=1), job(q, user=1), job(q, user=2), job(q, user=2)]
117
+ for j in jobs:
118
+ q.add(j)
119
+ await asyncio.sleep(0.05)
120
+ assert q.cancel_user(1) == 2
121
+ assert [j.id for j in q.for_user(2)] == [jobs[2].id, jobs[3].id]
122
+ assert q.cancel_all() == 2
123
+ gate.set()
124
+ await q.stop()
125
+
126
+
127
+ async def test_runner_crash_does_not_kill_the_worker():
128
+ ran = []
129
+
130
+ async def runner(j):
131
+ ran.append(j.id)
132
+ if j.id == 1:
133
+ raise RuntimeError("boom")
134
+
135
+ q = JobQueue(runner, workers=1)
136
+ q.start()
137
+ q.add(job(q))
138
+ q.add(job(q))
139
+ await asyncio.sleep(0.1)
140
+ assert ran == [1, 2]
141
+ await q.stop()
142
+
143
+
144
+ # ---------------------------------------------------------------------------
145
+ # progress reporter
146
+ # ---------------------------------------------------------------------------
147
+
148
+
149
+ class FakeClient:
150
+ def __init__(self):
151
+ self.edits = []
152
+ self.error = None
153
+
154
+ async def edit_message(self, message, text, buttons=None, parse_mode=None):
155
+ if self.error:
156
+ raise self.error
157
+ self.edits.append((text, buttons))
158
+
159
+
160
+ async def test_reporter_throttles_and_shows_stage_and_cancel_button():
161
+ client = FakeClient()
162
+ r = ProgressReporter(client, SimpleNamespace(id=1), 7, "My <File>.mkv", interval=60)
163
+ r.set_stage("⬇️ Downloading", 1000)
164
+ await asyncio.sleep(0.02)
165
+ assert len(client.edits) == 1
166
+ text, buttons = client.edits[0]
167
+ assert "Downloading" in text and "My &lt;File&gt;.mkv" in text
168
+ assert buttons[0][0].type.data == b"job:x:7"
169
+
170
+ for cur in range(1, 500, 50):
171
+ r(cur, 1000) # within the throttle window: no more edits
172
+ await asyncio.sleep(0.02)
173
+ assert len(client.edits) == 1
174
+
175
+ r(1000, 1000) # completion always shows
176
+ await asyncio.sleep(0.02)
177
+ assert len(client.edits) == 2 and "100.0%" in client.edits[-1][0]
178
+
179
+
180
+ async def test_stage_change_is_shown_even_while_an_edit_is_in_flight():
181
+ class SlowClient(FakeClient):
182
+ async def edit_message(self, message, text, buttons=None, parse_mode=None):
183
+ await asyncio.sleep(0.05)
184
+ await super().edit_message(message, text, buttons=buttons, parse_mode=parse_mode)
185
+
186
+ client = SlowClient()
187
+ r = ProgressReporter(client, None, 1, "x", interval=0)
188
+ r.set_stage("⬇️ Downloading", 100)
189
+ r(100, 100) # dropped: the first edit is still running
190
+ r.set_stage("⚙️ Applying metadata", 0) # must not be dropped
191
+ await r.finish("✅ Done")
192
+ stages = [t for t, _b in client.edits]
193
+ assert "Downloading" in stages[0] and "Applying metadata" in stages[1] and stages[-1] == "✅ Done"
194
+ assert len(stages) == 3
195
+
196
+
197
+ async def test_reporter_render_numbers():
198
+ r = ProgressReporter(FakeClient(), None, 1, "x", interval=4)
199
+ r.stage = "⬆️ Uploading"
200
+ r._speed = 1024 * 1024
201
+ text = r.render(512 * 1024 * 1024, 1024 * 1024 * 1024)
202
+ assert "50.0%" in text and "512.00 MB / 1.00 GB" in text and "1.00 MB/s" in text and "ETA 8:32" in text
203
+ assert "Working" in r.render(0, 0)
204
+
205
+
206
+ async def test_reporter_survives_flood_wait_and_message_errors():
207
+ client = FakeClient()
208
+ r = ProgressReporter(client, None, 1, "x", interval=0)
209
+ client.error = errors.FloodWaitError(request=None, capture=3)
210
+ r.set_stage("a", 10)
211
+ await asyncio.sleep(0.02)
212
+ assert r._muted_until > 0 # backed off instead of crashing
213
+ client.error = errors.MessageNotModifiedError(request=None)
214
+ r._muted_until = 0
215
+ r(5, 10)
216
+ await asyncio.sleep(0.02)
217
+ client.error = RuntimeError("deleted")
218
+ r(10, 10)
219
+ await asyncio.sleep(0.02)
220
+
221
+
222
+ async def test_reporter_finish_removes_buttons():
223
+ client = FakeClient()
224
+ r = ProgressReporter(client, None, 1, "x")
225
+ await r.finish("✅ Done")
226
+ assert client.edits[-1] == ("✅ Done", None)
tests/test_settings_ops.py ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+
3
+ from bot.db import DEFAULT_SETTINGS, Database
4
+ from bot.settings_ops import META_COMMANDS, META_KINDS, apply_setting, parse_channel, preview_template
5
+
6
+ UID = 7
7
+
8
+
9
+ @pytest.fixture
10
+ async def db(tmp_path):
11
+ d = Database(f"sqlite+aiosqlite:///{tmp_path}/s.db")
12
+ await d.init()
13
+ yield d
14
+ await d.close()
15
+
16
+
17
+ async def get(db):
18
+ return await db.get_settings(UID)
19
+
20
+
21
+ async def test_template_saved_with_preview(db):
22
+ ok, msg = await apply_setting(db, UID, "template", "[S{season}E{episode}] {title} [{quality}]")
23
+ assert ok and "Template saved" in msg and "Preview" in msg
24
+ assert (await get(db))["template"] == "[S{season}E{episode}] {title} [{quality}]"
25
+
26
+
27
+ async def test_template_validation_and_reset(db):
28
+ assert (await apply_setting(db, UID, "template", " "))[0] is False
29
+ ok, msg = await apply_setting(db, UID, "template", "x" * 201)
30
+ assert not ok and "too long" in msg
31
+ assert (await get(db))["template"] == DEFAULT_SETTINGS["template"]
32
+ await apply_setting(db, UID, "template", "{title}")
33
+ ok, _ = await apply_setting(db, UID, "template", "reset")
34
+ assert ok and (await get(db))["template"] == DEFAULT_SETTINGS["template"]
35
+
36
+
37
+ async def test_template_unknown_variables_are_reported_but_saved(db):
38
+ ok, msg = await apply_setting(db, UID, "template", "{title} {nope}")
39
+ assert ok and "{nope}" in msg and "Unknown variables" in msg
40
+ assert (await get(db))["template"] == "{title} {nope}"
41
+
42
+
43
+ async def test_caption_set_validate_reset(db):
44
+ ok, msg = await apply_setting(db, UID, "caption", "<b>{filename}</b> · {size}")
45
+ assert ok and (await get(db))["caption"] == "<b>{filename}</b> · {size}"
46
+ assert "Unknown" not in msg
47
+ assert (await apply_setting(db, UID, "caption", "x" * 801))[0] is False
48
+ assert (await apply_setting(db, UID, "caption", ""))[0] is False
49
+ ok, msg = await apply_setting(db, UID, "caption", "{bogus}")
50
+ assert ok and "{bogus}" in msg
51
+ ok, _ = await apply_setting(db, UID, "caption", "reset")
52
+ assert ok and (await get(db))["caption"] == "{filename}"
53
+
54
+
55
+ async def test_remove_words(db):
56
+ ok, msg = await apply_setting(db, UID, "remove", "@Chan, [Group]\n www.site.com ,, ")
57
+ assert ok
58
+ assert (await get(db))["remove_words"] == ["@Chan", "[Group]", "www.site.com"]
59
+ assert (await apply_setting(db, UID, "remove", " , ,"))[0] is False
60
+ assert (await apply_setting(db, UID, "remove", ", ".join(f"w{i}" for i in range(51))))[0] is False
61
+ assert (await apply_setting(db, UID, "remove", "x" * 61))[0] is False
62
+ ok, _ = await apply_setting(db, UID, "remove", "off")
63
+ assert ok and (await get(db))["remove_words"] == []
64
+
65
+
66
+ @pytest.mark.parametrize("kind", sorted(META_KINDS))
67
+ async def test_every_metadata_field_can_be_set_and_cleared(db, kind):
68
+ field = META_KINDS[kind]
69
+ ok, msg = await apply_setting(db, UID, kind, f"{field} value")
70
+ assert ok and (await get(db))["meta"][field] == f"{field} value"
71
+ assert "Metadata is Off" in msg # reminder that nothing applies until it is switched on
72
+ await db.update_settings(UID, metadata_on=True)
73
+ ok, msg = await apply_setting(db, UID, kind, "another")
74
+ assert ok and "Metadata is Off" not in msg
75
+ ok, _ = await apply_setting(db, UID, kind, "off")
76
+ assert ok and (await get(db))["meta"][field] == ""
77
+
78
+
79
+ async def test_metadata_value_validation(db):
80
+ assert (await apply_setting(db, UID, "mt_title", ""))[0] is False
81
+ ok, msg = await apply_setting(db, UID, "mt_title", "x" * 201)
82
+ assert not ok and "too long" in msg
83
+ assert (await get(db))["meta"]["title"] == ""
84
+
85
+
86
+ async def test_meta_commands_map_to_known_kinds():
87
+ assert set(META_COMMANDS.values()) == set(META_KINDS)
88
+
89
+
90
+ async def test_metadata_values_are_html_escaped_in_the_reply(db):
91
+ ok, msg = await apply_setting(db, UID, "mt_title", "<script>")
92
+ assert ok and "<script>" not in msg and "&lt;script&gt;" in msg
93
+
94
+
95
+ @pytest.mark.parametrize("kind", ["keep_audio", "keep_subtitle"])
96
+ async def test_keep_tracks_by_language_names_keeps_order_and_enables_metadata(db, kind):
97
+ assert (await get(db))["metadata_on"] is False
98
+ ok, msg = await apply_setting(db, UID, kind, "English, Japanese")
99
+ assert ok
100
+ s = await get(db)
101
+ assert s[kind] == ["english", "japanese"]
102
+ assert s["metadata_on"] is True and "turned it On" in msg
103
+ ok, msg = await apply_setting(db, UID, kind, "2 1")
104
+ assert ok and (await get(db))[kind] == ["2", "1"] and "turned it On" not in msg
105
+ ok, msg = await apply_setting(db, UID, kind, "off")
106
+ assert ok and (await get(db))[kind] == [] and "all tracks are kept" in msg
107
+
108
+
109
+ async def test_keep_tracks_rejects_bad_input_without_changing_anything(db):
110
+ await apply_setting(db, UID, "keep_audio", "english")
111
+ for bad in ("0", "1 english 0", "***", " ", "a" * 30, "1 2 3 4 5 6 7 8 9 10 11 12 13"):
112
+ ok, msg = await apply_setting(db, UID, "keep_audio", bad)
113
+ assert not ok and msg.startswith("❌"), bad
114
+ assert (await get(db))["keep_audio"] == ["english"]
115
+
116
+
117
+ async def test_keep_tracks_duplicates_are_collapsed(db):
118
+ await apply_setting(db, UID, "keep_audio", "english eng English 2 2")
119
+ assert (await get(db))["keep_audio"] == ["english", "eng", "2"]
120
+
121
+
122
+ async def test_unknown_kind_raises(db):
123
+ with pytest.raises(ValueError):
124
+ await apply_setting(db, UID, "nonsense", "x")
125
+
126
+
127
+ def test_parse_channel():
128
+ assert parse_channel("-1001234567890") == (True, -1001234567890, "")
129
+ assert parse_channel("12345") == (True, 12345, "")
130
+ assert parse_channel("off") == (True, None, "")
131
+ ok, cid, err = parse_channel("@mychannel")
132
+ assert not ok and cid is None and "numeric" in err
133
+ assert parse_channel("12")[0] is False
134
+
135
+
136
+ def test_preview_template_renders_sample():
137
+ out = preview_template("{title} [{quality}]")
138
+ assert "Preview" in out and "<code>" in out and "[" in out
tests/test_sysinfo.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+
3
+ from bot import sysinfo
4
+
5
+
6
+ def test_parse_cpu_max():
7
+ assert sysinfo.parse_cpu_max("200000 100000") == 2.0
8
+ assert sysinfo.parse_cpu_max("max 100000") is None
9
+ assert sysinfo.parse_cpu_max("garbage") is None
10
+ assert sysinfo.parse_cpu_max(None) is None
11
+
12
+
13
+ def test_parse_mem_limit():
14
+ assert sysinfo.parse_mem_limit("17179869184") == 17179869184
15
+ assert sysinfo.parse_mem_limit("max") is None
16
+ assert sysinfo.parse_mem_limit("9223372036854771712") is None # cgroup v1 "unlimited"
17
+ assert sysinfo.parse_mem_limit(None) is None
18
+
19
+
20
+ def test_parse_stat_value():
21
+ text = "usage_usec 12345\nuser_usec 1\ninactive_file 99"
22
+ assert sysinfo.parse_stat_value(text, "usage_usec") == 12345
23
+ assert sysinfo.parse_stat_value(text, "inactive_file") == 99
24
+ assert sysinfo.parse_stat_value(text, "missing") is None
25
+
26
+
27
+ @pytest.fixture
28
+ def fake_cgroup(tmp_path, monkeypatch):
29
+ (tmp_path / "cpu.max").write_text("200000 100000\n")
30
+ (tmp_path / "memory.max").write_text("17179869184\n")
31
+ (tmp_path / "memory.current").write_text("3000000000\n")
32
+ (tmp_path / "memory.stat").write_text("anon 1\ninactive_file 1000000000\n")
33
+ (tmp_path / "cpu.stat").write_text("usage_usec 1000000\n")
34
+ monkeypatch.setattr(sysinfo, "CGROUP_ROOT", str(tmp_path))
35
+ return tmp_path
36
+
37
+
38
+ def test_container_memory_uses_cgroup_and_ignores_page_cache(fake_cgroup):
39
+ used, total = sysinfo.memory_usage()
40
+ assert used == 2_000_000_000
41
+ assert total <= 17179869184
42
+ assert sysinfo.cpu_cores() == 2.0
43
+
44
+
45
+ async def test_container_cpu_percent_from_cgroup(fake_cgroup, monkeypatch):
46
+ calls = {"n": 0}
47
+ real = sysinfo._cgroup_cpu_usec
48
+
49
+ def fake_usec():
50
+ calls["n"] += 1
51
+ return 1_000_000 if calls["n"] == 1 else 1_500_000 # 0.5 cpu-seconds over ~0.1s window
52
+
53
+ monkeypatch.setattr(sysinfo, "_cgroup_cpu_usec", fake_usec)
54
+ pct = await sysinfo.cpu_percent(interval=0.1)
55
+ assert 0 < pct <= 100
56
+ monkeypatch.setattr(sysinfo, "_cgroup_cpu_usec", real)
57
+
58
+
59
+ def test_falls_back_to_psutil_without_cgroup(tmp_path, monkeypatch):
60
+ monkeypatch.setattr(sysinfo, "CGROUP_ROOT", str(tmp_path / "does-not-exist"))
61
+ used, total = sysinfo.memory_usage()
62
+ assert total > 0 and used >= 0
63
+ assert sysinfo.cpu_cores() >= 1
64
+
65
+
66
+ async def test_format_status_and_stats(tmp_path):
67
+ text = sysinfo.format_status(123.4, 1, "1.45.0")
68
+ assert "123 ms" in text and "DC 1" in text and "v1.0.0" in text and "Uptime" in text
69
+ assert "n/a" in sysinfo.format_status(None, None, "x")
70
+ stats = await sysinfo.format_stats(3, 12, 4_500_000_000, 1, 2, str(tmp_path))
71
+ for needle in ("Users:</b> 3", "Files renamed", "1 running · 2 waiting", "CPU", "RAM", "Disk", "Network"):
72
+ assert needle in stats
tests/test_telethon_glue.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Exercise the private-Telethon-API glue with real Telethon objects against a dummy TCP server.
2
+
3
+ The server never speaks MTProto; connecting with an existing auth key does not need
4
+ a handshake, which is enough to prove the constructor/connect/disconnect signatures
5
+ the fast path relies on still match the installed Telethon.
6
+ """
7
+ import asyncio
8
+ import os
9
+ from types import SimpleNamespace
10
+
11
+ import pytest
12
+ from telethon import TelegramClient
13
+ from telethon.crypto import AuthKey
14
+ from telethon.sessions import StringSession
15
+ from telethon.tl import types
16
+
17
+ from bot import fasttransfer as ft
18
+
19
+
20
+ @pytest.fixture
21
+ async def server():
22
+ connections = []
23
+
24
+ async def handle(reader, writer):
25
+ connections.append(writer)
26
+ try:
27
+ await reader.read()
28
+ finally:
29
+ writer.close()
30
+
31
+ srv = await asyncio.start_server(handle, "127.0.0.1", 0)
32
+ port = srv.sockets[0].getsockname()[1]
33
+ yield SimpleNamespace(port=port, connections=connections)
34
+ srv.close()
35
+ await srv.wait_closed()
36
+
37
+
38
+ async def test_open_transports_on_home_dc(server, monkeypatch):
39
+ client = TelegramClient(StringSession(), 12345, "0123456789abcdef0123456789abcdef")
40
+ client.session.set_dc(2, "127.0.0.1", server.port)
41
+ client.session.auth_key = AuthKey(os.urandom(256))
42
+ monkeypatch.setattr(
43
+ TelegramClient, "_config",
44
+ SimpleNamespace(dc_options=[types.DcOption(id=2, ip_address="127.0.0.1", port=server.port)]),
45
+ raising=False,
46
+ )
47
+
48
+ transports = await ft.open_transports(client, 2, 3)
49
+ try:
50
+ assert len(transports) == 3
51
+ for _ in range(50):
52
+ if len(server.connections) == 3:
53
+ break
54
+ await asyncio.sleep(0.02)
55
+ assert len(server.connections) == 3 # three real TCP connections to the "DC"
56
+ finally:
57
+ await ft.close_all(transports)
58
+
59
+
60
+ async def test_get_file_request_is_built_with_current_tl_signature():
61
+ from telethon.tl.functions.upload import GetFileRequest, SaveBigFilePartRequest
62
+
63
+ loc = types.InputDocumentFileLocation(id=1, access_hash=2, file_reference=b"r", thumb_size="")
64
+ req = GetFileRequest(location=loc, offset=1024 * 1024, limit=1024 * 1024)
65
+ assert req.offset == 1024 * 1024 and req.limit == 1024 * 1024
66
+ part = SaveBigFilePartRequest(5, 0, 3, b"abc")
67
+ assert (part.file_id, part.file_part, part.file_total_parts, part.bytes) == (5, 0, 3, b"abc")
68
+ # and both serialise (catches wrong field types early)
69
+ bytes(req)
70
+ bytes(part)
71
+
72
+
73
+ async def test_document_location_helper():
74
+ from telethon import utils
75
+
76
+ doc = types.Document(
77
+ id=1, access_hash=2, file_reference=b"x", date=None, mime_type="video/x-matroska", size=10, dc_id=4, attributes=[]
78
+ )
79
+ dc_id, location = utils.get_input_location(doc)
80
+ assert dc_id == 4 and isinstance(location, types.InputDocumentFileLocation)
81
+
82
+
83
+ async def test_send_file_accepts_input_file_big_with_explicit_attributes():
84
+ """send_file(file=InputFileBig, attributes=..., mime_type=...) must keep our filename/video attributes."""
85
+ from telethon import utils
86
+
87
+ big = types.InputFileBig(id=1, parts=3, name="disk-name.mkv")
88
+ attrs, mime = utils.get_attributes(
89
+ big,
90
+ mime_type="video/x-matroska",
91
+ attributes=[
92
+ types.DocumentAttributeFilename("[S01E05] Show [1080p].mkv"),
93
+ types.DocumentAttributeVideo(duration=1420, w=1920, h=1080, supports_streaming=True),
94
+ ],
95
+ force_document=False,
96
+ )
97
+ by_type = {type(a): a for a in attrs}
98
+ assert by_type[types.DocumentAttributeFilename].file_name == "[S01E05] Show [1080p].mkv"
99
+ video = by_type[types.DocumentAttributeVideo]
100
+ assert (video.duration, video.w, video.h) == (1420, 1920, 1080)
101
+ assert mime == "video/x-matroska"
102
+
103
+ attrs, _ = utils.get_attributes(
104
+ big, mime_type="video/x-matroska", attributes=[types.DocumentAttributeFilename("n.mkv")], force_document=True
105
+ )
106
+ assert types.DocumentAttributeVideo not in {type(a) for a in attrs} # as a document: no video attribute
tests/test_web_main.py ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import dataclasses
3
+ import socket
4
+ from types import SimpleNamespace
5
+
6
+ import aiohttp
7
+ import pytest
8
+ from aiohttp.test_utils import TestClient, TestServer
9
+ from telethon import functions
10
+ from telethon.crypto import AuthKey
11
+
12
+ import bot.main as bot_main
13
+ from bot import __version__
14
+ from bot.config import load_config
15
+ from bot.db import Database
16
+ from bot.web import make_app, start_web
17
+
18
+ from .tgfakes import FakeClient
19
+
20
+
21
+ def free_port() -> int:
22
+ with socket.socket() as s:
23
+ s.bind(("127.0.0.1", 0))
24
+ return s.getsockname()[1]
25
+
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # health server
29
+ # ---------------------------------------------------------------------------
30
+
31
+
32
+ async def test_health_endpoints_answer_get_and_head():
33
+ async with TestClient(TestServer(make_app())) as client:
34
+ r = await client.get("/")
35
+ assert r.status == 200 and "running" in await r.text()
36
+ assert (await client.head("/")).status == 200 # cron pingers often use HEAD
37
+ r = await client.get("/health")
38
+ body = await r.json()
39
+ assert r.status == 200 and body["status"] == "ok" and body["version"] == __version__
40
+ assert body["uptime_seconds"] >= 0
41
+ assert (await client.get("/nope")).status == 404
42
+
43
+
44
+ async def test_start_web_listens_on_the_given_port():
45
+ port = free_port()
46
+ runner = await start_web(port, host="127.0.0.1")
47
+ try:
48
+ async with aiohttp.ClientSession() as http:
49
+ async with http.get(f"http://127.0.0.1:{port}/health") as r:
50
+ assert r.status == 200
51
+ finally:
52
+ await runner.cleanup()
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # start-up helpers
57
+ # ---------------------------------------------------------------------------
58
+
59
+
60
+ def test_clean_temp_dir_removes_only_job_leftovers(tmp_path):
61
+ root = tmp_path / "work"
62
+ (root / "job1").mkdir(parents=True)
63
+ (root / "job1" / "movie.mkv").write_bytes(b"1")
64
+ (root / "tracks5_6").mkdir()
65
+ (root / "tracks5_6" / "probe.mkv").write_bytes(b"2")
66
+ (root / "other").mkdir()
67
+ (root / "keep.txt").write_text("k")
68
+ bot_main.clean_temp_dir(str(root))
69
+ assert sorted(p.name for p in root.iterdir()) == ["keep.txt", "other"]
70
+
71
+
72
+ def test_clean_temp_dir_creates_a_missing_directory(tmp_path):
73
+ bot_main.clean_temp_dir(str(tmp_path / "a" / "b"))
74
+ assert (tmp_path / "a" / "b").is_dir()
75
+
76
+
77
+ def test_main_exits_with_status_2_when_configuration_is_missing(monkeypatch, capsys):
78
+ for key in ("API_ID", "API_HASH", "BOT_TOKEN", "OWNER_ID"):
79
+ monkeypatch.delenv(key, raising=False)
80
+ with pytest.raises(SystemExit) as exc:
81
+ bot_main.main()
82
+ assert exc.value.code == 2
83
+ assert "Missing required environment variables" in capsys.readouterr().err
84
+
85
+
86
+ class FakeTG:
87
+ created: list = []
88
+
89
+ def __init__(self, session, api_id, api_hash, **kw):
90
+ self.session, self.api_id, self.api_hash, self.kw = session, api_id, api_hash, kw
91
+ self.had_key = bool(session.auth_key)
92
+ self.started_with = None
93
+ FakeTG.created.append(self)
94
+
95
+ async def start(self, bot_token=None):
96
+ self.started_with = bot_token
97
+ if not self.session.auth_key: # a fresh login creates an auth key
98
+ self.session.set_dc(2, "149.154.167.51", 443)
99
+ self.session.auth_key = AuthKey(b"\x07" * 256)
100
+ return self
101
+
102
+
103
+ @pytest.fixture
104
+ async def login(tmp_path, monkeypatch):
105
+ FakeTG.created = []
106
+ monkeypatch.setattr("telethon.TelegramClient", FakeTG)
107
+ cfg = load_config({"API_ID": "11", "API_HASH": "hash", "BOT_TOKEN": "1234:abc", "OWNER_ID": "1"})
108
+ db = Database(f"sqlite+aiosqlite:///{tmp_path}/login.db")
109
+ await db.init()
110
+ yield SimpleNamespace(cfg=cfg, db=db)
111
+ await db.close()
112
+
113
+
114
+ async def test_first_login_saves_the_session_and_later_starts_reuse_it(login):
115
+ first = await bot_main.build_client(login.cfg, login.db)
116
+ assert not first.had_key and first.started_with == "1234:abc" and (first.api_id, first.api_hash) == (11, "hash")
117
+ assert first.kw["connection_retries"] == -1 and first.kw["auto_reconnect"] is True
118
+ assert await login.db.kv_get("session") and await login.db.kv_get("session_bot") == "1234"
119
+
120
+ second = await bot_main.build_client(login.cfg, login.db) # e.g. after the Space restarted
121
+ assert second.had_key and second.session.dc_id == 2
122
+
123
+
124
+ async def test_a_saved_session_of_another_bot_is_not_reused(login):
125
+ await bot_main.build_client(login.cfg, login.db)
126
+ other = dataclasses.replace(login.cfg, bot_token="9999:zzz")
127
+ client = await bot_main.build_client(other, login.db)
128
+ assert not client.had_key and await login.db.kv_get("session_bot") == "9999"
129
+
130
+
131
+ async def test_session_string_setting_wins_over_the_database(login):
132
+ saved = (await bot_main.build_client(login.cfg, login.db)).session.save()
133
+ await login.db.kv_delete("session")
134
+ await login.db.kv_delete("session_bot")
135
+ cfg = dataclasses.replace(login.cfg, session_string=saved)
136
+ assert (await bot_main.build_client(cfg, login.db)).had_key
137
+
138
+
139
+ # ---------------------------------------------------------------------------
140
+ # whole start-up sequence with a fake Telegram client
141
+ # ---------------------------------------------------------------------------
142
+
143
+
144
+ async def test_amain_wires_everything_and_exits_nonzero_if_telegram_disconnects(tmp_path, monkeypatch):
145
+ port = free_port()
146
+ env = {
147
+ "API_ID": "1", "API_HASH": "h", "BOT_TOKEN": "1:t", "OWNER_ID": "1000001", "LOG_CHANNEL": "-1001111111111",
148
+ "DATABASE_URL": f"sqlite+aiosqlite:///{tmp_path}/main.db", "TEMP_DIR": str(tmp_path / "work"),
149
+ "PORT": str(port), "FAST_TRANSFER": "0",
150
+ }
151
+ for key, value in env.items():
152
+ monkeypatch.setenv(key, value)
153
+ (tmp_path / "work" / "job9").mkdir(parents=True) # leftover of a "crashed" run
154
+
155
+ gate = asyncio.Event()
156
+ client = FakeClient()
157
+ client.disconnected = False
158
+
159
+ async def get_me():
160
+ return SimpleNamespace(username="rename_bot")
161
+
162
+ async def run_until_disconnected():
163
+ await gate.wait()
164
+
165
+ async def disconnect():
166
+ client.disconnected = True
167
+
168
+ client.get_me, client.run_until_disconnected, client.disconnect = get_me, run_until_disconnected, disconnect
169
+
170
+ async def fake_build_client(cfg, db):
171
+ return client
172
+
173
+ monkeypatch.setattr(bot_main, "build_client", fake_build_client)
174
+
175
+ task = asyncio.ensure_future(bot_main.amain())
176
+ try:
177
+ for _ in range(200): # wait for the "started" notice: everything is wired by then
178
+ if any("Bot started" in text for _chat, text, _kw in client.sent):
179
+ break
180
+ await asyncio.sleep(0.05)
181
+ else:
182
+ pytest.fail("the bot never finished starting")
183
+ async with aiohttp.ClientSession() as http:
184
+ async with http.get(f"http://127.0.0.1:{port}/health") as r:
185
+ assert r.status == 200
186
+ assert len(client.handlers) > 10
187
+ assert any(isinstance(r, functions.bots.SetBotCommandsRequest) for r in client.requests)
188
+ assert not (tmp_path / "work" / "job9").exists()
189
+ assert client.sent[0][0] == -1001111111111
190
+ gate.set() # Telegram connection lost -> exit non-zero so the container restarts
191
+ assert await asyncio.wait_for(task, 10) == 1
192
+ finally:
193
+ gate.set()
194
+ if not task.done():
195
+ task.cancel()
196
+ assert client.disconnected
197
+ with pytest.raises(aiohttp.ClientError):
198
+ async with aiohttp.ClientSession() as http:
199
+ async with http.get(f"http://127.0.0.1:{port}/health"):
200
+ pass
tests/tgfakes.py ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Stand-ins for Telethon events and clients, used by the handler tests.
2
+
3
+ ``dispatch`` pushes an event through the handlers registered on ``FakeClient`` the
4
+ same way Telethon does (every matching builder runs, in registration order), so
5
+ the real event builders, patterns and ``func`` filters are exercised too.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import inspect
10
+ from types import SimpleNamespace
11
+ from typing import Any, List, Optional
12
+
13
+ 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) -> SimpleNamespace:
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 = object() if file is not None else None
35
+ self.video = object() if video else None
36
+ self.audio = object() if audio else None
37
+ self.photo = object() if photo else None
38
+ self.sticker = object() if sticker else None
39
+ self.gif = object() if gif else None
40
+ self.voice = object() if voice else None
41
+ self.video_note = object() if video_note else None
42
+ self.media = object() if (file is not None or photo) else None
43
+
44
+
45
+ def document_message(name: str = "Movie.mkv", size: int = 5 * 1024 * 1024, *, mid: int = 10, caption: str = "",
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:
51
+ """A ``NewMessage`` event."""
52
+
53
+ def __init__(self, sender: int, text: str = "", *, message: Optional[FakeMessage] = None, reply: Any = None,
54
+ mid: int = 1, chat: Optional[int] = None, private: bool = True, photo_bytes: Optional[bytes] = None):
55
+ self.message = message if message is not None else FakeMessage(text, mid=mid, sender=sender)
56
+ self.message.sender_id = sender
57
+ self.sender_id = sender
58
+ self.chat_id = sender if chat is None else chat
59
+ self.is_private = private
60
+ self.id = self.message.id
61
+ self.raw_text = self.message.raw_text
62
+ self.pattern_match = None
63
+ self._reply = reply
64
+ self.out: List[tuple] = [] # every respond()/reply(): (text, buttons, kwargs)
65
+ self.deleted = False
66
+ self.photo_bytes = photo_bytes
67
+
68
+ async def respond(self, text: Optional[str] = None, **kw: Any) -> SimpleNamespace:
69
+ self.out.append((text, kw.get("buttons"), kw))
70
+ return SimpleNamespace(id=1000 + len(self.out), text=text)
71
+
72
+ reply = respond
73
+
74
+ async def get_reply_message(self) -> Any:
75
+ return self._reply
76
+
77
+ async def delete(self) -> None:
78
+ self.deleted = True
79
+
80
+ async def download_media(self, file: Any = None) -> Optional[bytes]:
81
+ return self.photo_bytes
82
+
83
+ @property
84
+ def texts(self) -> List[str]:
85
+ return [t or "" for t, _b, _k in self.out]
86
+
87
+
88
+ class FakeCallback:
89
+ """A ``CallbackQuery`` event."""
90
+
91
+ def __init__(self, sender: int, data: str, *, msg_id: int = 500, chat: Optional[int] = None, photo: bool = False):
92
+ self.sender_id = sender
93
+ self.chat_id = sender if chat is None else chat
94
+ self.data = data.encode()
95
+ self.message_id = msg_id
96
+ self.id = 9
97
+ self.query = SimpleNamespace(data=self.data, chat_instance=1, msg_id=msg_id)
98
+ self.edits: List[tuple] = []
99
+ self.answers: List[tuple] = []
100
+ self.deleted = False
101
+ self._photo = photo
102
+ self.delete_error: Optional[Exception] = None
103
+
104
+ async def edit(self, text: Optional[str] = None, **kw: Any) -> None:
105
+ self.edits.append((text, kw.get("buttons")))
106
+
107
+ async def answer(self, message: Optional[str] = None, *, alert: bool = False, **kw: Any) -> None:
108
+ self.answers.append((message, alert))
109
+
110
+ async def delete(self) -> None:
111
+ if self.delete_error is not None:
112
+ raise self.delete_error
113
+ self.deleted = True
114
+
115
+ async def get_message(self) -> SimpleNamespace:
116
+ return SimpleNamespace(photo=object() if self._photo else None)
117
+
118
+ @property
119
+ def alerts(self) -> List[str]:
120
+ return [m or "" for m, a in self.answers if a]
121
+
122
+ @property
123
+ def toasts(self) -> List[str]:
124
+ return [m or "" for m, a in self.answers if m and not a]
125
+
126
+
127
+ class FakeClient:
128
+ def __init__(self) -> None:
129
+ self.handlers: List[tuple] = []
130
+ self.parse_mode = None
131
+ self.session = SimpleNamespace(dc_id=1)
132
+ self.sent: List[tuple] = [] # send_message(entity, text, **kw)
133
+ self.files: List[tuple] = [] # send_file(entity, file, **kw)
134
+ self.edited: List[tuple] = [] # edit_message(entity, text, **kw)
135
+ self.requests: List[Any] = [] # raw MTProto requests
136
+ self.bad_entities: set = set()
137
+ self.fail_send_file = False
138
+
139
+ def add_event_handler(self, callback: Any, event: Any = None) -> None:
140
+ self.handlers.append((callback, event))
141
+
142
+ async def __call__(self, request: Any) -> Any:
143
+ self.requests.append(request)
144
+ return True
145
+
146
+ async def get_input_entity(self, entity: Any) -> Any:
147
+ if entity in self.bad_entities:
148
+ raise ValueError(f"Could not find the input entity for {entity!r}")
149
+ return SimpleNamespace(id=entity)
150
+
151
+ async def send_message(self, entity: Any, text: str = "", **kw: Any) -> SimpleNamespace:
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
+
161
+ async def edit_message(self, entity: Any, message: Any = None, text: Optional[str] = None, **kw: Any) -> None:
162
+ if text is None and isinstance(message, str):
163
+ text, message = message, None
164
+ self.edited.append((entity, text, {**kw, "message": message}))
165
+
166
+
167
+ async def dispatch(client: FakeClient, event: Any) -> None:
168
+ """Run ``event`` through the registered handlers like Telethon's dispatcher."""
169
+ for callback, builder in list(client.handlers):
170
+ if isinstance(builder, events.CallbackQuery) != isinstance(event, FakeCallback):
171
+ continue # Telethon builds one event per builder type
172
+ builder.resolved = True
173
+ passed = builder.filter(event)
174
+ if inspect.isawaitable(passed):
175
+ passed = await passed
176
+ if not passed:
177
+ continue
178
+ try:
179
+ await callback(event)
180
+ except events.StopPropagation:
181
+ break
182
+
183
+
184
+ def button_rows(buttons: Any) -> List[List[tuple]]:
185
+ """Telethon buttons -> ``[[(text, data_str), ...], ...]``."""
186
+ out = []
187
+ for row in buttons or []:
188
+ out.append([(b.text, b.type.data.decode()) for b in row])
189
+ return out
190
+
191
+
192
+ def flat_buttons(buttons: Any) -> List[tuple]:
193
+ return [b for row in button_rows(buttons) for b in row]