@@ -282,7 +282,7 @@ class Plugin:
282282
283283 # Explicitly set the plugin key
284284 key = "iptv_checker"
285- version = "1.26.1421301 "
285+ version = "1.26.1582047 "
286286
287287 # Fields and actions are defined in plugin.json (single source of truth)
288288 def __init__ (self ):
@@ -734,12 +734,17 @@ def _load_progress(self):
734734 return {"current" : 0 , "total" : 0 , "status" : "idle" , "start_time" : None }
735735
736736 def _save_progress (self ):
737- """Save check progress to persistent storage"""
738- try :
739- with open (self .progress_file , 'w' ) as f :
740- json .dump (self .check_progress , f )
741- except Exception as e :
742- LOGGER .error (f"Failed to save progress file: { e } " )
737+ """Save check progress to persistent storage.
738+
739+ Uses the atomic tmp-file + os.replace helper rather than a plain
740+ open(path, 'w'). A direct write fails with EACCES when an existing
741+ progress file is owned by root and not group-writable (e.g. TrueNAS
742+ SCALE, where the app runs as uid 568 — see issue #21). The atomic
743+ path writes a fresh temp file owned by the current user and renames
744+ it over the target, which only requires write permission on the
745+ parent directory, so it succeeds regardless of the old file's owner.
746+ """
747+ self ._save_json_file (self .progress_file , self .check_progress )
743748
744749 def _load_json_file (self , filepath ):
745750 """Safely load a JSON file, returning None if corrupted or missing."""
@@ -1581,20 +1586,39 @@ def _fire_webhook(self, settings, logger):
15811586 dead = sum (1 for r in results if r .get ('status' ) == 'Dead' )
15821587 skipped = sum (1 for r in results if r .get ('status' ) == 'Skipped' )
15831588
1584- payload = json .dumps ({
1585- "plugin" : self .key ,
1586- "event" : "check_complete" ,
1587- "total" : len (results ),
1588- "alive" : alive ,
1589- "dead" : dead ,
1590- "skipped" : skipped ,
1591- "timestamp" : datetime .utcnow ().isoformat () + "Z" ,
1592- }).encode ('utf-8' )
1593-
1589+ # Discord webhooks reject the plugin's custom JSON shape (they only render
1590+ # {"content": ...} / {"embeds": [...]}). Detect a Discord host and send a
1591+ # native readable summary instead. Everything else keeps the original
1592+ # machine-readable payload for backward compatibility with existing consumers.
1593+ host = (urllib .parse .urlparse (webhook_url ).hostname or '' ).lower ()
1594+ is_discord = host in ('discord.com' , 'discordapp.com' ) or host .endswith ('.discord.com' )
1595+
1596+ if is_discord :
1597+ content = (
1598+ f"**IPTV Checker — check complete**\n "
1599+ f"Total: { len (results )} • ✅ Alive: { alive } • ❌ Dead: { dead } • ⏭️ Skipped: { skipped } "
1600+ )
1601+ payload = json .dumps ({"content" : content }).encode ('utf-8' )
1602+ else :
1603+ payload = json .dumps ({
1604+ "plugin" : self .key ,
1605+ "event" : "check_complete" ,
1606+ "total" : len (results ),
1607+ "alive" : alive ,
1608+ "dead" : dead ,
1609+ "skipped" : skipped ,
1610+ "timestamp" : datetime .utcnow ().isoformat () + "Z" ,
1611+ }).encode ('utf-8' )
1612+
1613+ # Always set an explicit User-Agent. Discord's Cloudflare edge 403s the
1614+ # default "Python-urllib/3.x" UA, which silently dropped every webhook.
15941615 req = urllib .request .Request (
15951616 webhook_url ,
15961617 data = payload ,
1597- headers = {"Content-Type" : "application/json" },
1618+ headers = {
1619+ "Content-Type" : "application/json" ,
1620+ "User-Agent" : f"Dispatcharr-IPTV-Checker/{ self .version } " ,
1621+ },
15981622 method = "POST" ,
15991623 )
16001624
0 commit comments