This commit is contained in:
2026-01-22 04:08:07 -08:00
parent 88a537d9f7
commit cd7f03e592
4 changed files with 238 additions and 487 deletions

View File

@@ -39,321 +39,78 @@ def clear_config_cache() -> None:
_CONFIG_CACHE.clear()
def _strip_inline_comment(line: str) -> str:
# Strip comments in a way that's friendly to common .conf usage:
# - Full-line comments starting with '#' or ';'
# - Inline comments starting with '#' or ';' *outside quotes*
# (e.g. dtype="float16" # optional)
stripped = line.strip()
if not stripped:
return ""
if stripped.startswith("#") or stripped.startswith(";"):
return ""
in_single = False
in_double = False
for i, ch in enumerate(line):
if ch == "'" and not in_double:
in_single = not in_single
continue
if ch == '"' and not in_single:
in_double = not in_double
continue
if in_single or in_double:
continue
if ch in {"#", ";"}:
# Treat as a comment start only when preceded by whitespace.
# This keeps values like paths or tokens containing '#' working
# when quoted, and reduces surprises for unquoted values.
if i == 0 or line[i - 1].isspace():
return line[:i].rstrip()
return line
def reload_config(
config_dir: Optional[Path] = None, filename: str = "medios.db"
) -> Dict[str, Any]:
_CONFIG_CACHE.pop("db_config", None)
return load_config(config_dir=config_dir, filename=filename)
def _parse_scalar(value: str) -> Any:
v = value.strip()
if not v:
return ""
def load_config(
config_dir: Optional[Path] = None, filename: str = "medios.db"
) -> Dict[str, Any]:
# We no longer use config_dir or filename for the config file itself,
# but we keep them in the signature for backward compatibility.
cache_key = "db_config"
if cache_key in _CONFIG_CACHE:
return _CONFIG_CACHE[cache_key]
if (v.startswith('"') and v.endswith('"')) or (v.startswith("'") and v.endswith("'")):
return v[1:-1]
# Load from database
try:
from SYS.database import get_config_all
db_config = get_config_all()
if db_config:
_CONFIG_CACHE[cache_key] = db_config
return db_config
except Exception:
pass
low = v.lower()
if low in {"true", "yes", "on", "1"}:
return True
if low in {"false", "no", "off", "0"}:
return False
if re.fullmatch(r"-?\d+", v):
try:
return int(v)
except Exception:
return v
if re.fullmatch(r"-?\d+\.\d+", v):
try:
return float(v)
except Exception:
return v
return v
return {}
def _set_nested(d: Dict[str, Any], dotted_key: str, value: Any) -> None:
parts = [p for p in dotted_key.split(".") if p]
if not parts:
return
cur: Dict[str, Any] = d
for p in parts[:-1]:
nxt = cur.get(p)
if not isinstance(nxt, dict):
nxt = {}
cur[p] = nxt
cur = nxt
cur[parts[-1]] = value
def _merge_dict_inplace(base: Dict[str, Any], patch: Dict[str, Any]) -> Dict[str, Any]:
for k, v in patch.items():
if isinstance(v, dict) and isinstance(base.get(k), dict):
_merge_dict_inplace(base[k], v) # type: ignore[index]
else:
base[k] = v
return base
def _apply_conf_block(
config: Dict[str, Any], kind: str, subtype: str, block: Dict[str, Any]
def save_config(
config: Dict[str, Any],
config_dir: Optional[Path] = None,
filename: str = "medios.db",
) -> None:
kind_l = str(kind).strip().lower()
subtype_l = str(subtype).strip().lower()
if kind_l == "store":
# Store instances are keyed by NAME (preferred). If a block uses `name=...`,
# normalize it into NAME to keep a single canonical key.
name = block.get("NAME")
if not name:
name = block.get("name")
if name:
block = dict(block)
block.pop("name", None)
block["NAME"] = name
if not name:
return
name_l = str(name).strip().lower()
payload = dict(block)
store = config.setdefault("store", {})
if not isinstance(store, dict):
config["store"] = {}
store = config["store"]
bucket = store.setdefault(subtype_l, {})
if not isinstance(bucket, dict):
store[subtype_l] = {}
bucket = store[subtype_l]
existing = bucket.get(name_l)
if isinstance(existing, dict):
_merge_dict_inplace(existing, payload)
else:
bucket[name_l] = payload
return
if kind_l == "provider":
provider_name = str(subtype).strip().lower()
provider = config.setdefault("provider", {})
if not isinstance(provider, dict):
config["provider"] = {}
provider = config["provider"]
existing = provider.get(provider_name)
if isinstance(existing, dict):
_merge_dict_inplace(existing, block)
else:
provider[provider_name] = dict(block)
return
if kind_l == "tool":
tool_name = str(subtype).strip().lower()
if not tool_name:
return
tool = config.setdefault("tool", {})
if not isinstance(tool, dict):
config["tool"] = {}
tool = config["tool"]
existing = tool.get(tool_name)
if isinstance(existing, dict):
_merge_dict_inplace(existing, block)
else:
tool[tool_name] = dict(block)
return
return
def parse_conf_text(text: str, *, base: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""Parse a lightweight .conf format into the app's config dict.
Supported patterns:
- Top-level key/value: temp="./temp"
- Sections: [store=hydrusnetwork] + name/access key/url lines
- Sections: [provider=OpenLibrary] + email/password lines
- Dotted keys: store.hydrusnetwork.<name>.url="http://..." (optional)
"""
config: Dict[str, Any] = dict(base or {})
current_kind: Optional[str] = None
current_subtype: Optional[str] = None
current_block: Dict[str, Any] = {}
def flush() -> None:
nonlocal current_kind, current_subtype, current_block
if current_kind and current_subtype and current_block:
_apply_conf_block(config, current_kind, current_subtype, current_block)
current_kind = None
current_subtype = None
current_block = {}
for raw_line in text.splitlines():
line = _strip_inline_comment(raw_line)
if not line.strip():
continue
stripped = line.strip()
if stripped.startswith("[") and stripped.endswith("]"):
flush()
header = stripped[1:-1].strip()
if "=" in header:
k, v = header.split("=", 1)
current_kind = k.strip()
current_subtype = v.strip()
"""Persist configuration to the database."""
try:
from SYS.database import save_config_value
for key, value in config.items():
if key in ('store', 'provider', 'tool'):
if isinstance(value, dict):
for subtype, instances in value.items():
if isinstance(instances, dict):
# provider/tool are usually config[cat][subtype][key]
# but store is config['store'][subtype][name][key]
if key == 'store':
for name, settings in instances.items():
if isinstance(settings, dict):
for k, v in settings.items():
save_config_value(key, subtype, name, k, v)
else:
for k, v in instances.items():
save_config_value(key, subtype, "default", k, v)
else:
# Unknown header style; ignore block
current_kind = None
current_subtype = None
continue
# global settings
if not key.startswith("_"):
save_config_value("global", "none", "none", key, value)
except Exception as e:
log(f"Failed to save config to database: {e}")
if "=" not in stripped:
continue
key, value = stripped.split("=", 1)
key = key.strip()
parsed_val = _parse_scalar(value)
if current_kind and current_subtype:
current_block[key] = parsed_val
else:
if "." in key:
_set_nested(config, key, parsed_val)
else:
config[key] = parsed_val
flush()
return config
_CONFIG_CACHE["db_config"] = config
def _load_conf_config(base_dir: Path, config_path: Path) -> Dict[str, Any]:
config: Dict[str, Any] = {}
raw = config_path.read_text(encoding="utf-8")
config = parse_conf_text(raw, base=config)
conf_dir = base_dir / "config.d"
if conf_dir.exists() and conf_dir.is_dir():
for frag in sorted(conf_dir.glob("*.conf")):
try:
frag_raw = frag.read_text(encoding="utf-8")
config = parse_conf_text(frag_raw, base=config)
except OSError as exc:
log(f"Failed to read {frag}: {exc}")
return config
def load() -> Dict[str, Any]:
"""Return the parsed configuration from database."""
return load_config()
def _format_conf_value(val: Any) -> str:
if isinstance(val, bool):
return "true" if val else "false"
if isinstance(val, (int, float)):
return str(val)
if val is None:
return '""'
s = str(val)
s = s.replace('"', '\\"')
return f'"{s}"'
def _serialize_conf(config: Dict[str, Any]) -> str:
lines: list[str] = []
# Top-level scalars first
for key in sorted(config.keys()):
if key in {"store", "provider", "tool", "networking"}:
continue
value = config.get(key)
if isinstance(value, dict):
continue
lines.append(f"{key}={_format_conf_value(value)}")
# Store blocks
store = config.get("store")
if isinstance(store, dict):
for subtype in sorted(store.keys()):
bucket = store.get(subtype)
if not isinstance(bucket, dict):
continue
for name in sorted(bucket.keys()):
block = bucket.get(name)
if not isinstance(block, dict):
continue
lines.append("")
lines.append(f"[store={subtype}]")
lines.append(f"name={_format_conf_value(name)}")
# Deduplicate keys case-insensitively and skip "name"
seen_keys = {"NAME", "name"}
for k in sorted(block.keys()):
k_upper = k.upper()
if k_upper in seen_keys:
continue
seen_keys.add(k_upper)
lines.append(f"{k}={_format_conf_value(block.get(k))}")
# Provider blocks
provider = config.get("provider")
if isinstance(provider, dict):
for prov in sorted(provider.keys()):
block = provider.get(prov)
if not isinstance(block, dict):
continue
lines.append("")
lines.append(f"[provider={prov}]")
seen_keys = set()
for k in sorted(block.keys()):
k_upper = k.upper()
if k_upper in seen_keys:
continue
seen_keys.add(k_upper)
lines.append(f"{k}={_format_conf_value(block.get(k))}")
# Tool blocks
tool = config.get("tool")
if isinstance(tool, dict):
for name in sorted(tool.keys()):
block = tool.get(name)
if not isinstance(block, dict):
continue
lines.append("")
lines.append(f"[tool={name}]")
seen_keys = set()
for k in sorted(block.keys()):
k_upper = k.upper()
if k_upper in seen_keys:
continue
seen_keys.add(k_upper)
lines.append(f"{k}={_format_conf_value(block.get(k))}")
return "\n".join(lines).rstrip() + "\n"
def save(config: Dict[str, Any]) -> None:
"""Persist *config* back to database."""
save_config(config)
def _make_cache_key(config_dir: Optional[Path], filename: str, actual_path: Optional[Path]) -> str: