Skip to content

Commit d1500a4

Browse files
committed
feat: add download-source link to notifications + markdown link formatting
- Add _format_link() helper: [label](url) for markdown, label: url for text - Split Apprise URLs into two instances (markdown-capable vs text-only) - Auto-upgrade ntfy URLs with ?format=markdown - Add download-source link (FitGirl/FreeGOG) between Metacritic and YouTube in notification body - Thread source_name/source_url from pipeline matching to notification call - Extract _append_optional_fields helper (CRAP 9.08→6.00) - 100% test coverage on notifications.py (47 tests) Closes: download-source-link-notification
1 parent ca131b1 commit d1500a4

5 files changed

Lines changed: 487 additions & 37 deletions

File tree

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "gamarr"
7-
version = "1.1.6"
7+
version = "1.1.7"
88
description = "Metadata game downloader - torrent metadata harvester"
99
readme = "README.md"
1010
requires-python = ">=3.12"
@@ -100,4 +100,4 @@ dev = [
100100
"pre-commit",
101101
"mypy",
102102
"ruff"
103-
]
103+
]

src/gamarr/notifications.py

Lines changed: 139 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
from loguru import logger
99

10+
_MARKDOWN_SCHEMES = frozenset({"ntfy", "ntfys", "discord", "slack", "tgram", "tg", "matrix", "matrixs"})
11+
1012

