This commit is contained in:
2026-01-30 10:47:47 -08:00
parent a44b80fd1d
commit ab94c57244
5 changed files with 872 additions and 99 deletions

View File

@@ -7,8 +7,10 @@ from textual.app import ComposeResult
from textual.containers import Container, Horizontal, Vertical, ScrollableContainer
from textual.screen import ModalScreen
from textual.widgets import Static, Button, Input, Label, ListView, ListItem, Rule, Select
from pathlib import Path
from SYS.config import load_config, save_config, reload_config, global_config
from SYS.config import load_config, save_config, reload_config, global_config, count_changed_entries, ConfigSaveConflict
from SYS.database import db
from SYS.logger import log
from Store.registry import _discover_store_classes, _required_keys_for
from ProviderCore.registry import list_providers
@@ -124,6 +126,8 @@ class ConfigModal(ModalScreen):
self._matrix_status: Optional[Static] = None
self._matrix_test_running = False
self._editor_snapshot: Optional[Dict[str, Any]] = None
# Path to the database file used by this process (for diagnostics)
self._db_path = str(db.db_path)
def _capture_editor_snapshot(self) -> None:
self._editor_snapshot = deepcopy(self.config_data)
@@ -141,8 +145,10 @@ class ConfigModal(ModalScreen):
def compose(self) -> ComposeResult:
with Container(id="config-container"):
yield Static("CONFIGURATION EDITOR", classes="section-title")
yield Static(f"DB: {self._db_path}", classes="config-label", id="config-db-path")
yield Static("Last saved: unknown", classes="config-label", id="config-last-save")
with Horizontal():
with Vertical(id="config-sidebar"):
with Vertical(id="config-sidebar"):
yield Label("Categories", classes="config-label")
with ListView(id="category-list"):
yield ListItem(Label("Global Settings"), id="cat-globals")
@@ -156,11 +162,28 @@ class ConfigModal(ModalScreen):
yield Button("Add Store", variant="primary", id="add-store-btn")
yield Button("Add Provider", variant="primary", id="add-provider-btn")
yield Button("Back", id="back-btn")
yield Button("Restore Backup", id="restore-backup-btn")
yield Button("Copy DB Path", id="copy-db-btn")
yield Button("Close", variant="error", id="cancel-btn")
def on_mount(self) -> None:
self.query_one("#add-store-btn", Button).display = False
self.query_one("#add-provider-btn", Button).display = False
# Update DB path and last-saved on mount
try:
self.query_one("#config-db-path", Static).update(self._db_path)
except Exception:
pass
try:
mtime = None
try:
mtime = db.db_path.stat().st_mtime
mtime = __import__('datetime').datetime.utcfromtimestamp(mtime).isoformat() + "Z"
except Exception:
mtime = None
self.query_one("#config-last-save", Static).update(f"Last saved: {mtime or '(unknown)'}")
except Exception:
pass
self.refresh_view()
def refresh_view(self) -> None:
@@ -568,19 +591,37 @@ class ConfigModal(ModalScreen):
if not self.validate_current_editor():
return
if self.editing_item_name and not self._editor_has_changes():
self.notify("No changes to save", severity="warning")
self.notify("No changes to save", severity="warning", timeout=3)
return
try:
saved = self.save_all()
msg = f"Configuration saved ({saved} entries)"
if saved == 0:
msg = "Configuration saved (no rows changed)"
self.notify(msg)
msg = f"Configuration saved (no rows changed) to {db.db_path.name}"
else:
msg = f"Configuration saved ({saved} change(s)) to {db.db_path.name}"
# Make the success notification visible a bit longer so it's not missed
self.notify(msg, timeout=5)
# Return to the main list view within the current category
self.editing_item_name = None
self.editing_item_type = None
self.refresh_view()
self._editor_snapshot = None
except ConfigSaveConflict as exc:
# A concurrent on-disk change was detected; do not overwrite it.
self.notify(
"Save aborted: configuration changed on disk. The editor will refresh.",
severity="error",
timeout=10,
)
# Refresh our in-memory view from disk and drop the editor snapshot
try:
self.config_data = reload_config()
except Exception:
pass
self._editor_snapshot = None
self.editing_item_name = None
self.editing_item_type = None
self.refresh_view()
except Exception as exc:
self.notify(f"Save failed: {exc}", severity="error", timeout=10)
elif bid in self._button_id_map:
@@ -602,13 +643,12 @@ class ConfigModal(ModalScreen):
if "provider" in self.config_data and name in self.config_data["provider"]:
del self.config_data["provider"][name]
removed = True
if str(name).strip().lower() == "alldebrid":
self._remove_alldebrid_store_entry()
if removed:
try:
saved = self.save_all()
msg = f"Configuration saved ({saved} entries)"
if saved == 0:
msg = "Configuration saved (no rows changed)"
self.notify(msg)
self.notify("Saving configuration...", timeout=3)
except Exception as exc:
self.notify(f"Save failed: {exc}", severity="error", timeout=10)
self.refresh_view()
@@ -640,6 +680,27 @@ class ConfigModal(ModalScreen):
self._request_matrix_test()
elif bid == "matrix-rooms-btn":
self._open_matrix_room_picker()
elif bid == "restore-backup-btn":
try:
backup = self._get_last_backup_path()
if not backup:
self.notify("No backups available", severity="warning")
else:
# Ask for confirmation via a simple notification and perform restore
self.notify(f"Restoring {backup.name}...", timeout=2)
self._restore_backup_background(str(backup))
except Exception as exc:
self.notify(f"Restore failed: {exc}", severity="error")
elif bid == "copy-db-btn":
try:
if hasattr(self.app, "copy_to_clipboard"):
self.app.copy_to_clipboard(str(db.db_path))
self.notify("DB path copied to clipboard")
else:
# Fall back to a visible notification
self.notify(str(db.db_path))
except Exception:
self.notify("Failed to copy DB path", severity="warning")
elif bid.startswith("paste-"):
# Programmatic paste button
target_id = bid.replace("paste-", "")
@@ -674,6 +735,65 @@ class ConfigModal(ModalScreen):
else:
self.notify("Clipboard not supported in this terminal", severity="warning")
def _get_last_backup_path(self):
try:
backup_dir = Path(db.db_path).with_name("config_backups")
if not backup_dir.exists():
return None
files = sorted(backup_dir.glob("medios-backup-*.db"), key=lambda p: p.stat().st_mtime, reverse=True)
return files[0] if files else None
except Exception:
return None
@work(thread=True)
def _restore_backup_background(self, backup_path: str) -> None:
try:
import sqlite3, json
cfg = {}
with sqlite3.connect(backup_path) as conn:
cur = conn.cursor()
cur.execute("SELECT category, subtype, item_name, key, value FROM config")
rows = cur.fetchall()
for cat, sub, name, key, val in rows:
try:
parsed = json.loads(val)
except Exception:
parsed = val
if cat == 'global':
cfg[key] = parsed
elif cat in ('provider', 'tool'):
cd = cfg.setdefault(cat, {})
sd = cd.setdefault(sub, {})
sd[key] = parsed
elif cat == 'store':
cd = cfg.setdefault('store', {})
sd = cd.setdefault(sub, {})
nd = sd.setdefault(name, {})
nd[key] = parsed
else:
cfg.setdefault(cat, {})[key] = parsed
# Persist restored config using save_config
from SYS.config import save_config, reload_config
saved = save_config(cfg)
# Reload and update UI from main thread
self.app.call_from_thread(self._on_restore_complete, True, backup_path, saved)
except Exception as exc:
self.app.call_from_thread(self._on_restore_complete, False, backup_path, str(exc))
def _on_restore_complete(self, success: bool, backup_path: str, saved_or_error):
if success:
# Refresh our in-memory view and UI
try:
from SYS.config import reload_config
self.config_data = reload_config()
self.refresh_view()
except Exception:
pass
self.notify(f"Restore complete: re-saved {saved_or_error} entries from {Path(backup_path).name}")
else:
self.notify(f"Restore failed: {saved_or_error}", severity="error")
def on_store_type_selected(self, stype: str) -> None:
if not stype:
return
@@ -847,6 +967,28 @@ class ConfigModal(ModalScreen):
self._update_config_value(widget_id, value)
def _remove_alldebrid_store_entry(self) -> bool:
"""Remove the mirrored AllDebrid store entry that would recreate the provider."""
store_block = self.config_data.get("store")
if not isinstance(store_block, dict):
return False
debrid = store_block.get("debrid")
if not isinstance(debrid, dict):
return False
removed = False
for key in list(debrid.keys()):
if str(key or "").strip().lower() == "all-debrid":
debrid.pop(key, None)
removed = True
if not debrid:
store_block.pop("debrid", None)
if not store_block:
self.config_data.pop("store", None)
return removed
def _get_matrix_provider_block(self) -> Dict[str, Any]:
providers = self.config_data.get("provider")
if not isinstance(providers, dict):
@@ -870,6 +1012,7 @@ class ConfigModal(ModalScreen):
self._synchronize_inputs_to_config()
if self._matrix_status:
self._matrix_status.update("Saving configuration before testing…")
changed = count_changed_entries(self.config_data)
try:
entries = save_config(self.config_data)
except Exception as exc:
@@ -879,7 +1022,7 @@ class ConfigModal(ModalScreen):
return
self.config_data = reload_config()
if self._matrix_status:
self._matrix_status.update(f"Saved configuration ({entries} entries). Testing Matrix connection…")
self._matrix_status.update(f"Saved configuration ({changed} change(s)) to {db.db_path.name}. Testing Matrix connection…")
self._matrix_test_running = True
self._matrix_test_background()
@@ -941,6 +1084,7 @@ class ConfigModal(ModalScreen):
return
matrix_block = self.config_data.setdefault("provider", {}).setdefault("matrix", {})
matrix_block["rooms"] = ", ".join(cleaned)
changed = count_changed_entries(self.config_data)
try:
entries = save_config(self.config_data)
except Exception as exc:
@@ -949,7 +1093,7 @@ class ConfigModal(ModalScreen):
return
self.config_data = reload_config()
if self._matrix_status:
status = f"Saved {len(cleaned)} default room(s) ({entries} rows persisted)."
status = f"Saved {len(cleaned)} default room(s) ({changed} change(s)) to {db.db_path.name}."
self._matrix_status.update(status)
self.refresh_view()
@@ -967,10 +1111,139 @@ class ConfigModal(ModalScreen):
def save_all(self) -> int:
self._synchronize_inputs_to_config()
entries = save_config(self.config_data)
self.config_data = reload_config()
log(f"ConfigModal saved {entries} configuration entries")
return entries
# Compute change count prior to persisting so callers can report the number of
# actual changes rather than the total number of rows written to the DB.
changed = count_changed_entries(self.config_data)
# Snapshot config for background save
snapshot = deepcopy(self.config_data)
# Schedule the save to run on a background worker so the UI doesn't block.
try:
# Prefer Textual's worker when running inside the app
self._save_background(snapshot, changed)
except Exception:
# Fallback: start a plain thread that runs the underlying task body
import threading
func = getattr(self._save_background, "__wrapped__", None)
if func:
thread = threading.Thread(target=lambda: func(self, snapshot, changed), daemon=True)
else:
thread = threading.Thread(target=lambda: self._save_background(snapshot, changed), daemon=True)
thread.start()
# Ensure DB path indicator is current and show saving status
self._db_path = str(db.db_path)
try:
self.query_one("#config-db-path", Static).update(self._db_path)
except Exception:
pass
try:
self.query_one("#config-last-save", Static).update("Last saved: (saving...)")
except Exception:
pass
log(f"ConfigModal scheduled save (changed={changed})")
return changed
@work(thread=True)
def _save_background(self, cfg: Dict[str, Any], changed: int) -> None:
try:
saved_entries = save_config(cfg)
try:
appobj = self.app
except Exception:
appobj = None
if appobj and hasattr(appobj, 'call_from_thread'):
appobj.call_from_thread(self._on_save_complete, True, None, changed, saved_entries)
else:
# If no app (e.g., running under tests), call completion directly
self._on_save_complete(True, None, changed, saved_entries)
except ConfigSaveConflict as exc:
try:
appobj = self.app
except Exception:
appobj = None
if appobj and hasattr(appobj, 'call_from_thread'):
appobj.call_from_thread(self._on_save_complete, False, str(exc), changed, 0)
else:
self._on_save_complete(False, str(exc), changed, 0)
except Exception as exc:
try:
appobj = self.app
except Exception:
appobj = None
if appobj and hasattr(appobj, 'call_from_thread'):
appobj.call_from_thread(self._on_save_complete, False, str(exc), changed, 0)
else:
self._on_save_complete(False, str(exc), changed, 0)
def _on_save_complete(self, success: bool, error: Optional[str], changed: int, saved_entries: int) -> None:
# Safely determine whether a Textual app context is available. Accessing
# `self.app` can raise when not running inside the TUI; handle that.
try:
appobj = self.app
except Exception:
appobj = None
if success:
try:
self.config_data = reload_config()
except Exception:
pass
# Update last-saved label with file timestamp for visibility
db_mtime = None
try:
db_mtime = db.db_path.stat().st_mtime
db_mtime = __import__('datetime').datetime.utcfromtimestamp(db_mtime).isoformat() + "Z"
except Exception:
db_mtime = None
if appobj:
try:
if changed == 0:
label_text = f"Last saved: (no changes)"
else:
label_text = f"Last saved: {changed} change(s) at {db_mtime or '(unknown)'}"
try:
self.query_one("#config-last-save", Static).update(label_text)
except Exception:
pass
except Exception:
pass
try:
self.refresh_view()
except Exception:
pass
try:
self.notify(f"Configuration saved ({changed} change(s)) to {db.db_path.name}", timeout=5)
except Exception:
pass
else:
# No TUI available; log instead of updating UI
log(f"Configuration saved ({changed} change(s)) to {db.db_path.name}")
log(f"ConfigModal saved {saved_entries} configuration entries (changed={changed})")
else:
# Save failed; notify via UI if available, otherwise log
if appobj:
try:
self.notify(f"Save failed: {error}", severity="error", timeout=10)
except Exception:
pass
try:
self.config_data = reload_config()
except Exception:
pass
try:
self.refresh_view()
except Exception:
pass
else:
log(f"Save failed: {error}")
def validate_current_editor(self) -> bool:
"""Ensure all required fields for the current item are filled."""