feat(notify): value-first emoji rows
ci / docker (push) Successful in 24s
test / komodo-deploy-script (push) Successful in 26s
test / komodo-deploy-script (pull_request) Successful in 16s

Switch the summary to the layout jblu picked: each counter renders as
"emoji value label", grouped two per line and joined by a middle dot.

    Scanned 42 torrents in 12s

    🗑️ 7 removed  ·  👻 4 orphaned
    ⏭️ 5 ignored  ·  🐣 3 premature

    🛡️ 10 protected  ·  🌐 20 public

- the value leads, so the numbers are the first thing the eye lands on
- html style now bolds the values instead of the labels
- "Non-protected" reads as "public", matching the non_protected_tag
- use_emoji now defaults to true; false gives the plain variant
- dry runs read "7 would be removed"
This commit is contained in:
trex Bot committed 2026-11-03 15:10:03 -06:00
1 parent 5d9da0d22b
commit dfce115fd8
5 files changed
+90 -51

No files matched your search

+16 -13
View File
@@ -137,35 +137,38 @@ Pushover and Apprise share one summary builder, so both report the same
numbers. The title states the outcome, the body gives the scanned total, the
actionable counters, then the tracker split.
`format = "plain"` (default) produces a compact, grouped summary:
The default summary looks like this:
```
qbit-maid: 7 torrents removed
Scanned 42 torrents in 12s
Removed 7 · Orphaned 4
Ignored 5 · Premature 3
🗑️ 7 removed · 👻 4 orphaned
⏭️ 5 ignored · 🐣 3 premature
Protected 10 · Non-protected 20
🛡️ 10 protected · 🌐 20 public
```
Counters are grouped two per line and joined with a middle dot rather than
padded into columns. Pushover renders the body in a proportional font, so
space-padded columns collapse into a ragged list on a phone — the separator
survives any font.
Each counter is `emoji value label`, grouped two per line and joined with a
middle dot. The value leads so the numbers are the first thing the eye lands
on, and the separator survives any font — Pushover renders the body in a
proportional font, so space-padded columns collapse into a ragged list on a
phone.
`format = "html"` bolds the labels using Pushover's HTML subset (`html=1` is
`format = "html"` bolds the values using Pushover's HTML subset (`html=1` is
set automatically). Apprise has no HTML support, so it always receives the
plain body.
Setting `use_emoji = true` prefixes each counter with an emoji marker:
`use_emoji` defaults to `true`. Set it to `false` for a plain-text variant:
```
🗑️ Removed 7 · 👻 Orphaned 4
⏭️ Ignored 5 · 🐣 Premature 3
Scanned 42 torrents in 12s
🛡️ Protected 10 · 🌐 Non-protected 20
7 removed · 4 orphaned
5 ignored · 3 premature
10 protected · 20 public
```
To preview the result without sending a push, render the real
+5 -4
View File
@@ -26,11 +26,12 @@ use_pushover = false
po_key = "<key>"
po_token = "<token>"
# Notification formatting
# format: "plain" (grouped text) or "html" (bold labels, Pushover HTML)
# Counters are grouped two per line and joined with a middle dot, because
# Pushover uses a proportional font and padded columns do not align.
# format: "plain" (grouped text) or "html" (bold values, Pushover HTML)
# Counters render as "emoji value label", grouped two per line and joined
# with a middle dot, because Pushover uses a proportional font and padded
# columns do not align.
format = "plain"
use_emoji = false
use_emoji = true
include_hostname = false
notify_on_empty = true
# Optional: -2..2. Priority 2 also needs retry/expire (defaults shown).
Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 53 KiB

