77
88from loguru import logger
99
10+ _MARKDOWN_SCHEMES = frozenset ({"ntfy" , "ntfys" , "discord" , "slack" , "tgram" , "tg" , "matrix" , "matrixs" })
11+
1012
1113class 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\n Reason: { 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 \n This 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 )
0 commit comments