1113
class Notifier:
1214
"""Sends notifications for gamarr events via Apprise."""
@@ -24,16 +26,28 @@ def __init__(
2426
self._on_failure = on_failure
2527
self._on_error = on_error
2628
self._on_scrape_failure = on_scrape_failure
27-
self._apprise = self._init_apprise()
2829

29-
def _init_apprise(self) -> Any:
30-
if not self._urls:
30+
# Split URLs into markdown-capable and text-only groups
31+
self._md_urls: list[str] = []
32+
self._text_urls: list[str] = []
33+
for url in self._urls:
34+
if Notifier._is_markdown_service(url):
35+
self._md_urls.append(Notifier._maybe_upgrade_ntfy(url))
36+
else:
37+
self._text_urls.append(url)
38+
39+
self._apprise_md = self._init_apprise(self._md_urls)
40+
self._apprise_text = self._init_apprise(self._text_urls)
41+
42+
@staticmethod
43+
def _init_apprise(urls: list[str]) -> Any:
44+
if not urls:
3145
return None
3246
try:
3347
import apprise
3448

3549
apobj = apprise.Apprise()
36-
for url in self._urls:
50+
for url in urls:
3751
apobj.add(url)
3852
return apobj
3953
except Exception as exc:
@@ -61,6 +75,45 @@ def _youtube_search_url(title: str) -> str:
6175
query = urllib.parse.quote_plus(f"{title} review")
6276
return f"https://www.youtube.com/results?search_query={query}"
6377

78+
@staticmethod
79+
def _is_markdown_service(url: str) -> bool:
80+
"""Return True if *url* uses a scheme that supports markdown formatting."""
81+
try:
82+
scheme = url.split("://", 1)[0].lower()
83+
except (ValueError, AttributeError):
84+
return False
85+
return scheme in _MARKDOWN_SCHEMES
86+
87+
@staticmethod
88+
def _maybe_upgrade_ntfy(url: str) -> str:
89+
"""Append ``?format=markdown`` to ntfy URLs that don't already have it."""
90+
try:
91+
scheme = url.split("://", 1)[0].lower()
92+
except (ValueError, AttributeError):
93+
return url
94+
if scheme not in ("ntfy", "ntfys"):
95+
return url
96+
if "format=markdown" in url:
97+
return url
98+
sep = "&" if "?" in url else "?"
99+
return f"{url}{sep}format=markdown"
100+
101+
@staticmethod
102+
def _format_link(label: str, url: str, *, use_markdown: bool) -> str:
103+
"""Format a clickable link line.
104+
105+
Args:
106+
label: Display text for the link (e.g. ``"Metacritic"``).
107+
url: The full URL.
108+
use_markdown: If True, produce ``[label](url)``; otherwise ``label: url``.
109+
110+
Returns:
111+
A single-line string suitable for the notification body.
112+
"""
113+
if use_markdown:
114+
return f"[{label}]({url})"
115+
return f"{label}: {url}"
116+
64117
def send_download_notification(
65118
self,
66119
title: str,
@@ -74,10 +127,14 @@ def send_download_notification(
74127
genres: list[str] | None = None,
75128
must_play: bool | None = None,
76129
release_date: str | None = None,
130+
source_name: str | None = None,
131+
source_url: str | None = None,
77132
) -> None:
78-
if not self._on_download or not self._apprise:
133+
if not self._on_download:
134+
return
135+
if not self._apprise_md and not self._apprise_text:
79136
return
80-
body = self._format_download_body(
137+
text_body = self._format_download_body(
81138
add_paused=add_paused,
82139
metascore=metascore,
83140
metascore_reviews=metascore_reviews,
@@ -89,8 +146,45 @@ def send_download_notification(
89146
slug=slug,
90147
title=title,
91148
platform=platform,
149+
source_name=source_name,
150+
source_url=source_url,
151+
use_markdown=False,
92152
)
93-
self._send(f"gamarr - {title} ({platform})", body)
153+
md_body = None
154+
if self._apprise_md:
155+
md_body = self._format_download_body(
156+
add_paused=add_paused,
157+
metascore=metascore,
158+
metascore_reviews=metascore_reviews,
159+
user_score=user_score,
160+
user_reviews=user_reviews,
161+
must_play=must_play,
162+
genres=genres,
163+
release_date=release_date,
164+
slug=slug,
165+
title=title,
166+
platform=platform,
167+
source_name=source_name,
168+
source_url=source_url,
169+
use_markdown=True,
170+
)
171+
self._send(f"gamarr - {title} ({platform})", text_body, body_markdown=md_body)
172+
173+
@staticmethod
174+
def _append_optional_fields(
175+
parts: list[str],
176+
*,
177+
must_play: bool | None,
178+
genres: list[str] | None,
179+
release_date: str | None,
180+
) -> None:
181+
"""Append optional metadata fields to the body parts list."""
182+
if must_play is not None:
183+
parts.append(f"Must Play: {'Yes' if must_play else 'No'}")
184+
if genres:
185+
parts.append(f"Genre: {', '.join(genres)}")
186+
if release_date:
187+
parts.append(f"Release: {release_date}")
94188

95189
@staticmethod
96190
def _format_download_body(
@@ -106,33 +200,39 @@ def _format_download_body(
106200
slug: str,
107201
title: str,
108202
platform: str,
203+
source_name: str | None = None,
204+
source_url: str | None = None,
205+
use_markdown: bool = False,
109206
) -> str:
110207
"""Build the notification body string for a game download."""
208+
link = Notifier._format_link
209+
mk = use_markdown
111210
parts = [f"Status: {'Paused' if add_paused else 'Downloading'}"]
112211
parts.extend(
113212
[
114213
Notifier._format_score_line("Critic Score", metascore, metascore_reviews),
115214
Notifier._format_score_line("User Score", user_score, user_reviews),
116215
]
117216
)
118-
if must_play is not None:
119-
parts.append(f"Must Play: {'Yes' if must_play else 'No'}")
120-
if genres:
121-
parts.append(f"Genre: {', '.join(genres)}")
122-
if release_date:
123-
parts.append(f"Release: {release_date}")
124-
parts.append(f"Metacritic: https://www.metacritic.com/game/{slug or 'unknown'}/")
125-
parts.append(f"YouTube: {Notifier._youtube_search_url(title)}")
217+
Notifier._append_optional_fields(parts, must_play=must_play, genres=genres, release_date=release_date)
218+
parts.append(link("Metacritic", f"https://www.metacritic.com/game/{slug or 'unknown'}", use_markdown=mk))
219+
if source_name and source_url:
220+
parts.append(link(source_name.title(), source_url, use_markdown=mk))
221+
parts.append(link("YouTube", Notifier._youtube_search_url(title), use_markdown=mk))
126222
return "\n".join(parts)
127223

128224
def send_failure_notification(self, title: str, reason: str) -> None:
129-
if not self._on_failure or not self._apprise:
225+
if not self._on_failure:
226+
return
227+
if not self._apprise_md and not self._apprise_text:
130228
return
131229
body = f"gamarr: {title} failed checks\nReason: {reason}"
132230
self._send("gamarr - Failed", body)
133231

134232
def send_error_notification(self, error_message: str) -> None:
135-
if not self._on_error or not self._apprise:
233+
if not self._on_error:
234+
return
235+
if not self._apprise_md and not self._apprise_text:
136236
return
137237
body = f"gamarr pipeline error:\n{error_message}"
138238
self._send("gamarr - Error", body)
@@ -145,15 +245,29 @@ def send_scrape_notification(self, message: str) -> None:
145245
Args:
146246
message: Description of the scraping issue to include in the body.
147247
"""
148-
if not self._on_scrape_failure or not self._apprise:
248+
if not self._on_scrape_failure:
249+
return
250+
if not self._apprise_md and not self._apprise_text:
149251
return
150252
body = f"{message}\n\nThis may indicate a Metacritic site change or network issue."
151253
self._send("gamarr - Scraping Issue", body)
152254

153-
def _send(self, title: str, body: str) -> None:
154-
if not self._apprise:
155-
return
156-
try:
157-
self._apprise.notify(title=title, body=body)
158-
except Exception as exc:
159-
logger.warning("Failed to send notification '{}': {}", title, exc)
255+
def _send(self, title: str, body: str, *, body_markdown: str | None = None) -> None:
256+
"""Send notification to both markdown and text instances.
257+
258+
Args:
259+
title: Notification title (shared across all instances).
260+
body: Plain-text body for the text instance (label: url format).
261+
body_markdown: Markdown body for the markdown instance
262+
([label](url) format). If None, markdown instance is skipped.
263+
"""
264+
if self._apprise_md and body_markdown is not None:
265+
try:
266+
self._apprise_md.notify(title=title, body=body_markdown)
267+
except Exception as exc:
268+
logger.warning("Failed to send markdown notification '{}': {}", title, exc)
269+
if self._apprise_text:
270+
try:
271+
self._apprise_text.notify(title=title, body=body)
272+
except Exception as exc:
273+
logger.warning("Failed to send text notification '{}': {}", title, exc)

src/gamarr/pipeline.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1437,6 +1437,7 @@ def _deliver_with_jit_verify(
14371437
magnet_fetcher: Callable[[str], str | None],
14381438
notifier: Any,
14391439
best: dict[str, Any],
1440+
source_name: str = "fitgirl",
14401441
) -> dict[str, Any] | None:
14411442
"""Verify scores just-in-time, then deliver the match.
14421443
@@ -1483,6 +1484,7 @@ def _deliver_with_jit_verify(
14831484
game_genres=game_genres,
14841485
game_must_play=game_must_play,
14851486
game_release_date=game_release_date,
1487+
source_name=source_name,
14861488
)
14871489

14881490

@@ -1592,6 +1594,7 @@ def _deliver_match(
15921594
game_genres: list[str] | None = None,
15931595
game_must_play: bool | None = None,
15941596
game_release_date: str | None = None,
1597+
source_name: str = "fitgirl",
15951598
) -> dict[str, Any]:
15961599
"""Deliver a matched pending game to qBittorrent and emit notifications.
15971600
@@ -1694,6 +1697,8 @@ def _deliver_match(
16941697
must_play=game_must_play,
16951698
release_date=game_release_date,
16961699
add_paused=qbt.add_paused,
1700+
source_name=source_name,
1701+
source_url=best["url"],
16971702
)
16981703
return record_result
16991704

@@ -1820,6 +1825,7 @@ def _process_single_pending_match(
18201825
magnet_fetcher=magnet_fetcher,
18211826
notifier=notifier,
18221827
best=best,
1828+
source_name=source_name,
18231829
)
18241830
if result_dict is not None:
18251831
return result_dict

0 commit comments

Comments
 (0)