+20 -15
View File
@@ -155,7 +155,7 @@ def get_notify_options(self):
priority = po.get("priority")
return {
"style": style,
"use_emoji": bool(po.get("use_emoji", False)),
"use_emoji": bool(po.get("use_emoji", True)),
"include_hostname": bool(po.get("include_hostname", False)),
"notify_on_empty": bool(po.get("notify_on_empty", True)),
"priority": None if priority is None else int(priority),
@@ -214,7 +214,10 @@ def debug_torrent_list(self):
def _stat_row(pairs, style, use_emoji):
"""Render one line of ``Label value`` pairs joined by a middle dot.
"""Render one line of ``emoji value label`` pairs joined by a middle dot.
The value leads and the label trails, so the numbers are the first thing
the eye lands on even though the font is proportional.
Args:
pairs: Sequence of ``(emoji_key, label, value)`` tuples.
@@ -222,31 +225,33 @@ def _stat_row(pairs, style, use_emoji):
use_emoji: Prefix each pair with its emoji marker.
Returns:
A single line of text, e.g. ``Removed 7 \u00b7 Orphaned 4``.
A single line of text, e.g.
``\U0001F5D1\uFE0F 7 removed \u00b7 \U0001F47B 4 orphaned``.
"""
parts = []
for key, label, value in pairs:
emoji = NOTIFY_EMOJI.get(key, "") if use_emoji else ""
prefix = f"{emoji} " if emoji else ""
if style == "html":
parts.append(f"{prefix}<b>{label}</b> {value}")
parts.append(f"{prefix}<b>{value}</b> {label}")
else:
parts.append(f"{prefix}{label} {value}")
parts.append(f"{prefix}{value} {label}")
return ROW_SEPARATOR.join(parts)
def build_summary(self, style=DEFAULT_NOTIFY_STYLE, use_emoji=False,
def build_summary(self, style=DEFAULT_NOTIFY_STYLE, use_emoji=True,
include_hostname=False):
"""Build the ``(title, body)`` pair used by every notifier.
Result-first layout: the title states what happened, the body gives the
scanned total, the actionable counters, then the tracker split. Counters
are grouped two-per-line and separated by a middle dot, because Pushover
renders a proportional font and space-padded columns do not line up.
are grouped two-per-line as ``emoji value label`` and separated by a middle
dot, because Pushover renders a proportional font and space-padded columns
do not line up.
Args:
style: ``"plain"`` for grouped text, ``"html"`` for Pushover HTML.
use_emoji: Prefix each row with an emoji marker.
use_emoji: Prefix each counter with an emoji marker.
include_hostname: Add a ``Host:`` line above the summary.
Returns:
@@ -286,19 +291,19 @@ def build_summary(self, style=DEFAULT_NOTIFY_STYLE, use_emoji=False,
lines.append(f"Scanned {total} {scanned_noun} in {stats['runtime']}")
lines.append("")
removed_label = "Removed" if delete_enabled else "Would remove"
removed_label = "removed" if delete_enabled else "would be removed"
lines.append(_stat_row([
("removed", removed_label, removed),
("orphaned", "Orphaned", stats["orphaned"]),
("orphaned", "orphaned", stats["orphaned"]),
], style, use_emoji))
lines.append(_stat_row([
("ignored", "Ignored", stats["ignored"]),
("premature", "Premature", stats["premature"]),
("ignored", "ignored", stats["ignored"]),
("premature", "premature", stats["premature"]),
], style, use_emoji))
lines.append("")
lines.append(_stat_row([
("protected", "Protected", stats["protected"]),
("non_protected", "Non-protected", stats["non_protected"]),
("protected", "protected", stats["protected"]),
("non_protected", "public", stats["non_protected"]),
], style, use_emoji))
return title, "\n".join(lines)
+49 -19
View File
@@ -190,16 +190,28 @@ class TestQbitmaid(unittest.TestCase):
self.assertEqual(stats["runtime"], "unknown")
def test_build_summary_plain(self):
title, body = build_summary(FakeQbt(), style="plain")
title, body = build_summary(FakeQbt(), style="plain", use_emoji=False)
self.assertEqual(title, "qbit-maid: 7 torrents removed")
self.assertEqual(
body,
"Scanned 42 torrents in 12s\n"
"\n"
"Removed 7 \u00b7 Orphaned 4\n"
"Ignored 5 \u00b7 Premature 3\n"
"7 removed \u00b7 4 orphaned\n"
"5 ignored \u00b7 3 premature\n"
"\n"
"Protected 10 \u00b7 Non-protected 20",
"10 protected \u00b7 20 public",
)
def test_build_summary_plain_emoji(self):
_, body = build_summary(FakeQbt(), style="plain", use_emoji=True)
self.assertEqual(
body,
"Scanned 42 torrents in 12s\n"
"\n"
"\U0001F5D1\uFE0F 7 removed \u00b7 \U0001F47B 4 orphaned\n"
"\u23ED\uFE0F 5 ignored \u00b7 \U0001F423 3 premature\n"
"\n"
"\U0001F6E1\uFE0F 10 protected \u00b7 \U0001F310 20 public",
)
def test_build_summary_plain_has_no_leading_indent(self):
@@ -224,33 +236,47 @@ class TestQbitmaid(unittest.TestCase):
for row in rows:
self.assertEqual(row.count("\u00b7"), 1, row)
def test_build_summary_value_leads_label(self):
"""The number comes first so it is the first thing the eye lands on."""
_, body = build_summary(FakeQbt(), style="plain", use_emoji=False)
self.assertIn("7 removed", body)
self.assertIn("20 public", body)
self.assertNotIn("Removed 7", body)
self.assertNotIn("Non-protected", body)
def test_build_summary_html(self):
title, body = build_summary(FakeQbt(), style="html")
self.assertEqual(title, "qbit-maid: 7 torrents removed")
self.assertIn("Scanned <b>42</b> torrents in <b>12s</b>", body)
self.assertIn("<b>Removed</b> 7", body)
self.assertIn("<b>Non-protected</b> 20", body)
self.assertIn("<b>7</b> removed", body)
self.assertIn("<b>20</b> public", body)
self.assertNotIn("---", title)
def test_build_summary_emoji(self):
_, plain = build_summary(FakeQbt(), style="plain", use_emoji=True)
self.assertIn("\U0001F5D1\uFE0F Removed 7", plain)
self.assertIn("\U0001F47B Orphaned 4", plain)
self.assertIn("\U0001F5D1\uFE0F 7 removed", plain)
self.assertIn("\U0001F47B 4 orphaned", plain)
_, html = build_summary(FakeQbt(), style="html", use_emoji=True)
self.assertIn("\U0001F5D1\uFE0F <b>Removed</b> 7", html)
self.assertIn("\U0001F5D1\uFE0F <b>7</b> removed", html)
def test_build_summary_emoji_off_has_no_emoji(self):
_, body = build_summary(FakeQbt(), style="plain", use_emoji=False)
for marker in ("\U0001F5D1", "\U0001F47B", "\u23ED", "\U0001F423",
"\U0001F6E1", "\U0001F310"):
self.assertNotIn(marker, body)
def test_build_summary_delete_disabled(self):
title, body = build_summary(FakeQbt(delete_torrents=False))
self.assertEqual(title, "qbit-maid: 7 torrents would be removed")
self.assertIn("Would remove 7", body)
self.assertNotIn("Removed", body)
def test_build_summary_does_not_repeat_title_in_body(self):
"""The title already states the outcome; the body must not restate it."""
title, body = build_summary(FakeQbt(), style="plain")
self.assertIn("7 would be removed", body)
self.assertNotIn("7 removed", body)
def test_build_summary_body_does_not_repeat_title_phrase(self):
"""The title carries the outcome; the body must not restate it verbatim."""
title, body = build_summary(FakeQbt(), style="plain", use_emoji=False)
self.assertNotIn("torrents removed", body)
self.assertIn("Removed 7", body)
self.assertNotIn("torrents would be removed", body)
self.assertIn("7 removed", body)
self.assertTrue(title.endswith("7 torrents removed"))
def test_build_summary_nothing_to_do(self):
@@ -276,16 +302,20 @@ class TestQbitmaid(unittest.TestCase):
def test_get_notify_options_defaults(self):
opts = get_notify_options(FakeQbt())
self.assertEqual(opts["style"], "plain")
self.assertFalse(opts["use_emoji"])
self.assertTrue(opts["use_emoji"])
self.assertFalse(opts["include_hostname"])
self.assertTrue(opts["notify_on_empty"])
self.assertIsNone(opts["priority"])
self.assertIsNone(opts["sound"])
def test_get_notify_options_emoji_can_be_disabled(self):
opts = get_notify_options(FakeQbt(config={"pushover": {"use_emoji": False}}))
self.assertFalse(opts["use_emoji"])
def test_get_notify_options_overrides(self):
fake = FakeQbt(config={"pushover": {
"format": "HTML",
"use_emoji": True,
"use_emoji": False,
"include_hostname": True,
"notify_on_empty": False,
"priority": 2,
@@ -295,7 +325,7 @@ class TestQbitmaid(unittest.TestCase):
}})
opts = get_notify_options(fake)
self.assertEqual(opts["style"], "html")
self.assertTrue(opts["use_emoji"])
self.assertFalse(opts["use_emoji"])
self.assertTrue(opts["include_hostname"])
self.assertFalse(opts["notify_on_empty"])
self.assertEqual(opts["priority"], 2)