RSS/Atom -> ntfy bridge with web UI, OPML import/export and RU/EN localization
Web-managed fork of nurefexc/rss-bridge-ntfy: Flask UI + REST API, background sync engine (SQLite dedup, quiet hours, filters, flood protection, images), OPML import/export and switchable interface/notification language. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
"""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})
|
||||
Reference in New Issue
Block a user