hh
This commit is contained in:
421
config.py
421
config.py
@@ -1,19 +1,271 @@
|
||||
|
||||
"""Unified configuration helpers for downlow."""
|
||||
"""Unified configuration helpers.
|
||||
|
||||
Configuration is defined exclusively via the modular `.conf` format.
|
||||
|
||||
- Required: `temp`
|
||||
- Optional: stores, providers, and other settings
|
||||
- Modular: optional fragments in `config.d/*.conf` are merged in lexicographic order
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
from pathlib import Path
|
||||
from SYS.logger import log
|
||||
|
||||
DEFAULT_CONFIG_FILENAME = "config.json"
|
||||
DEFAULT_CONFIG_FILENAME = "config.conf"
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
|
||||
_CONFIG_CACHE: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
|
||||
def _strip_inline_comment(line: str) -> str:
|
||||
# Keep it simple: only strip full-line comments and inline comments that start after whitespace.
|
||||
# Users can always quote values that contain '#' or ';'.
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
return ""
|
||||
if stripped.startswith("#") or stripped.startswith(";"):
|
||||
return ""
|
||||
return line
|
||||
|
||||
|
||||
def _parse_scalar(value: str) -> Any:
|
||||
v = value.strip()
|
||||
if not v:
|
||||
return ""
|
||||
|
||||
if (v.startswith('"') and v.endswith('"')) or (v.startswith("'") and v.endswith("'")):
|
||||
return v[1:-1]
|
||||
|
||||
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
|
||||
|
||||
|
||||
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]) -> 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
|
||||
|
||||
|
||||
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="C:\\Users\\Me\\Downloads"
|
||||
- Sections: [store=folder] + name/path lines
|
||||
- Sections: [store=hydrusnetwork] + name/access key/url lines
|
||||
- Sections: [provider=OpenLibrary] + email/password lines
|
||||
- Dotted keys: store.folder.default.path="C:\\Media" (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()
|
||||
else:
|
||||
# Unknown header style; ignore block
|
||||
current_kind = None
|
||||
current_subtype = None
|
||||
continue
|
||||
|
||||
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
|
||||
|
||||
|
||||
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 _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"}:
|
||||
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)}")
|
||||
for k in sorted(block.keys()):
|
||||
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}]")
|
||||
for k in sorted(block.keys()):
|
||||
lines.append(f"{k}={_format_conf_value(block.get(k))}")
|
||||
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
|
||||
|
||||
def _make_cache_key(config_dir: Optional[Path], filename: str, actual_path: Optional[Path]) -> str:
|
||||
if actual_path:
|
||||
return str(actual_path.resolve())
|
||||
@@ -37,7 +289,7 @@ def get_hydrus_instance(config: Dict[str, Any], instance_name: str = "home") ->
|
||||
Returns:
|
||||
Dict with access key and URL, or None if not found
|
||||
"""
|
||||
# Try current format first: config["store"]["hydrusnetwork"]["home"]
|
||||
# Canonical: config["store"]["hydrusnetwork"]["home"]
|
||||
store = config.get("store", {})
|
||||
if isinstance(store, dict):
|
||||
hydrusnetwork = store.get("hydrusnetwork", {})
|
||||
@@ -45,35 +297,14 @@ def get_hydrus_instance(config: Dict[str, Any], instance_name: str = "home") ->
|
||||
instance = hydrusnetwork.get(instance_name)
|
||||
if isinstance(instance, dict):
|
||||
return instance
|
||||
|
||||
# Try legacy format: config["storage"]["hydrus"]
|
||||
storage = config.get("storage", {})
|
||||
if isinstance(storage, dict):
|
||||
hydrus_config = storage.get("hydrus", {})
|
||||
if isinstance(hydrus_config, dict):
|
||||
instance = hydrus_config.get(instance_name)
|
||||
if isinstance(instance, dict):
|
||||
return instance
|
||||
|
||||
# Fall back to old format: config["HydrusNetwork"]
|
||||
hydrus_network = config.get("HydrusNetwork")
|
||||
if not isinstance(hydrus_network, dict):
|
||||
return None
|
||||
|
||||
instance = hydrus_network.get(instance_name)
|
||||
if isinstance(instance, dict):
|
||||
return instance
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_hydrus_access_key(config: Dict[str, Any], instance_name: str = "home") -> Optional[str]:
|
||||
"""Get Hydrus access key for an instance.
|
||||
|
||||
Supports multiple formats:
|
||||
- Current: config["store"]["hydrusnetwork"][name]["Hydrus-Client-API-Access-Key"]
|
||||
- Legacy: config["storage"]["hydrus"][name]["key"]
|
||||
- Old: config["HydrusNetwork_Access_Key"]
|
||||
Config format:
|
||||
- config["store"]["hydrusnetwork"][name]["API"]
|
||||
|
||||
Args:
|
||||
config: Configuration dict
|
||||
@@ -84,26 +315,17 @@ def get_hydrus_access_key(config: Dict[str, Any], instance_name: str = "home") -
|
||||
"""
|
||||
instance = get_hydrus_instance(config, instance_name)
|
||||
if instance:
|
||||
# Try current format key name
|
||||
key = instance.get("Hydrus-Client-API-Access-Key")
|
||||
if key:
|
||||
return str(key).strip()
|
||||
# Try legacy key name
|
||||
key = instance.get("key")
|
||||
if key:
|
||||
return str(key).strip()
|
||||
|
||||
# Fall back to old flat format
|
||||
key = config.get("HydrusNetwork_Access_Key")
|
||||
return str(key).strip() if key else None
|
||||
key = instance.get("API")
|
||||
return str(key).strip() if key else None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_hydrus_url(config: Dict[str, Any], instance_name: str = "home") -> Optional[str]:
|
||||
"""Get Hydrus URL for an instance.
|
||||
|
||||
Supports both old flat format and new nested format:
|
||||
- Old: config["HydrusNetwork_URL"] or constructed from IP/Port/HTTPS
|
||||
- New: config["HydrusNetwork"][instance_name]["url"]
|
||||
Config format:
|
||||
- config["store"]["hydrusnetwork"][name]["URL"]
|
||||
|
||||
Args:
|
||||
config: Configuration dict
|
||||
@@ -113,15 +335,8 @@ def get_hydrus_url(config: Dict[str, Any], instance_name: str = "home") -> Optio
|
||||
URL string, or None if not found
|
||||
"""
|
||||
instance = get_hydrus_instance(config, instance_name)
|
||||
url = instance.get("url") if instance else config.get("HydrusNetwork_URL")
|
||||
if url: # Check if not None and not empty
|
||||
return str(url).strip()
|
||||
# Build from IP/Port/HTTPS if not found
|
||||
host = str(config.get("HydrusNetwork_IP") or "localhost").strip() or "localhost"
|
||||
port = str(config.get("HydrusNetwork_Port") or "45869").strip()
|
||||
scheme = "https" if str(config.get("HydrusNetwork_Use_HTTPS") or "").strip().lower() in {"1", "true", "yes", "on"} else "http"
|
||||
authority = host if not (":" in host and not host.startswith("[")) else f"[{host}]"
|
||||
return f"{scheme}://{authority}:{port}"
|
||||
url = instance.get("URL") if instance else None
|
||||
return str(url).strip() if url else None
|
||||
|
||||
|
||||
|
||||
@@ -205,10 +420,10 @@ def get_local_storage_path(config: Dict[str, Any]) -> Optional[Path]:
|
||||
|
||||
def get_debrid_api_key(config: Dict[str, Any], service: str = "All-debrid") -> Optional[str]:
|
||||
"""Get Debrid API key from config.
|
||||
|
||||
Supports both formats:
|
||||
- New: config["storage"]["debrid"]["All-debrid"]
|
||||
- Old: config["Debrid"]["All-debrid"]
|
||||
|
||||
Config format:
|
||||
- config["store"]["debrid"][<name>]["api_key"]
|
||||
where <name> is the store name (e.g. "all-debrid")
|
||||
|
||||
Args:
|
||||
config: Configuration dict
|
||||
@@ -217,21 +432,23 @@ def get_debrid_api_key(config: Dict[str, Any], service: str = "All-debrid") -> O
|
||||
Returns:
|
||||
API key string if found, None otherwise
|
||||
"""
|
||||
# Try new format first
|
||||
storage = config.get("storage", {})
|
||||
if isinstance(storage, dict):
|
||||
debrid_config = storage.get("debrid", {})
|
||||
if isinstance(debrid_config, dict):
|
||||
api_key = debrid_config.get(service)
|
||||
if api_key: # Check if not None and not empty
|
||||
return str(api_key).strip() if api_key else None
|
||||
|
||||
# Fall back to old format
|
||||
debrid_config = config.get("Debrid", {})
|
||||
if isinstance(debrid_config, dict):
|
||||
api_key = debrid_config.get(service)
|
||||
if api_key: # Check if not None and not empty
|
||||
return str(api_key).strip() if api_key else None
|
||||
store = config.get("store", {})
|
||||
if not isinstance(store, dict):
|
||||
return None
|
||||
|
||||
debrid_config = store.get("debrid", {})
|
||||
if not isinstance(debrid_config, dict):
|
||||
return None
|
||||
|
||||
service_key = str(service).strip().lower()
|
||||
entry = debrid_config.get(service_key)
|
||||
|
||||
if isinstance(entry, dict):
|
||||
api_key = entry.get("api_key")
|
||||
return str(api_key).strip() if api_key else None
|
||||
|
||||
if isinstance(entry, str):
|
||||
return entry.strip() or None
|
||||
|
||||
return None
|
||||
|
||||
@@ -273,7 +490,7 @@ def get_provider_credentials(config: Dict[str, Any], provider: str) -> Optional[
|
||||
|
||||
|
||||
def resolve_cookies_path(config: Dict[str, Any], script_dir: Optional[Path] = None) -> Optional[Path]:
|
||||
value = config.get("cookies") or config.get("Cookies_Path")
|
||||
value = config.get("cookies")
|
||||
if value:
|
||||
candidate = Path(str(value)).expanduser()
|
||||
if candidate.is_file():
|
||||
@@ -300,43 +517,18 @@ def load_config(config_dir: Optional[Path] = None, filename: str = DEFAULT_CONFI
|
||||
if cache_key in _CONFIG_CACHE:
|
||||
return _CONFIG_CACHE[cache_key]
|
||||
|
||||
try:
|
||||
raw = config_path.read_text(encoding="utf-8")
|
||||
except FileNotFoundError:
|
||||
# Try alternate filename if default not found
|
||||
if filename == DEFAULT_CONFIG_FILENAME:
|
||||
alt_path = base_dir / "downlow.json"
|
||||
try:
|
||||
raw = alt_path.read_text(encoding="utf-8")
|
||||
config_path = alt_path
|
||||
cache_key = _make_cache_key(config_dir, filename, alt_path)
|
||||
except FileNotFoundError:
|
||||
_CONFIG_CACHE[cache_key] = {}
|
||||
return {}
|
||||
except OSError as exc:
|
||||
log(f"Failed to read {alt_path}: {exc}")
|
||||
_CONFIG_CACHE[cache_key] = {}
|
||||
return {}
|
||||
else:
|
||||
_CONFIG_CACHE[cache_key] = {}
|
||||
return {}
|
||||
except OSError as exc:
|
||||
log(f"Failed to read {config_path}: {exc}")
|
||||
if config_path.suffix.lower() != ".conf":
|
||||
log(f"Unsupported config format: {config_path.name} (only .conf is supported)")
|
||||
_CONFIG_CACHE[cache_key] = {}
|
||||
return {}
|
||||
|
||||
raw = raw.strip()
|
||||
if not raw:
|
||||
_CONFIG_CACHE[cache_key] = {}
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
log(f"Invalid JSON in {config_path}: {exc}")
|
||||
data = _load_conf_config(base_dir, config_path)
|
||||
except FileNotFoundError:
|
||||
_CONFIG_CACHE[cache_key] = {}
|
||||
return {}
|
||||
if not isinstance(data, dict):
|
||||
log(f"Expected object in {config_path}, got {type(data).__name__}")
|
||||
except OSError as exc:
|
||||
log(f"Failed to read {config_path}: {exc}")
|
||||
_CONFIG_CACHE[cache_key] = {}
|
||||
return {}
|
||||
|
||||
@@ -360,25 +552,12 @@ def save_config(
|
||||
) -> None:
|
||||
base_dir = config_dir or SCRIPT_DIR
|
||||
config_path = base_dir / filename
|
||||
|
||||
# Load existing config to preserve keys that aren't being changed
|
||||
|
||||
if config_path.suffix.lower() != ".conf":
|
||||
raise RuntimeError(f"Unsupported config format: {config_path.name} (only .conf is supported)")
|
||||
|
||||
try:
|
||||
existing_raw = config_path.read_text(encoding="utf-8")
|
||||
existing_data = json.loads(existing_raw.strip())
|
||||
if isinstance(existing_data, dict):
|
||||
# Merge: existing config as base, then overlay with new config
|
||||
merged = existing_data.copy()
|
||||
merged.update(config)
|
||||
config = merged
|
||||
except (FileNotFoundError, OSError, json.JSONDecodeError):
|
||||
# File doesn't exist or is invalid, use provided config as-is
|
||||
pass
|
||||
|
||||
try:
|
||||
config_path.write_text(
|
||||
json.dumps(config, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
config_path.write_text(_serialize_conf(config), encoding="utf-8")
|
||||
except OSError as exc:
|
||||
raise RuntimeError(f"Failed to write config to {config_path}: {exc}") from exc
|
||||
|
||||
|
||||
Reference in New Issue
Block a user