Spaces:
Running
Running
File size: 21,219 Bytes
58f00c9 84e63a1 58f00c9 b737d7a 58f00c9 84e63a1 58f00c9 84e63a1 58f00c9 f4c871b 58f00c9 c3c6394 58f00c9 84e63a1 58f00c9 6c3a31d b737d7a 6c3a31d 58f00c9 6c3a31d 58f00c9 84e63a1 58f00c9 84e63a1 58f00c9 84e63a1 58f00c9 84e63a1 58f00c9 84e63a1 58f00c9 84e63a1 58f00c9 5f400f8 6c3a31d 5f400f8 6c3a31d 5f400f8 6c3a31d 5f400f8 6c3a31d 5f400f8 6c3a31d 5f400f8 6c3a31d 5f400f8 529b973 6c3a31d 529b973 6c3a31d 529b973 6c3a31d 529b973 6c3a31d 529b973 6c3a31d 529b973 5f400f8 6c3a31d 5f400f8 6c3a31d 5f400f8 6c3a31d 5f400f8 a00abaf | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 | import shutil
from pathlib import Path
from types import SimpleNamespace
import pytest
from telethon import errors, types
from bot import errorlog
from bot.config import load_config
from bot.db import Database
from bot.media import probe
from bot.parser import DEFAULT_TEMPLATE, compute_new_name
from bot.pipeline import Renamer, decide_send_type, guess_mime, original_name_of, source_ext
from bot.queue import Job
from .appkit import ensure_auto
from .conftest import needs_ffmpeg
class FakeMessage:
def __init__(self, name, size, *, video=True, audio=False, mime="video/x-matroska", msg_id=7, ext=None):
self.id = msg_id
self.file = SimpleNamespace(name=name, size=size, ext=ext if ext is not None else Path(name or "").suffix, mime_type=mime)
self.video = object() if video else None
self.audio = object() if audio else None
self.document = object()
class FakeClient:
def __init__(self, source: Path, message: FakeMessage):
self.source = source
self.message = message
self.sent = []
self.edits = []
self.uploads = {}
self.download_error = None
self.send_errors = []
async def get_messages(self, chat_id, ids):
return self.message
async def download_media(self, message, file, progress_callback):
progress_callback(1, 2)
if self.download_error:
raise self.download_error
shutil.copy(self.source, file)
progress_callback(2, 2)
async def upload_file(self, path, file_name, progress_callback):
progress_callback(1, 1)
keep = Path(path).with_name("uploaded_copy_" + Path(path).name)
keep = Path(self.source).parent / ("upload-" + str(len(self.uploads)) + Path(path).suffix)
shutil.copy(path, keep)
self.uploads[len(self.uploads)] = keep
return types.InputFileBig(id=len(self.uploads), parts=1, name=file_name)
async def send_file(self, target, input_file, **kwargs):
if self.send_errors:
raise self.send_errors.pop(0)
self.sent.append((target, input_file, kwargs))
async def edit_message(self, message, text, buttons=None, parse_mode=None):
self.edits.append((text, buttons))
@pytest.fixture
async def env(tmp_path, media_dir):
cfg = load_config({
"API_ID": "1", "API_HASH": "h", "BOT_TOKEN": "1:t", "OWNER_ID": "42",
"TEMP_DIR": str(tmp_path / "work"), "FAST_TRANSFER": "0", "PROGRESS_INTERVAL": "2",
})
db = Database(f"sqlite+aiosqlite:///{tmp_path}/p.db")
await db.init()
ns = SimpleNamespace(cfg=cfg, db=db, media_dir=media_dir, tmp=tmp_path)
yield ns
await db.close()
def make(env, name="[SubsPlease] Tokyo Revengers - 05 (1080p) [ABCD1234].mkv", src="sample.mkv", **msg_kwargs):
source = env.media_dir / src
message = FakeMessage(name, source.stat().st_size, **msg_kwargs)
client = FakeClient(source, message)
renamer = Renamer(client, env.cfg, env.db)
new_name, _ = compute_new_name(template=DEFAULT_TEMPLATE, mode="filename", original_name=name, original_ext=Path(name).suffix)
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,
status_msg=SimpleNamespace(id=99))
return client, renamer, job
@needs_ffmpeg
async def test_plain_rename_sends_video_with_thumbnail(env):
client, renamer, job = make(env)
await renamer.process(job)
assert job.new_name == "[S01E05] Tokyo Revengers [1080p].mkv"
(target, input_file, kw), = client.sent
assert target == 42 and kw["reply_to"] == 7
by_type = {type(a): a for a in kw["attributes"]}
assert by_type[types.DocumentAttributeFilename].file_name == job.new_name
video = by_type[types.DocumentAttributeVideo]
assert (video.w, video.h) == (320, 240) and 1 < video.duration < 3 and video.supports_streaming
assert kw["force_document"] is False and kw["mime_type"] == "video/x-matroska"
assert kw["thumb"][:2] == b"\xff\xd8" # generated from a video frame
assert kw["caption"] == "<b>[S01E05] Tokyo Revengers [1080p]</b>" and kw["parse_mode"] == "html" # bold, by default
assert input_file.name == job.new_name
assert client.uploads[0].read_bytes() == (env.media_dir / "sample.mkv").read_bytes() # untouched without metadata
assert "Done" in client.edits[-1][0] and client.edits[-1][1] is None
assert job.state == "done"
assert (await env.db.totals())[0] == 1
assert not (Path(env.cfg.temp_dir) / "job1").exists() # temp files cleaned
@needs_ffmpeg
async def test_notes_from_queueing_appear_in_the_done_message(env):
client, renamer, job = make(env)
job.notes = ["Couldn't find {episode} in the file name, so it is left empty."]
await renamer.process(job)
assert job.state == "done"
assert "⚠️ Couldn't find {episode} in the file name" in client.edits[-1][0]
@needs_ffmpeg
async def test_metadata_and_track_selection_are_applied_before_upload(env):
await env.db.update_settings(
42, metadata_on=True, keep_audio=["english", "japanese"], keep_subtitle=["2"],
meta={"title": "Tokyo Revengers", "audio": "{lang}", "subtitle": "English"},
)
client, renamer, job = make(env)
await renamer.process(job)
out = await probe(client.uploads[0])
assert [s.lang for s in out.audio] == ["eng", "jpn"]
assert [s.lang for s in out.subtitles] == ["eng"]
assert out.tags.get("title") == "Tokyo Revengers"
assert [s.title for s in out.audio] == ["English", "Japanese"]
assert client.sent and job.state == "done"
@needs_ffmpeg
async def test_document_file_type_is_forced(env):
await env.db.update_settings(42, file_type="document")
client, renamer, job = make(env)
await renamer.process(job)
(_t, _f, kw), = client.sent
assert kw["force_document"] is True
assert types.DocumentAttributeVideo not in {type(a) for a in kw["attributes"]}
@needs_ffmpeg
async def test_same_type_keeps_document_as_document(env):
client, renamer, job = make(env, video=False) # original was sent as a file
await renamer.process(job)
(_t, _f, kw), = client.sent
assert kw["force_document"] is True
assert types.DocumentAttributeVideo not in {type(a) for a in kw["attributes"]}
@needs_ffmpeg
async def test_forced_video_for_non_video_falls_back_to_document(env, tmp_path):
blob = tmp_path / "data.zip"
blob.write_bytes(b"PK" + b"0" * 5000)
message = FakeMessage("stuff.zip", 5002, video=False, mime="application/zip")
client = FakeClient(blob, message)
renamer = Renamer(client, env.cfg, env.db)
await env.db.update_settings(42, file_type="video")
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())
await renamer.process(job)
(_t, _f, kw), = client.sent
assert kw["force_document"] is True and kw["mime_type"] == "application/zip"
assert kw["thumb"] is None
async def make_auto(env, thumb=None, **changes):
"""The user's auto format (made on demand), with these changes and this thumbnail."""
fmt = await ensure_auto(env.db, 42)
if changes:
await env.db.update_format(42, fmt["id"], **changes)
if thumb:
await env.db.set_format_thumb(42, fmt["id"], thumb)
return await env.db.get_format(42, fmt["id"])
@needs_ffmpeg
async def test_custom_thumbnail_wins_over_generated(env):
client, renamer, job = make(env)
job.fmt = await make_auto(env, thumb=b"\xff\xd8custom")
await renamer.process(job)
assert client.sent[0][2]["thumb"] == b"\xff\xd8custom"
@needs_ffmpeg
async def test_target_channel(env):
await env.db.update_settings(42, target_channel=-100123)
client, renamer, job = make(env)
await renamer.process(job)
(target, _f, kw), = client.sent
assert target == -100123 and "reply_to" not in kw
assert "channel" in client.edits[-1][0]
@needs_ffmpeg
async def test_audio_file_sent_as_audio_with_tags(env):
await env.db.update_settings(42, metadata_on=True, meta={"title": "Song", "artist": "Me"})
client, renamer, job = make(env, name="old name.mp3", src="sample.mp3", video=False, audio=True, mime="audio/mpeg")
job.new_name = "New Song.mp3"
await renamer.process(job)
(_t, _f, kw), = client.sent
audio = next(a for a in kw["attributes"] if isinstance(a, types.DocumentAttributeAudio))
assert audio.title == "Song" and audio.performer == "Me" and audio.duration >= 1
assert kw["force_document"] is False and kw["mime_type"] == "audio/mpeg"
assert (await probe(client.uploads[0])).tags["title"] == "Song"
@needs_ffmpeg
async def test_unsupported_container_with_metadata_warns_but_still_renames(env, tmp_path):
# an .avi has video streams but is outside the supported remux containers
avi = tmp_path / "x.avi"
import subprocess
subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc=duration=1:size=64x64:rate=5",
"-c:v", "mpeg4", str(avi)], check=True)
message = FakeMessage("x.avi", avi.stat().st_size, video=False, mime="video/x-msvideo")
client = FakeClient(avi, message)
await env.db.update_settings(42, metadata_on=True, meta={"title": "T"})
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())
await Renamer(client, env.cfg, env.db).process(job)
assert client.sent and "not supported" in client.edits[-1][0]
@needs_ffmpeg
async def test_caption_rejected_is_retried_without_caption(env):
client, renamer, job = make(env)
client.send_errors = [errors.MediaCaptionTooLongError(request=None)]
await renamer.process(job)
(_t, _f, kw), = client.sent
assert kw["caption"] is None
assert "caption was rejected" in client.edits[-1][0] and job.state == "done"
@needs_ffmpeg
async def test_cancel_before_start(env):
client, renamer, job = make(env)
job.cancel.set()
await renamer.process(job)
assert job.state == "cancelled" and client.sent == []
assert "Cancelled" in client.edits[-1][0]
assert not (Path(env.cfg.temp_dir) / "job1").exists()
@pytest.fixture
async def log_channel():
"""What reaches the log channel while a test runs (the error reporter is attached to the root logger, as in main.py)."""
reports = []
async def send(text):
reports.append(text)
handler = errorlog.install(send)
yield SimpleNamespace(reports=reports, flush=handler.aclose)
handler.detach()
@needs_ffmpeg
async def test_failure_is_reported_and_logged(env, log_channel):
client, renamer, job = make(env)
client.download_error = RuntimeError("network down")
await renamer.process(job)
await log_channel.flush()
assert job.state == "failed" and client.sent == []
assert "Failed" in client.edits[-1][0] and "network down" in client.edits[-1][0]
(report,) = log_channel.reports # the log channel gets the whole traceback, as a Python code block
assert report.startswith(f'<pre><code class="language-python">Error processing file:\nuser 42 · job #1\n{job.file_name} → {job.new_name} (')
assert "Traceback (most recent call last):" in report and report.endswith("RuntimeError: network down</code></pre>")
assert not (Path(env.cfg.temp_dir) / "job1").exists()
@needs_ffmpeg
async def test_a_finished_job_leaves_nothing_in_the_log_channel(env, log_channel):
client, renamer, job = make(env)
await renamer.process(job)
await log_channel.flush()
assert job.state == "done" and log_channel.reports == []
@needs_ffmpeg
async def test_not_enough_disk_space(env, monkeypatch):
client, renamer, job = make(env)
monkeypatch.setattr(shutil, "disk_usage", lambda p: SimpleNamespace(total=100, used=100, free=10))
await renamer.process(job)
assert job.state == "failed" and "disk space" in client.edits[-1][0]
@needs_ffmpeg
async def test_deleted_original_message_fails_cleanly(env):
client, renamer, job = make(env)
async def gone(chat_id, ids):
return None
client.get_messages = gone
await renamer.process(job)
assert job.state == "failed" and "deleted" in client.edits[-1][0]
def test_helpers():
video = FakeMessage("a.mkv", 1)
doc = FakeMessage("a.mkv", 1, video=False)
audio = FakeMessage("a.mp3", 1, video=False, audio=True)
assert decide_send_type("same", video) == "video"
assert decide_send_type("same", doc) == "document"
assert decide_send_type("same", audio) == "audio"
assert decide_send_type("document", video) == "document"
assert decide_send_type("audio", doc) == "audio"
assert guess_mime("x.mkv", None) == "video/x-matroska"
assert guess_mime("x.unknown123", "foo/bar") == "foo/bar"
assert guess_mime("x.unknown123", None) == "application/octet-stream"
nameless = FakeMessage(None, 1, ext=".mp4", msg_id=55)
assert original_name_of(nameless) == "video_55.mp4"
assert source_ext(nameless, "video_55.mp4") == ".mp4"
assert source_ext(FakeMessage("noext", 1, ext=".mkv"), "noext") == ".mkv"
# ---------------------------------------------------------------------------
# saved /rename formats
# ---------------------------------------------------------------------------
async def make_fmt(env, name="One Piece", **changes):
fmt = await env.db.create_format(42, name, template="One Piece - E{episode}")
if changes:
await env.db.update_format(42, fmt["id"], **changes)
return await env.db.get_format(42, fmt["id"])
@needs_ffmpeg
async def test_a_format_brings_its_own_metadata_tracks_and_thumbnail(env):
# the default metadata is off and has a different title: a manual format must not depend on either
await env.db.update_settings(42, metadata_on=False, meta={"title": "DEFAULT"}, keep_audio=["hindi"])
await make_auto(env, thumb=b"\xff\xd8auto") # nor on the auto format
fmt = await make_fmt(env, meta={"title": "One Piece", "audio": "{lang}"}, keep_audio=["english", "japanese"], keep_subtitle=["2"])
await env.db.set_format_thumb(42, fmt["id"], b"\xff\xd8format")
fmt = await env.db.get_format(42, fmt["id"])
client, renamer, job = make(env)
job.fmt = fmt
await renamer.process(job)
out = await probe(client.uploads[0])
assert [s.lang for s in out.audio] == ["eng", "jpn"] and [s.lang for s in out.subtitles] == ["eng"]
assert out.tags.get("title") == "One Piece" and [s.title for s in out.audio] == ["English", "Japanese"]
(_t, _f, kw), = client.sent
assert kw["thumb"] == b"\xff\xd8format"
assert job.state == "done"
@needs_ffmpeg
async def test_a_format_without_a_thumbnail_gets_a_frame_of_the_video(env):
await make_auto(env, thumb=b"\xff\xd8auto") # another format's thumbnail is not borrowed
fmt = await make_fmt(env)
client, renamer, job = make(env)
job.fmt = fmt
await renamer.process(job)
(_t, _f, kw), = client.sent
assert kw["thumb"][:2] == b"\xff\xd8" and kw["thumb"] != b"\xff\xd8auto" # cut out of the video
@needs_ffmpeg
async def test_a_manual_format_that_sets_no_metadata_leaves_the_file_untouched_even_if_default_metadata_is_on(env):
await env.db.update_settings(42, metadata_on=True, meta={"title": "DEFAULT"}, keep_audio=["english"])
fmt = await make_fmt(env)
client, renamer, job = make(env)
job.fmt = fmt
await renamer.process(job)
assert client.uploads[0].read_bytes() == (env.media_dir / "sample.mkv").read_bytes() # no ffmpeg pass
assert job.state == "done"
@needs_ffmpeg
async def test_an_auto_format_without_metadata_uses_the_default_metadata_while_it_is_on(env):
await env.db.update_settings(42, metadata_on=True, meta={"title": "DEFAULT TITLE"})
client, renamer, job = make(env)
job.fmt = await make_auto(env) # sets no metadata of its own
await renamer.process(job)
assert (await probe(client.uploads[0])).tags.get("title") == "DEFAULT TITLE"
await env.db.update_settings(42, metadata_on=False) # switched off: the file is left alone
client, renamer, job = make(env)
job.fmt = await make_auto(env)
await renamer.process(job)
assert client.uploads[0].read_bytes() == (env.media_dir / "sample.mkv").read_bytes()
@needs_ffmpeg
async def test_an_auto_format_with_metadata_of_its_own_ignores_the_default_metadata(env):
await env.db.update_settings(42, metadata_on=True, meta={"title": "DEFAULT TITLE"})
client, renamer, job = make(env)
job.fmt = await make_auto(env, meta={"title": "OWN TITLE"})
await renamer.process(job)
assert (await probe(client.uploads[0])).tags.get("title") == "OWN TITLE"
await env.db.update_settings(42, metadata_on=False) # its own metadata applies whatever the switch says
client, renamer, job = make(env)
job.fmt = await make_auto(env)
await renamer.process(job)
assert (await probe(client.uploads[0])).tags.get("title") == "OWN TITLE"
@needs_ffmpeg
async def test_everything_else_still_comes_from_the_users_settings(env):
await env.db.update_settings(42, file_type="document", target_channel=-100777)
fmt = await make_fmt(env, meta={"title": "T"})
client, renamer, job = make(env)
job.fmt = fmt
job.new_name = "One Piece - E5.mkv"
await renamer.process(job)
(target, _f, kw), = client.sent
assert target == -100777 and kw["force_document"] is True # the file type and the channel are still the user's
assert kw["caption"] == "<b>One Piece - E5</b>" # the caption is the format's: it has none, so it is the new name in bold
@needs_ffmpeg
async def test_a_format_sends_its_own_caption(env):
from bot.utils import human_size
await make_auto(env, caption="<i>THE AUTO FORMAT'S CAPTION {filename}</i>") # another format's caption is not borrowed
fmt = await make_fmt(env, caption="<b>{filename}</b> · {original} · {size}")
client, renamer, job = make(env)
job.fmt = fmt
job.new_name = "One Piece - E5.mkv"
await renamer.process(job)
(_t, _f, kw), = client.sent
size = (env.media_dir / "sample.mkv").stat().st_size
assert kw["caption"] == f"<b>One Piece - E5</b> · {job.file_name} · {human_size(size)}" and kw["parse_mode"] == "html"
@needs_ffmpeg
async def test_a_format_without_a_caption_sends_the_new_name_in_bold(env):
await make_auto(env, caption="<i>THE AUTO FORMAT'S CAPTION {filename}</i>")
fmt = await make_fmt(env)
client, renamer, job = make(env)
job.fmt = fmt
job.new_name = "One Piece - E5.mkv"
await renamer.process(job)
assert client.sent[0][2]["caption"] == "<b>One Piece - E5</b>"
@needs_ffmpeg
async def test_an_auto_rename_uses_the_caption_of_its_auto_format(env):
await env.db.create_format(42, "Unused", template="X") # a manual format changes nothing for the auto renames
client, renamer, job = make(env)
job.fmt = await make_auto(env, caption="<i>{filename}</i> ({quality})")
await renamer.process(job)
assert client.sent[0][2]["caption"] == "<i>[S01E05] Tokyo Revengers [1080p]</i> (1080p)"
@needs_ffmpeg
async def test_an_auto_rename_without_any_format_uses_the_built_in_defaults(env):
client, renamer, job = make(env)
assert job.fmt is None
await renamer.process(job)
(_t, _f, kw), = client.sent
assert kw["caption"] == "<b>[S01E05] Tokyo Revengers [1080p]</b>" # the new name in bold
assert kw["thumb"][:2] == b"\xff\xd8" # a frame of the video
assert client.uploads[0].read_bytes() == (env.media_dir / "sample.mkv").read_bytes()
assert await env.db.list_formats(42) == [] # nothing was made for it
@needs_ffmpeg
async def test_a_format_deleted_while_queued_still_finishes_with_its_snapshot(env):
fmt = await make_fmt(env, meta={"title": "One Piece"})
await env.db.set_format_thumb(42, fmt["id"], b"\xff\xd8gone")
await env.db.delete_format(42, fmt["id"])
client, renamer, job = make(env)
job.fmt = fmt # the snapshot taken when the file was queued
await renamer.process(job)
(_t, _f, kw), = client.sent
assert kw["thumb"][:2] == b"\xff\xd8" and kw["thumb"] != b"\xff\xd8gone" and job.state == "done" # its thumbnail went with it
out = await probe(client.uploads[0])
assert out.tags.get("title") == "One Piece" # the snapshot's metadata is still applied
@needs_ffmpeg
async def test_the_done_message_says_how_fast_each_way_went(env, monkeypatch):
from bot import pipeline
from bot.utils import human_size
ticks = iter(range(0, 1000, 2)) # every look at the clock is two seconds later than the one before
monkeypatch.setattr(pipeline, "time", SimpleNamespace(monotonic=lambda: next(ticks)))
client, renamer, job = make(env)
await renamer.process(job)
size = (env.media_dir / "sample.mkv").stat().st_size
done = client.edits[-1][0]
assert f"{human_size(size)} · ⬇ {human_size(size / 2)}/s · ⬆ {human_size(size / 2)}/s" in done
@needs_ffmpeg
async def test_a_transfer_that_took_under_a_second_shows_no_speed(env):
client, renamer, job = make(env)
await renamer.process(job)
assert "Done" in client.edits[-1][0] and "/s" not in client.edits[-1][0]
|