Files
rss-bridge-ntfy/store.py
T

178 lines
6.2 KiB
Python
Raw Normal View History

"""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})