Spaces:
Running
Running
github-actions[bot] commited on
Commit ·
c3c6394
1
Parent(s): 58f00c9
Automated sync from GitHub
Browse files- .gitattributes +3 -3
- bot/app.py +14 -5
- bot/db.py +7 -3
- bot/parser.py +83 -6
- bot/pipeline.py +1 -1
- bot/queue.py +63 -22
- tests/test_app.py +74 -0
- tests/test_db.py +11 -0
- tests/test_parser.py +92 -0
- tests/test_pipeline.py +9 -0
- tests/test_queue_progress.py +137 -0
.gitattributes
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 1 |
+
*.png filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.ttf filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.otf filter=lfs diff=lfs merge=lfs -text
|
| 4 |
*.webp filter=lfs diff=lfs merge=lfs -text
|
| 5 |
*.mp4 filter=lfs diff=lfs merge=lfs -text
|
| 6 |
*.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 7 |
*.jpg filter=lfs diff=lfs merge=lfs -text
|
| 8 |
*.jpeg filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
bot/app.py
CHANGED
|
@@ -20,7 +20,7 @@ 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
|
|
@@ -55,6 +55,7 @@ BACK = {
|
|
| 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 |
|
|
@@ -98,7 +99,7 @@ class App:
|
|
| 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
|
|
@@ -471,21 +472,29 @@ class App:
|
|
| 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,
|
| 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 |
)
|
|
|
|
| 20 |
from . import media, panels, sysinfo
|
| 21 |
from .config import Config
|
| 22 |
from .db import Database
|
| 23 |
+
from .parser import build_manual_name, compute_new_name, missing_variables
|
| 24 |
from .pipeline import Renamer, original_name_of, source_ext
|
| 25 |
from .queue import Job, JobQueue
|
| 26 |
from .settings_ops import META_COMMANDS, apply_setting, parse_channel, preview_template
|
|
|
|
| 55 |
"keep_audio": "tool", "keep_subtitle": "tool",
|
| 56 |
}
|
| 57 |
PANEL_NAMES = {"home", "rename", "cap", "meta", "tool", "ftype", "help", "admin"}
|
| 58 |
+
QUEUE_SETTLE = 1.5 # seconds an idle worker waits so files sent together are queued in message order
|
| 59 |
TRACKS_PROBE_BYTES = 12 * 1024 * 1024
|
| 60 |
TRACKS_FULL_LIMIT = 700 * 1024 * 1024
|
| 61 |
|
|
|
|
| 99 |
self.admins: Set[int] = set(cfg.admin_ids)
|
| 100 |
self.pending: Dict[int, Pending] = {}
|
| 101 |
self.renamer = Renamer(client, cfg, db, self._log_send)
|
| 102 |
+
self.queue = JobQueue(self.renamer.process, workers=cfg.max_jobs, on_cancelled=self._on_job_cancelled, settle=QUEUE_SETTLE)
|
| 103 |
self._warned: Set[int] = set()
|
| 104 |
|
| 105 |
# ------------------------------------------------------------------ auth
|
|
|
|
| 472 |
s = await self.db.get_settings(uid)
|
| 473 |
orig = original_name_of(msg)
|
| 474 |
ext = source_ext(msg, orig)
|
| 475 |
+
notes: List[str] = []
|
| 476 |
if manual:
|
| 477 |
new_name = build_manual_name(manual, ext)
|
| 478 |
else:
|
| 479 |
+
new_name, info = compute_new_name(
|
| 480 |
template=s["template"], mode=s["mode"], original_name=orig, original_ext=ext,
|
| 481 |
caption=msg.raw_text or "", remove_words=s["remove_words"],
|
| 482 |
)
|
| 483 |
+
gaps = missing_variables(s["template"], info)
|
| 484 |
+
if gaps:
|
| 485 |
+
where_from = "caption" if s["mode"] == "caption" else "file name"
|
| 486 |
+
notes.append(
|
| 487 |
+
"Couldn't find " + ", ".join("{%s}" % g for g in gaps) + f" in the {where_from}, so it is left empty. "
|
| 488 |
+
"Cancel and use /rename, or change the template."
|
| 489 |
+
)
|
| 490 |
job = Job(
|
| 491 |
id=self.queue.new_id(), user_id=uid, chat_id=event.chat_id, msg_id=msg.id,
|
| 492 |
+
file_name=orig, size=size, new_name=new_name, manual=bool(manual), notes=notes, order=event.id,
|
| 493 |
)
|
| 494 |
ahead = self.queue.running + self.queue.waiting
|
| 495 |
where = "starting soon" if ahead == 0 else f"{ahead} ahead of it"
|
| 496 |
job.status_msg = await event.reply(
|
| 497 |
+
f"📥 <b>Queued</b> ({where})\n<code>{esc(clip(new_name, 120))}</code>" + "".join(f"\n⚠️ {esc(n)}" for n in notes),
|
| 498 |
buttons=panels.to_buttons([[("✖ Cancel", f"job:x:{job.id}")]]),
|
| 499 |
parse_mode="html",
|
| 500 |
)
|
bot/db.py
CHANGED
|
@@ -13,6 +13,8 @@ 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 |
|
|
@@ -117,9 +119,11 @@ class Database:
|
|
| 117 |
async def _get_or_create(self, session, uid: int) -> User:
|
| 118 |
row = await session.get(User, uid)
|
| 119 |
if row is None:
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
|
|
|
|
|
|
| 123 |
return row
|
| 124 |
|
| 125 |
async def get_settings(self, uid: int) -> Dict[str, Any]:
|
|
|
|
| 13 |
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
| 14 |
|
| 15 |
from sqlalchemy import BigInteger, DateTime, Integer, LargeBinary, String, Text, func, select
|
| 16 |
+
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
| 17 |
+
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
| 18 |
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine
|
| 19 |
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
| 20 |
|
|
|
|
| 119 |
async def _get_or_create(self, session, uid: int) -> User:
|
| 120 |
row = await session.get(User, uid)
|
| 121 |
if row is None:
|
| 122 |
+
# A forwarded batch is handled by several tasks at once (and two instances can overlap briefly while the
|
| 123 |
+
# Space restarts), so let the database settle who creates the row instead of failing on the unique key.
|
| 124 |
+
insert = pg_insert if self.engine.dialect.name == "postgresql" else sqlite_insert
|
| 125 |
+
await session.execute(insert(User).values(id=uid, settings="{}").on_conflict_do_nothing(index_elements=["id"]))
|
| 126 |
+
row = await session.get(User, uid)
|
| 127 |
return row
|
| 128 |
|
| 129 |
async def get_settings(self, uid: int) -> Dict[str, Any]:
|
bot/parser.py
CHANGED
|
@@ -102,6 +102,13 @@ _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)
|
|
@@ -136,6 +143,44 @@ 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 "")
|
|
@@ -168,12 +213,11 @@ def parse_info(text: str) -> Info:
|
|
| 168 |
episode = me.group(1)
|
| 169 |
spans.append(me.span())
|
| 170 |
break
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
info.season = _fmt_num(season)
|
| 177 |
|
| 178 |
# --- quality --------------------------------------------------------------
|
| 179 |
mq = _QUALITY_RE.search(norm)
|
|
@@ -222,6 +266,19 @@ def parse_info(text: str) -> Info:
|
|
| 222 |
if ms2:
|
| 223 |
spans.append(ms2.span())
|
| 224 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
# --- title ------------------------------------------------------------------------
|
| 226 |
body = _LEADING_GROUPS_RE.sub("", norm)
|
| 227 |
shift = len(norm) - len(body)
|
|
@@ -247,6 +304,10 @@ def parse_info(text: str) -> Info:
|
|
| 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 |
|
|
@@ -310,6 +371,22 @@ def render_template(template: str, values: Dict[str, str]) -> str:
|
|
| 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()
|
|
|
|
| 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 |
+
# episode written first: "1147 - One Piece" / "1147-One Piece". A 1-2 digit number needs a space beside
|
| 106 |
+
# the dash, so titles such as "3-gatsu no Lion" keep their number.
|
| 107 |
+
_LEAD_EP_RE = re.compile(
|
| 108 |
+
r"(?:(?P<long>\d{3,4})(?:v\d+)?\s*[-–—]\s*|(?P<short>\d{1,2})(?:v\d+)?(?:\s+[-–—]\s*|\s*[-–—]\s+))(?=\S)"
|
| 109 |
+
)
|
| 110 |
+
_BRACKET_EP_RE = re.compile(r"\[(\d{1,4})(?:v\d+)?\]") # "[1147]"
|
| 111 |
+
_NUM_TOKEN_RE = re.compile(_Q + r"(\d{3,4})(?:v\d+)?(?![A-Za-z0-9])") # "One Piece 1147 720p"
|
| 112 |
_YEAR_PAREN_RE = re.compile(r"[\(\[]((?:19|20)\d{2})[\)\]]")
|
| 113 |
_YEAR_BARE_RE = re.compile(_Q + r"((?:19|20)\d{2})(?![A-Za-z0-9])")
|
| 114 |
_CODEC_RE = re.compile(_Q + r"(HEVC|[xh]\.?26[45]|AV1|AVC|VP9)(?![A-Za-z0-9])", re.I)
|
|
|
|
| 143 |
return f"{int(value):02d}"
|
| 144 |
|
| 145 |
|
| 146 |
+
def _is_year(value: str) -> bool:
|
| 147 |
+
return 1900 <= int(value) <= 2099
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def _leading_or_bracket_episode(norm: str) -> Optional[Tuple[str, Tuple[int, int]]]:
|
| 151 |
+
"""Episode numbers written first ("1147 - One Piece") or alone in brackets ("[1147]")."""
|
| 152 |
+
start = len(norm) - len(_LEADING_GROUPS_RE.sub("", norm)) # skip "[Group] " prefixes
|
| 153 |
+
m = _LEAD_EP_RE.match(norm, start)
|
| 154 |
+
if m:
|
| 155 |
+
number = m.group("long") or m.group("short")
|
| 156 |
+
if not _is_year(number):
|
| 157 |
+
return number, (start, m.end())
|
| 158 |
+
for m in _BRACKET_EP_RE.finditer(norm):
|
| 159 |
+
if not _is_year(m.group(1)):
|
| 160 |
+
return m.group(1), m.span()
|
| 161 |
+
return None
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def _bare_episode(norm: str, spans: List[Tuple[int, int]]) -> Optional[Tuple[str, Tuple[int, int]]]:
|
| 165 |
+
"""A lone 3-4 digit number between the title and the release tags ("One Piece 1147 720p")."""
|
| 166 |
+
marks = [a for a, _b in spans if a > 0]
|
| 167 |
+
first = min(marks) if marks else len(norm)
|
| 168 |
+
found = None
|
| 169 |
+
for m in _NUM_TOKEN_RE.finditer(norm):
|
| 170 |
+
if m.start() == 0 or m.start() >= first or _is_year(m.group(1)):
|
| 171 |
+
continue
|
| 172 |
+
if any(a <= m.start() < b for a, b in spans):
|
| 173 |
+
continue
|
| 174 |
+
found = m
|
| 175 |
+
if found is None:
|
| 176 |
+
return None
|
| 177 |
+
if not marks: # no release tags to anchor on: the number must end the title
|
| 178 |
+
tail = norm[found.end():].strip()
|
| 179 |
+
if tail and tail[0] not in "[(-–—":
|
| 180 |
+
return None
|
| 181 |
+
return found.group(1), found.span()
|
| 182 |
+
|
| 183 |
+
|
| 184 |
def parse_info(text: str) -> Info:
|
| 185 |
"""Extract title / season / episode / quality / year / codec from a release name."""
|
| 186 |
raw = _strip_markup(text or "")
|
|
|
|
| 213 |
episode = me.group(1)
|
| 214 |
spans.append(me.span())
|
| 215 |
break
|
| 216 |
+
if not episode:
|
| 217 |
+
found = _leading_or_bracket_episode(norm)
|
| 218 |
+
if found:
|
| 219 |
+
episode = found[0]
|
| 220 |
+
spans.append(found[1])
|
|
|
|
| 221 |
|
| 222 |
# --- quality --------------------------------------------------------------
|
| 223 |
mq = _QUALITY_RE.search(norm)
|
|
|
|
| 266 |
if ms2:
|
| 267 |
spans.append(ms2.span())
|
| 268 |
|
| 269 |
+
# --- episode without any marker: "One Piece 1147 720p" (skipped for names with a year: movie titles) ---
|
| 270 |
+
if not episode and not info.year:
|
| 271 |
+
found = _bare_episode(norm, spans)
|
| 272 |
+
if found:
|
| 273 |
+
episode = found[0]
|
| 274 |
+
spans.append(found[1])
|
| 275 |
+
if episode and not season:
|
| 276 |
+
season = "1"
|
| 277 |
+
if episode:
|
| 278 |
+
info.episode = _fmt_num(episode)
|
| 279 |
+
if season:
|
| 280 |
+
info.season = _fmt_num(season)
|
| 281 |
+
|
| 282 |
# --- title ------------------------------------------------------------------------
|
| 283 |
body = _LEADING_GROUPS_RE.sub("", norm)
|
| 284 |
shift = len(norm) - len(body)
|
|
|
|
| 304 |
mb = re.search(r"\s[\[\(]", body)
|
| 305 |
if mb and mb.start() < cut:
|
| 306 |
cut = mb.start()
|
| 307 |
+
# so does a channel tag: "One Piece @Anime_X_Universe"
|
| 308 |
+
mh = re.search(r"\s@\w", body)
|
| 309 |
+
if mh and mh.start() < cut:
|
| 310 |
+
cut = mh.start()
|
| 311 |
info.title = _clean_title(body[:cut])
|
| 312 |
return info
|
| 313 |
|
|
|
|
| 371 |
return _MULTISPACE_RE.sub(" ", rendered).strip(" -._")
|
| 372 |
|
| 373 |
|
| 374 |
+
_GROUP_RE = re.compile(r"\[[^\[\]]*\]|\([^()]*\)")
|
| 375 |
+
|
| 376 |
+
|
| 377 |
+
def missing_variables(template: str, info: Info) -> List[str]:
|
| 378 |
+
"""Variables the template uses *outside* [..]/(..) groups that are empty for this file.
|
| 379 |
+
|
| 380 |
+
A bare ``{episode}`` that could not be found leaves a visible hole in the name ("One Piece - E.mkv"),
|
| 381 |
+
unlike a bracket group, which is dropped on purpose; the caller warns the user about these.
|
| 382 |
+
"""
|
| 383 |
+
out: List[str] = []
|
| 384 |
+
for name in _TOKEN_RE.findall(_GROUP_RE.sub("", template or "")):
|
| 385 |
+
if name in ("season", "episode", "quality", "year", "codec") and not getattr(info, name) and name not in out:
|
| 386 |
+
out.append(name)
|
| 387 |
+
return out
|
| 388 |
+
|
| 389 |
+
|
| 390 |
def apply_remove_words(text: str, words: Iterable[str]) -> str:
|
| 391 |
for word in words:
|
| 392 |
word = word.strip()
|
bot/pipeline.py
CHANGED
|
@@ -141,7 +141,7 @@ class Renamer:
|
|
| 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.")
|
|
|
|
| 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 = list(job.notes)
|
| 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.")
|
bot/queue.py
CHANGED
|
@@ -21,6 +21,8 @@ class Job:
|
|
| 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)
|
|
@@ -30,13 +32,28 @@ class Job:
|
|
| 30 |
|
| 31 |
Runner = Callable[[Job], Awaitable[None]]
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
class JobQueue:
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
self._runner = runner
|
| 37 |
self._on_cancelled = on_cancelled
|
| 38 |
self._workers = max(1, workers)
|
| 39 |
-
self.
|
|
|
|
| 40 |
self._ids = itertools.count(1)
|
| 41 |
self._tasks: List[asyncio.Task] = []
|
| 42 |
self.jobs: Dict[int, Job] = {}
|
|
@@ -60,7 +77,7 @@ class JobQueue:
|
|
| 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.
|
| 64 |
return ahead
|
| 65 |
|
| 66 |
@property
|
|
@@ -75,14 +92,14 @@ class JobQueue:
|
|
| 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=
|
| 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=
|
| 86 |
|
| 87 |
def cancel(self, job_id: int, user_id: Optional[int] = None) -> bool:
|
| 88 |
job = self.jobs.get(job_id)
|
|
@@ -113,24 +130,48 @@ class JobQueue:
|
|
| 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.
|
| 119 |
try:
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
| 21 |
size: int
|
| 22 |
new_name: str
|
| 23 |
manual: bool = False
|
| 24 |
+
notes: List[str] = field(default_factory=list) # warnings shown with the queued / finished message
|
| 25 |
+
order: int = 0 # id of the message that triggered the job: files are processed oldest message first
|
| 26 |
status_msg: Any = None
|
| 27 |
state: str = "queued" # queued | running | done | failed | cancelled
|
| 28 |
cancel: asyncio.Event = field(default_factory=asyncio.Event)
|
|
|
|
| 32 |
|
| 33 |
Runner = Callable[[Job], Awaitable[None]]
|
| 34 |
|
| 35 |
+
SETTLE_CAP = 4 # an idle worker never waits longer than this many settle periods in a row
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _order_key(job: Job) -> tuple:
|
| 39 |
+
return (job.order, job.id)
|
| 40 |
+
|
| 41 |
|
| 42 |
class JobQueue:
|
| 43 |
+
"""Jobs run oldest-message-first by a small pool of workers.
|
| 44 |
+
|
| 45 |
+
Telethon handles messages that arrive together concurrently, so jobs are not necessarily added in the
|
| 46 |
+
order the files were sent. Picking by message id (not by insertion order) and letting an idle worker wait
|
| 47 |
+
until nothing new was queued for ``settle`` seconds (at most ``SETTLE_CAP`` times that) keeps a forwarded
|
| 48 |
+
batch in its original order.
|
| 49 |
+
"""
|
| 50 |
+
|
| 51 |
+
def __init__(self, runner: Runner, workers: int = 1, on_cancelled: Optional[Runner] = None, settle: float = 0.0):
|
| 52 |
self._runner = runner
|
| 53 |
self._on_cancelled = on_cancelled
|
| 54 |
self._workers = max(1, workers)
|
| 55 |
+
self._settle = max(0.0, settle)
|
| 56 |
+
self._wake = asyncio.Event()
|
| 57 |
self._ids = itertools.count(1)
|
| 58 |
self._tasks: List[asyncio.Task] = []
|
| 59 |
self.jobs: Dict[int, Job] = {}
|
|
|
|
| 77 |
"""Queue a job; returns how many jobs are ahead of it (running + waiting)."""
|
| 78 |
ahead = self.running + self.waiting
|
| 79 |
self.jobs[job.id] = job
|
| 80 |
+
self._wake.set()
|
| 81 |
return ahead
|
| 82 |
|
| 83 |
@property
|
|
|
|
| 92 |
"""0 = running now, 1 = next up, ..."""
|
| 93 |
if job.state == "running":
|
| 94 |
return 0
|
| 95 |
+
waiting = sorted((j for j in self.jobs.values() if j.state == "queued"), key=_order_key)
|
| 96 |
try:
|
| 97 |
return waiting.index(job) + 1
|
| 98 |
except ValueError:
|
| 99 |
return 0
|
| 100 |
|
| 101 |
def for_user(self, user_id: int) -> List[Job]:
|
| 102 |
+
return sorted((j for j in self.jobs.values() if j.user_id == user_id), key=_order_key)
|
| 103 |
|
| 104 |
def cancel(self, job_id: int, user_id: Optional[int] = None) -> bool:
|
| 105 |
job = self.jobs.get(job_id)
|
|
|
|
| 130 |
except Exception: # noqa: BLE001
|
| 131 |
log.exception("job callback failed")
|
| 132 |
|
| 133 |
+
def _claim(self) -> Optional[Job]:
|
| 134 |
+
"""Take the oldest queued job (no await in between, so two workers never take the same one)."""
|
| 135 |
+
queued = [j for j in self.jobs.values() if j.state == "queued"]
|
| 136 |
+
if not queued:
|
| 137 |
+
return None
|
| 138 |
+
job = min(queued, key=_order_key)
|
| 139 |
+
job.state = "running"
|
| 140 |
+
job.started = time.time()
|
| 141 |
+
return job
|
| 142 |
+
|
| 143 |
+
async def _settle_batch(self) -> None:
|
| 144 |
+
"""The rest of a forwarded batch may still be on its way: wait until nothing new was queued for a while."""
|
| 145 |
+
if not self._settle:
|
| 146 |
+
return
|
| 147 |
+
deadline = time.monotonic() + self._settle * SETTLE_CAP
|
| 148 |
+
while time.monotonic() < deadline:
|
| 149 |
+
self._wake.clear()
|
| 150 |
+
try:
|
| 151 |
+
await asyncio.wait_for(self._wake.wait(), timeout=self._settle)
|
| 152 |
+
except asyncio.TimeoutError:
|
| 153 |
+
return
|
| 154 |
+
|
| 155 |
+
async def _next(self) -> Job:
|
| 156 |
+
while True:
|
| 157 |
+
job = self._claim()
|
| 158 |
+
if job is not None:
|
| 159 |
+
return job
|
| 160 |
+
self._wake.clear()
|
| 161 |
+
await self._wake.wait()
|
| 162 |
+
await self._settle_batch()
|
| 163 |
+
|
| 164 |
async def _worker(self) -> None:
|
| 165 |
while True:
|
| 166 |
+
job = await self._next()
|
| 167 |
try:
|
| 168 |
+
await self._runner(job)
|
| 169 |
+
except asyncio.CancelledError:
|
| 170 |
+
raise
|
| 171 |
+
except Exception: # noqa: BLE001 - the runner reports its own failures
|
| 172 |
+
log.exception("job %s crashed", job.id)
|
| 173 |
+
job.state = "failed"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
finally:
|
| 175 |
+
if job.state == "running":
|
| 176 |
+
job.state = "done"
|
| 177 |
+
self.jobs.pop(job.id, None)
|
tests/test_app.py
CHANGED
|
@@ -16,6 +16,7 @@ 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 (
|
|
@@ -393,6 +394,79 @@ async def test_remove_words_are_applied_to_the_new_name(ctx):
|
|
| 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)
|
|
|
|
| 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 |
+
from bot.queue import JobQueue
|
| 20 |
|
| 21 |
from .conftest import needs_ffmpeg
|
| 22 |
from .tgfakes import (
|
|
|
|
| 394 |
assert "SomeChannel" not in job.new_name and "Tokyo Revengers" in job.new_name
|
| 395 |
|
| 396 |
|
| 397 |
+
async def test_the_one_piece_style_name_gets_its_episode_number(ctx):
|
| 398 |
+
await say(ctx, OWNER, "/template One Piece - E{episode}")
|
| 399 |
+
ev = await send_doc(ctx, OWNER, "1147 - One Piece [Dual] 720p @Anime_X_Universe.mkv", mid=21)
|
| 400 |
+
(job,) = ctx.app.queue.jobs.values()
|
| 401 |
+
assert job.new_name == "One Piece - E1147.mkv" and job.notes == []
|
| 402 |
+
assert "One Piece - E1147.mkv" in ev.texts[0] and "⚠️" not in ev.texts[0]
|
| 403 |
+
|
| 404 |
+
|
| 405 |
+
async def test_a_template_variable_that_cannot_be_filled_is_flagged(ctx):
|
| 406 |
+
await say(ctx, OWNER, "/template One Piece - E{episode}")
|
| 407 |
+
ev = await send_doc(ctx, OWNER, "Plain Movie.mkv")
|
| 408 |
+
(job,) = ctx.app.queue.jobs.values()
|
| 409 |
+
assert job.new_name == "One Piece - E.mkv"
|
| 410 |
+
assert "⚠️" in ev.texts[0] and "{episode}" in ev.texts[0] and "file name" in ev.texts[0] and "/rename" in ev.texts[0]
|
| 411 |
+
assert job.notes and "{episode}" in job.notes[0]
|
| 412 |
+
|
| 413 |
+
await say(ctx, OWNER, "/mode caption")
|
| 414 |
+
ev = await send_doc(ctx, OWNER, "Plain Movie.mkv", mid=22)
|
| 415 |
+
assert "in the caption" in ev.texts[0]
|
| 416 |
+
|
| 417 |
+
|
| 418 |
+
async def test_default_template_and_manual_names_never_warn(ctx):
|
| 419 |
+
ev = await send_doc(ctx, OWNER, "Plain Movie.mkv")
|
| 420 |
+
assert "⚠️" not in ev.texts[0] # bracket groups vanish on purpose
|
| 421 |
+
await say(ctx, OWNER, "/template One Piece - E{episode}")
|
| 422 |
+
ev = await say(ctx, OWNER, "/rename My Name", reply=document_message("Plain Movie.mkv", mid=40), mid=41)
|
| 423 |
+
assert "⚠️" not in ev.texts[0]
|
| 424 |
+
|
| 425 |
+
|
| 426 |
+
class SlowEvent(FakeEvent):
|
| 427 |
+
"""A message whose reply takes a while, like a slow round trip to Telegram."""
|
| 428 |
+
|
| 429 |
+
def __init__(self, *a, delay=0.0, **kw):
|
| 430 |
+
super().__init__(*a, **kw)
|
| 431 |
+
self.delay = delay
|
| 432 |
+
|
| 433 |
+
async def reply(self, text=None, **kw):
|
| 434 |
+
await asyncio.sleep(self.delay)
|
| 435 |
+
return await super().respond(text, **kw)
|
| 436 |
+
|
| 437 |
+
|
| 438 |
+
async def test_jobs_are_ordered_by_the_message_that_triggered_them(ctx):
|
| 439 |
+
await send_doc(ctx, OWNER, "B.mkv", mid=31)
|
| 440 |
+
await send_doc(ctx, OWNER, "A.mkv", mid=30)
|
| 441 |
+
await say(ctx, OWNER, "/rename X", reply=document_message("old.mkv", 1024, mid=5), mid=34)
|
| 442 |
+
assert sorted(j.order for j in ctx.app.queue.jobs.values()) == [30, 31, 34]
|
| 443 |
+
assert [j.file_name for j in ctx.app.queue.for_user(OWNER)] == ["A.mkv", "B.mkv", "old.mkv"]
|
| 444 |
+
|
| 445 |
+
|
| 446 |
+
async def test_a_forwarded_batch_comes_out_in_the_order_it_was_sent(ctx):
|
| 447 |
+
"""Telethon handles messages that arrive together concurrently; the results must still be in order."""
|
| 448 |
+
ran = []
|
| 449 |
+
|
| 450 |
+
async def runner(job):
|
| 451 |
+
ran.append(job.file_name)
|
| 452 |
+
|
| 453 |
+
ctx.app.queue = JobQueue(runner, workers=1, settle=0.3)
|
| 454 |
+
ctx.app.queue.start()
|
| 455 |
+
await asyncio.sleep(0.02) # the worker is idle, waiting for files
|
| 456 |
+
names = {
|
| 457 |
+
1145: "1145 - One Piece [Dual] 720p @Anime_X_Universe.mkv",
|
| 458 |
+
1146: "1146 - One Piece [Dual] 720p @Anime_X_Universe.mkv",
|
| 459 |
+
1147: "1147 - One Piece [Dual] 720p @Anime_X_Universe.mkv",
|
| 460 |
+
}
|
| 461 |
+
delays = {1145: 0.10, 1146: 0.00, 1147: 0.05} # the replies finish in a different order than the files arrived
|
| 462 |
+
events = [
|
| 463 |
+
SlowEvent(OWNER, message=document_message(name, 1024, mid=mid), delay=delays[mid]) for mid, name in names.items()
|
| 464 |
+
]
|
| 465 |
+
await asyncio.gather(*(dispatch(ctx.client, ev) for ev in reversed(events)))
|
| 466 |
+
await asyncio.sleep(0.7)
|
| 467 |
+
assert ran == [names[1145], names[1146], names[1147]]
|
| 468 |
+
|
| 469 |
+
|
| 470 |
async def test_rename_command_uses_the_given_name_and_keeps_the_extension(ctx):
|
| 471 |
reply = document_message("old.name.mkv", 10 * 1024 * 1024, mid=33)
|
| 472 |
ev = await say(ctx, OWNER, "/rename My Custom Name", reply=reply, mid=34)
|
tests/test_db.py
CHANGED
|
@@ -74,3 +74,14 @@ async def test_admins_and_kv(db):
|
|
| 74 |
assert await db.kv_get("session") == "def"
|
| 75 |
await db.kv_delete("session")
|
| 76 |
assert await db.kv_get("session") is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
assert await db.kv_get("session") == "def"
|
| 75 |
await db.kv_delete("session")
|
| 76 |
assert await db.kv_get("session") is None
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
async def test_first_use_by_many_concurrent_handlers_does_not_collide(db):
|
| 80 |
+
"""A forwarded batch is handled by several tasks at once; all of them look the new user up together."""
|
| 81 |
+
import asyncio
|
| 82 |
+
|
| 83 |
+
results = await asyncio.gather(*(db.get_settings(77) for _ in range(8)))
|
| 84 |
+
assert all(r == DEFAULT_SETTINGS for r in results)
|
| 85 |
+
# the same goes for the other writers that create the row on first use
|
| 86 |
+
await asyncio.gather(*(db.record_job(78, 10) for _ in range(5)), *(db.set_thumb(79, b"x") for _ in range(5)))
|
| 87 |
+
assert await db.count_users() == 3
|
tests/test_parser.py
CHANGED
|
@@ -6,6 +6,7 @@ from bot.parser import (
|
|
| 6 |
build_manual_name,
|
| 7 |
compute_new_name,
|
| 8 |
finalize_name,
|
|
|
|
| 9 |
parse_caption,
|
| 10 |
parse_info,
|
| 11 |
render_template,
|
|
@@ -200,3 +201,94 @@ def test_manual_name_sanitised():
|
|
| 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"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
build_manual_name,
|
| 7 |
compute_new_name,
|
| 8 |
finalize_name,
|
| 9 |
+
missing_variables,
|
| 10 |
parse_caption,
|
| 11 |
parse_info,
|
| 12 |
render_template,
|
|
|
|
| 201 |
def test_sanitize_and_remove_words():
|
| 202 |
assert sanitize(" ..hidden:name ") == "hidden name"
|
| 203 |
assert apply_remove_words("Show [Group] 720p", ["[group]"]) == "Show 720p"
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
# ---- episode numbers written first / bare / in brackets (e.g. "1147 - One Piece [Dual] 720p @Channel") ----
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
@pytest.mark.parametrize(
|
| 210 |
+
"name,title,episode,quality",
|
| 211 |
+
[
|
| 212 |
+
("1147 - One Piece [Dual] 720p @Anime_X_Universe.mkv", "One Piece", "1147", "720p"),
|
| 213 |
+
("1145 - One Piece [Dual] 720p @Anime_X_Universe.mkv", "One Piece", "1145", "720p"),
|
| 214 |
+
("1147 - One Piece @Anime_X_Universe.mkv", "One Piece", "1147", ""),
|
| 215 |
+
("1147-One Piece [720p].mkv", "One Piece", "1147", "720p"),
|
| 216 |
+
("1147 – One Piece 720p.mkv", "One Piece", "1147", "720p"),
|
| 217 |
+
("1147_-_One_Piece_[Dual]_720p.mkv", "One Piece", "1147", "720p"),
|
| 218 |
+
("[Group] 1147 - One Piece 720p.mkv", "One Piece", "1147", "720p"),
|
| 219 |
+
("1147v2 - One Piece 720p.mkv", "One Piece", "1147", "720p"),
|
| 220 |
+
("05 - Naruto [720p].mkv", "Naruto", "05", "720p"),
|
| 221 |
+
("1080 - One Piece [Dual] 720p.mkv", "One Piece", "1080", "720p"), # 1080 is an episode here, not a resolution
|
| 222 |
+
("One Piece 1147 720p.mkv", "One Piece", "1147", "720p"),
|
| 223 |
+
("One.Piece.1147.720p.WEB-DL.mkv", "One Piece", "1147", "720p"),
|
| 224 |
+
("One Piece 1147 Dual Audio 720p.mkv", "One Piece", "1147", "720p"),
|
| 225 |
+
("One Piece 1147 [Dual].mkv", "One Piece", "1147", ""),
|
| 226 |
+
("Naruto Shippuden 500 - Name.mkv", "Naruto Shippuden", "500", ""),
|
| 227 |
+
("[1147] One Piece 720p.mkv", "One Piece", "1147", "720p"),
|
| 228 |
+
("One Piece [1147] [720p].mkv", "One Piece", "1147", "720p"),
|
| 229 |
+
("[Group] Title [05] [720p].mkv", "Title", "05", "720p"),
|
| 230 |
+
],
|
| 231 |
+
)
|
| 232 |
+
def test_episode_without_a_marker_word(name, title, episode, quality):
|
| 233 |
+
info = parse_info(name)
|
| 234 |
+
assert (info.title, info.season, info.episode, info.quality) == (title, "01", episode, quality)
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
@pytest.mark.parametrize(
|
| 238 |
+
"name,title",
|
| 239 |
+
[
|
| 240 |
+
("3-gatsu no Lion 720p.mkv", "3-gatsu no Lion"),
|
| 241 |
+
("5-toubun no Hanayome [1080p].mkv", "5-toubun no Hanayome"),
|
| 242 |
+
("86-Eighty Six 720p.mkv", "86-Eighty Six"),
|
| 243 |
+
("2001 - A Space Odyssey (1968).mkv", "2001 - A Space Odyssey"),
|
| 244 |
+
("Blade Runner 2049 1080p BluRay.mkv", "Blade Runner"),
|
| 245 |
+
("12 Angry Men (1957) 1080p BluRay.mkv", "12 Angry Men"),
|
| 246 |
+
("300 (2006) 1080p BluRay.mkv", "300"),
|
| 247 |
+
("Room 237 (2012) 1080p.mkv", "Room 237"),
|
| 248 |
+
("Apollo 13 (1995) 1080p.mkv", "Apollo 13"),
|
| 249 |
+
("Spider-Man 3 1080p WEB-DL.mkv", "Spider-Man 3"),
|
| 250 |
+
("Toy Story 4 720p.mkv", "Toy Story 4"),
|
| 251 |
+
("Oppenheimer.2023.1080p.BluRay.x265.10bit.mkv", "Oppenheimer"),
|
| 252 |
+
("Title [2019] [720p].mkv", "Title"),
|
| 253 |
+
("Some Movie 1920x1080.mkv", "Some Movie"),
|
| 254 |
+
("Title 10bit 720p.mkv", "Title 10bit"),
|
| 255 |
+
],
|
| 256 |
+
)
|
| 257 |
+
def test_numbers_that_belong_to_titles_are_not_episodes(name, title):
|
| 258 |
+
info = parse_info(name)
|
| 259 |
+
assert info.episode == "" and info.title == title
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
def test_an_explicit_marker_beats_a_leading_number():
|
| 263 |
+
info = parse_info("86 - Eighty Six - 05 (1080p).mkv")
|
| 264 |
+
assert (info.title, info.episode, info.quality) == ("86 - Eighty Six", "05", "1080p")
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
def test_channel_tags_end_the_title():
|
| 268 |
+
assert parse_info("One Piece @Anime_X_Universe 720p.mkv").title == "One Piece"
|
| 269 |
+
assert parse_info("Some @ Title.mkv").title == "Some @ Title"
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
def test_the_users_one_piece_files_get_their_episode_number():
|
| 273 |
+
for number in ("1145", "1146", "1147"):
|
| 274 |
+
name, _info = compute_new_name(
|
| 275 |
+
template="One Piece - E{episode}", mode="filename", original_ext=".mkv",
|
| 276 |
+
original_name=f"{number} - One Piece [Dual] 720p @Anime_X_Universe.mkv",
|
| 277 |
+
)
|
| 278 |
+
assert name == f"One Piece - E{number}.mkv"
|
| 279 |
+
name, _ = compute_new_name(
|
| 280 |
+
template=DEFAULT_TEMPLATE, mode="filename", original_ext=".mkv",
|
| 281 |
+
original_name="1147 - One Piece [Dual] 720p @Anime_X_Universe.mkv",
|
| 282 |
+
)
|
| 283 |
+
assert name == "[S01E1147] One Piece [720p].mkv"
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
def test_missing_variables_only_reports_holes_outside_bracket_groups():
|
| 287 |
+
info = parse_info("Plain Movie.mkv")
|
| 288 |
+
assert missing_variables("One Piece - E{episode}", info) == ["episode"]
|
| 289 |
+
assert missing_variables("S{season}E{episode} {title}", info) == ["season", "episode"]
|
| 290 |
+
assert missing_variables("{title} {quality} {quality}", info) == ["quality"]
|
| 291 |
+
assert missing_variables(DEFAULT_TEMPLATE, info) == [] # bracket groups vanish on purpose
|
| 292 |
+
assert missing_variables("{title} ({year})", info) == []
|
| 293 |
+
assert missing_variables("{title} {nope}", info) == []
|
| 294 |
+
assert missing_variables("One Piece - E{episode}", parse_info("1147 - One Piece 720p.mkv")) == []
|
tests/test_pipeline.py
CHANGED
|
@@ -114,6 +114,15 @@ async def test_plain_rename_sends_video_with_thumbnail(env):
|
|
| 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(
|
|
|
|
| 114 |
assert any("Tokyo Revengers" in t for t in env.logs)
|
| 115 |
|
| 116 |
|
| 117 |
+
@needs_ffmpeg
|
| 118 |
+
async def test_notes_from_queueing_appear_in_the_done_message(env):
|
| 119 |
+
client, renamer, job = make(env)
|
| 120 |
+
job.notes = ["Couldn't find {episode} in the file name, so it is left empty."]
|
| 121 |
+
await renamer.process(job)
|
| 122 |
+
assert job.state == "done"
|
| 123 |
+
assert "⚠️ Couldn't find {episode} in the file name" in client.edits[-1][0]
|
| 124 |
+
|
| 125 |
+
|
| 126 |
@needs_ffmpeg
|
| 127 |
async def test_metadata_and_track_selection_are_applied_before_upload(env):
|
| 128 |
await env.db.update_settings(
|
tests/test_queue_progress.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
import asyncio
|
|
|
|
| 2 |
from types import SimpleNamespace
|
| 3 |
|
| 4 |
from telethon import errors
|
|
@@ -141,6 +142,142 @@ async def test_runner_crash_does_not_kill_the_worker():
|
|
| 141 |
await q.stop()
|
| 142 |
|
| 143 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
# ---------------------------------------------------------------------------
|
| 145 |
# progress reporter
|
| 146 |
# ---------------------------------------------------------------------------
|
|
|
|
| 1 |
import asyncio
|
| 2 |
+
import time
|
| 3 |
from types import SimpleNamespace
|
| 4 |
|
| 5 |
from telethon import errors
|
|
|
|
| 142 |
await q.stop()
|
| 143 |
|
| 144 |
|
| 145 |
+
def ordered_job(queue, order, user=1):
|
| 146 |
+
return Job(id=queue.new_id(), user_id=user, chat_id=user, msg_id=order, file_name=f"{order}.mkv", size=1,
|
| 147 |
+
new_name=f"{order}.mkv", order=order)
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
async def idle_queue(runner, **kw):
|
| 151 |
+
"""A started queue whose worker is already waiting for work, like the real bot between files."""
|
| 152 |
+
q = JobQueue(runner, workers=1, **kw)
|
| 153 |
+
q.start()
|
| 154 |
+
await asyncio.sleep(0.02)
|
| 155 |
+
return q
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
async def test_a_batch_queued_out_of_order_runs_in_message_order():
|
| 159 |
+
ran = []
|
| 160 |
+
|
| 161 |
+
async def runner(j):
|
| 162 |
+
ran.append(j.order)
|
| 163 |
+
|
| 164 |
+
q = await idle_queue(runner, settle=0.1)
|
| 165 |
+
for order in (1146, 1147, 1145): # the order in which three concurrent handlers happened to finish
|
| 166 |
+
q.add(ordered_job(q, order))
|
| 167 |
+
await asyncio.sleep(0.01)
|
| 168 |
+
await asyncio.sleep(0.4)
|
| 169 |
+
assert ran == [1145, 1146, 1147]
|
| 170 |
+
await q.stop()
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
async def test_without_a_settle_time_the_first_queued_job_starts_at_once():
|
| 174 |
+
ran = []
|
| 175 |
+
|
| 176 |
+
async def runner(j):
|
| 177 |
+
ran.append(j.order)
|
| 178 |
+
|
| 179 |
+
q = await idle_queue(runner)
|
| 180 |
+
for order in (1146, 1147, 1145):
|
| 181 |
+
q.add(ordered_job(q, order))
|
| 182 |
+
await asyncio.sleep(0.01)
|
| 183 |
+
await asyncio.sleep(0.1)
|
| 184 |
+
assert ran[0] == 1146 # which is why the bot uses a settle time
|
| 185 |
+
await q.stop()
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
async def test_jobs_queued_while_busy_are_picked_oldest_message_first():
|
| 189 |
+
ran, gate = [], asyncio.Event()
|
| 190 |
+
|
| 191 |
+
async def runner(j):
|
| 192 |
+
ran.append(j.order)
|
| 193 |
+
if j.order == 1:
|
| 194 |
+
await gate.wait()
|
| 195 |
+
|
| 196 |
+
q = await idle_queue(runner)
|
| 197 |
+
q.add(ordered_job(q, 1))
|
| 198 |
+
await asyncio.sleep(0.02)
|
| 199 |
+
for order in (30, 20, 25):
|
| 200 |
+
q.add(ordered_job(q, order))
|
| 201 |
+
gate.set()
|
| 202 |
+
await asyncio.sleep(0.1)
|
| 203 |
+
assert ran == [1, 20, 25, 30]
|
| 204 |
+
await q.stop()
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
async def test_an_idle_worker_waits_for_the_settle_time_before_starting():
|
| 208 |
+
ran = []
|
| 209 |
+
|
| 210 |
+
async def runner(j):
|
| 211 |
+
ran.append(j.id)
|
| 212 |
+
|
| 213 |
+
q = await idle_queue(runner, settle=0.3)
|
| 214 |
+
q.add(job(q))
|
| 215 |
+
await asyncio.sleep(0.1)
|
| 216 |
+
assert ran == [] and q.waiting == 1
|
| 217 |
+
await asyncio.sleep(0.5)
|
| 218 |
+
assert ran == [1]
|
| 219 |
+
await q.stop()
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
async def test_every_new_job_extends_the_wait_so_a_slow_batch_is_still_grouped():
|
| 223 |
+
ran = []
|
| 224 |
+
|
| 225 |
+
async def runner(j):
|
| 226 |
+
ran.append(j.order)
|
| 227 |
+
|
| 228 |
+
q = await idle_queue(runner, settle=0.3)
|
| 229 |
+
for order in (3, 2, 1): # each arrives 0.2 s after the previous one: more than one settle period in total
|
| 230 |
+
q.add(ordered_job(q, order))
|
| 231 |
+
await asyncio.sleep(0.2)
|
| 232 |
+
await asyncio.sleep(0.6)
|
| 233 |
+
assert ran == [1, 2, 3]
|
| 234 |
+
await q.stop()
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
async def test_a_steady_stream_cannot_hold_the_worker_back_forever():
|
| 238 |
+
started_at, last_add = [], []
|
| 239 |
+
|
| 240 |
+
async def runner(j):
|
| 241 |
+
started_at.append(time.monotonic())
|
| 242 |
+
|
| 243 |
+
q = await idle_queue(runner, settle=0.15) # capped at SETTLE_CAP * 0.15 s
|
| 244 |
+
for order in range(30, 0, -1):
|
| 245 |
+
q.add(ordered_job(q, order))
|
| 246 |
+
last_add.append(time.monotonic())
|
| 247 |
+
await asyncio.sleep(0.05)
|
| 248 |
+
assert started_at and started_at[0] < last_add[-1] - 0.3 # it started well before the stream stopped
|
| 249 |
+
await q.stop()
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
async def test_position_and_listing_follow_message_order():
|
| 253 |
+
async def runner(j):
|
| 254 |
+
pass
|
| 255 |
+
|
| 256 |
+
q = JobQueue(runner, workers=1) # never started: everything stays queued
|
| 257 |
+
a, b, c = ordered_job(q, 30), ordered_job(q, 10), ordered_job(q, 20)
|
| 258 |
+
for j in (a, b, c):
|
| 259 |
+
q.add(j)
|
| 260 |
+
assert [q.position(j) for j in (b, c, a)] == [1, 2, 3]
|
| 261 |
+
assert [j.order for j in q.for_user(1)] == [10, 20, 30]
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
async def test_cancelling_during_the_settle_time_skips_the_job():
|
| 265 |
+
ran = []
|
| 266 |
+
|
| 267 |
+
async def runner(j):
|
| 268 |
+
ran.append(j.id)
|
| 269 |
+
|
| 270 |
+
q = await idle_queue(runner, settle=0.2)
|
| 271 |
+
a, b = job(q), job(q)
|
| 272 |
+
q.add(a)
|
| 273 |
+
q.add(b)
|
| 274 |
+
await asyncio.sleep(0.05)
|
| 275 |
+
assert q.cancel(a.id)
|
| 276 |
+
await asyncio.sleep(0.6)
|
| 277 |
+
assert ran == [b.id]
|
| 278 |
+
await q.stop()
|
| 279 |
+
|
| 280 |
+
|
| 281 |
# ---------------------------------------------------------------------------
|
| 282 |
# progress reporter
|
| 283 |
# ---------------------------------------------------------------------------
|