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

Automated sync from GitHub

Browse files
.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, _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
  )
 
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
- 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]:
 
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
- 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)
@@ -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
- 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] = {}
@@ -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._queue.put_nowait(job)
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=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)
@@ -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._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()
 
 
 
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
  # ---------------------------------------------------------------------------