Spaces:
Running
Running
github-actions[bot] commited on
Commit ·
58f00c9
1
Parent(s): 9399f83
Automated sync from GitHub
Browse files- .dockerignore +9 -0
- .env.example +36 -0
- .gitattributes +8 -35
- .gitignore +7 -0
- Dockerfile +27 -0
- bot/__init__.py +1 -0
- bot/__main__.py +3 -0
- bot/app.py +774 -0
- bot/config.py +102 -0
- bot/db.py +232 -0
- bot/errors.py +6 -0
- bot/fasttransfer.py +430 -0
- bot/main.py +121 -0
- bot/media.py +442 -0
- bot/panels.py +265 -0
- bot/parser.py +396 -0
- bot/pipeline.py +230 -0
- bot/progress.py +104 -0
- bot/queue.py +136 -0
- bot/settings_ops.py +134 -0
- bot/sysinfo.py +153 -0
- bot/utils.py +49 -0
- bot/web.py +36 -0
- pytest.ini +3 -0
- requirements-dev.txt +4 -0
- requirements.txt +9 -0
- tests/__init__.py +0 -0
- tests/conftest.py +82 -0
- tests/test_app.py +822 -0
- tests/test_db.py +76 -0
- tests/test_fasttransfer.py +335 -0
- tests/test_media.py +321 -0
- tests/test_panels.py +160 -0
- tests/test_parser.py +202 -0
- tests/test_pipeline.py +279 -0
- tests/test_queue_progress.py +226 -0
- tests/test_settings_ops.py +138 -0
- tests/test_sysinfo.py +72 -0
- tests/test_telethon_glue.py +106 -0
- tests/test_web_main.py +200 -0
- tests/tgfakes.py +193 -0
.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 |
-
*.
|
| 2 |
-
*.
|
| 3 |
-
*.
|
| 4 |
-
*.
|
| 5 |
-
*.
|
| 6 |
-
*.
|
| 7 |
-
*.
|
| 8 |
-
*.
|
| 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 <b>{filename}</b></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 & 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><b>{filename}</b></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 <id> · /deladmin <id> · /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><b></code>, <code><i></code>, <code><code></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 "<b>" 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 <File>.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 "<script>" 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]
|