178 lines
6.2 KiB
Python
178 lines
6.2 KiB
Python
|
|
"""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})
|