"""Thread-safe persistent store for settings and feeds. Everything the web UI manages lives in two JSON files inside DATA_DIR: - settings.json : global settings (ntfy server, interval, timezone, ...) - feeds.json : list of feeds, each bound to an ntfy topic The SQLite de-duplication database and the log file also live in DATA_DIR. """ import json import os import threading import uuid DATA_DIR = os.environ.get("DATA_DIR", "data") SETTINGS_FILE = os.path.join(DATA_DIR, "settings.json") FEEDS_FILE = os.path.join(DATA_DIR, "feeds.json") DB_PATH = os.path.join(DATA_DIR, "history.db") LOG_PATH = os.path.join(DATA_DIR, "bridge.log") DEFAULT_USER_AGENT = ( "Mozilla/5.0 (compatible; rss-bridge-ntfy/2.0; +https://github.com/nurefexc/rss-bridge-ntfy)" ) DEFAULT_SETTINGS = { "ntfy_url": "https://ntfy.sh", "ntfy_token": "", "sync_interval": 600, # seconds between sync cycles "tz": "UTC", # timezone name (IANA), e.g. Europe/Moscow "user_agent": DEFAULT_USER_AGENT, "batch_limit": 3, # max new items per feed per cycle "max_desc_length": 250, # truncate description to N chars "flood_protection": True, # stagger low-priority notifications "enabled": True, # is the background engine active "language": "ru", # UI + notification language (ru / en) } # Default values applied to every feed object on save (keeps records complete). FEED_DEFAULTS = { "name": "", "url": "", "topic": "", "priority": 3, "icon": "", "enabled": True, "quiet_hours": "", # e.g. "22-7" -> quiet between 22:00 and 07:00 "quiet_priority": 1, "include_regex": "", "exclude_regex": "", } class Store: """JSON-backed configuration store guarded by a re-entrant lock.""" def __init__(self): self._lock = threading.RLock() os.makedirs(DATA_DIR, exist_ok=True) self._ensure_files() # ---- low level helpers ------------------------------------------------- def _ensure_files(self): if not os.path.exists(SETTINGS_FILE): self._write(SETTINGS_FILE, DEFAULT_SETTINGS) if not os.path.exists(FEEDS_FILE): self._write(FEEDS_FILE, {"feeds": []}) @staticmethod def _read(path, fallback): try: with open(path, "r", encoding="utf-8") as fh: return json.load(fh) except (OSError, json.JSONDecodeError): return json.loads(json.dumps(fallback)) @staticmethod def _write(path, data): tmp = f"{path}.tmp" with open(tmp, "w", encoding="utf-8") as fh: json.dump(data, fh, ensure_ascii=False, indent=2) os.replace(tmp, path) # ---- settings ---------------------------------------------------------- def get_settings(self): with self._lock: data = self._read(SETTINGS_FILE, DEFAULT_SETTINGS) merged = dict(DEFAULT_SETTINGS) merged.update({k: v for k, v in data.items() if k in DEFAULT_SETTINGS}) return merged def update_settings(self, patch): with self._lock: current = self.get_settings() for key, value in patch.items(): if key in DEFAULT_SETTINGS: current[key] = self._coerce(key, value, current[key]) self._write(SETTINGS_FILE, current) return current @staticmethod def _coerce(key, value, previous): """Coerce incoming JSON values to the type of the existing default.""" target = type(DEFAULT_SETTINGS.get(key, previous)) try: if target is bool: if isinstance(value, str): return value.strip().lower() in ("1", "true", "yes", "on") return bool(value) if target is int: return int(value) if target is float: return float(value) return str(value) except (TypeError, ValueError): return previous # ---- feeds ------------------------------------------------------------- def get_feeds(self): with self._lock: data = self._read(FEEDS_FILE, {"feeds": []}) feeds = data.get("feeds", []) return [self._normalize_feed(f) for f in feeds] def _normalize_feed(self, feed): result = dict(FEED_DEFAULTS) result.update({k: v for k, v in feed.items() if k in FEED_DEFAULTS}) result["id"] = feed.get("id") or uuid.uuid4().hex # Type fixes try: result["priority"] = int(result["priority"]) except (TypeError, ValueError): result["priority"] = 3 try: result["quiet_priority"] = int(result["quiet_priority"]) except (TypeError, ValueError): result["quiet_priority"] = 1 result["enabled"] = bool(result["enabled"]) result["topic"] = str(result["topic"]).strip() return result def add_feed(self, feed): with self._lock: feeds = self.get_feeds() new_feed = self._normalize_feed(feed) new_feed["id"] = uuid.uuid4().hex feeds.append(new_feed) self._save_feeds(feeds) return new_feed def update_feed(self, feed_id, patch): with self._lock: feeds = self.get_feeds() updated = None for idx, feed in enumerate(feeds): if feed["id"] == feed_id: merged = dict(feed) merged.update({k: v for k, v in patch.items() if k in FEED_DEFAULTS}) merged["id"] = feed_id feeds[idx] = self._normalize_feed(merged) updated = feeds[idx] break if updated is not None: self._save_feeds(feeds) return updated def delete_feed(self, feed_id): with self._lock: feeds = self.get_feeds() remaining = [f for f in feeds if f["id"] != feed_id] if len(remaining) == len(feeds): return False self._save_feeds(remaining) return True def _save_feeds(self, feeds): self._write(FEEDS_FILE, {"feeds": feeds})