Files
Jackify/jackify/backend/services/problem_mods_service.py
T

235 lines
7.3 KiB
Python

"""Problem mods service.
Identifies and disables mods with known Proton compatibility issues by
rewriting modlist.txt files atomically.
"""
import json
import logging
import os
import re
import tempfile
from pathlib import Path
from typing import Optional, List
import requests
from jackify.backend.models.game_types import normalize_game_name
from jackify.shared.paths import get_jackify_data_dir
logger = logging.getLogger(__name__)
PROBLEM_MODS_MANIFEST_URL = (
"https://raw.githubusercontent.com/Omni-guides/Jackify/main/manifests/problem_mods.json"
)
_BUNDLED_MANIFEST_PATH = Path(__file__).parent / "problem_mods_manifest.json"
_CANONICAL_TO_MANIFEST_KEY = {
"skyrim": "SkyrimSE",
"skyrimse": "SkyrimSE",
"fallout4": "Fallout4",
"skyrimvr": "SkyrimVR",
"fallout4vr": "Fallout4VR",
"enderal": "Enderal",
}
def _load_bundled_manifest() -> dict:
try:
with open(_BUNDLED_MANIFEST_PATH, "r", encoding="utf-8") as fh:
data = json.load(fh)
if isinstance(data, dict):
return data
except Exception as e:
logger.debug("Bundled problem mods manifest load failed: %s", e)
return {}
_manifest_cache: Optional[dict] = None
def _disk_cache_path() -> Path:
return get_jackify_data_dir() / "manifests" / "problem_mods.json"
def _load_disk_cache() -> Optional[dict]:
path = _disk_cache_path()
try:
with open(path, "r", encoding="utf-8") as fh:
data = json.load(fh)
if isinstance(data, dict):
return data
except Exception:
pass
return None
def _save_disk_cache(data: dict) -> None:
path = _disk_cache_path()
try:
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=".problem_mods_", suffix=".tmp")
with os.fdopen(fd, "w", encoding="utf-8") as fh:
json.dump(data, fh, indent=2)
os.replace(tmp, path)
except Exception as e:
logger.debug("Problem mods manifest disk save failed: %s", e)
def fetch_remote_manifest() -> Optional[dict]:
"""Fetch the remote problem mods manifest. Returns parsed dict or None on failure."""
try:
resp = requests.get(PROBLEM_MODS_MANIFEST_URL, timeout=8, verify=True)
resp.raise_for_status()
data = resp.json()
if isinstance(data, dict):
return data
except Exception as e:
logger.debug("Problem mods manifest fetch failed: %s", e)
return None
def apply_remote_manifest(data: dict) -> None:
"""Store fetched manifest in memory and persist to disk."""
global _manifest_cache
_manifest_cache = data
_save_disk_cache(data)
def _effective_manifest() -> dict:
if _manifest_cache is not None:
return _manifest_cache
disk = _load_disk_cache()
if disk is not None:
return disk
return _load_bundled_manifest()
def _get_mod_fixes(game_type: str) -> List[dict]:
"""Return mod_fixes list for the given game type.
Handles old format (plain list of mod names = disable-only entries) so
cached manifests from before the format change continue to work.
"""
canonical = normalize_game_name(game_type) or game_type.lower().replace(" ", "")
manifest_key = _CANONICAL_TO_MANIFEST_KEY.get(canonical)
if not manifest_key:
return []
raw = _effective_manifest().get(manifest_key, {})
if isinstance(raw, list):
return [{"mod": name, "disable": True} for name in raw]
if isinstance(raw, dict):
return raw.get("mod_fixes", [])
return []
def get_problem_mods(game_type: str) -> List[str]:
"""Return names of mods flagged for disabling for the given game type."""
return [fix["mod"] for fix in _get_mod_fixes(game_type) if fix.get("disable")]
def get_enabled_mods(modlist_txt_path: Path) -> set:
"""Return lowercase set of enabled mod names from a modlist.txt."""
try:
lines = modlist_txt_path.read_text(encoding="utf-8").splitlines()
except Exception:
return set()
mods = set()
for line in lines:
stripped = line.strip()
if stripped.startswith("+"):
name = stripped[1:]
mods.add(name.lower())
bare = re.sub(r'^\[.*?\]\s*', '', name)
if bare != name:
mods.add(bare.lower())
return mods
def create_prefix_dirs(wineprefix: Path, game_type: str, enabled_mods: set) -> List[str]:
"""Create directories inside a Wine prefix for present problem mods.
Only acts when the associated mod is actually enabled in the modlist.
Paths are relative to drive_c inside the prefix.
Returns list of paths created.
"""
created: List[str] = []
for fix in _get_mod_fixes(game_type):
if not fix.get("create_dirs"):
continue
if fix.get("mod", "").lower() not in enabled_mods:
continue
for rel_path in fix["create_dirs"]:
target = wineprefix / "drive_c" / rel_path
if target.exists():
continue
try:
target.mkdir(parents=True, exist_ok=True)
logger.info("Created prefix directory for '%s': %s", fix["mod"], target)
created.append(rel_path)
except Exception as e:
logger.warning("Could not create prefix directory %s: %s", target, e)
return created
def disable_problem_mods(modlist_txt_path: Path, game_type: str) -> List[str]:
"""Disable problem mods in modlist.txt by prepending '-' to enabled entries.
Reads modlist.txt, disables any enabled ('+name') line whose name is in the
problem list, writes the file back atomically. Idempotent: already-disabled
entries are not counted.
Returns list of mod names actually disabled (empty if none).
"""
problem_names = get_problem_mods(game_type)
if not problem_names:
return []
problem_set = {n.lower() for n in problem_names}
try:
content = modlist_txt_path.read_text(encoding="utf-8")
except Exception as e:
logger.warning("Could not read modlist.txt at %s: %s", modlist_txt_path, e)
return []
lines = content.splitlines(keepends=True)
disabled: List[str] = []
new_lines: List[str] = []
for line in lines:
stripped = line.rstrip("\r\n")
if stripped.startswith("+"):
name = stripped[1:]
# Strip leading [tag] prefixes (e.g. "[PC] MCM Booster" -> "MCM Booster")
bare_name = re.sub(r'^\[.*?\]\s*', '', name)
if name.lower() in problem_set or bare_name.lower() in problem_set:
eol = line[len(stripped):]
new_lines.append(f"-{name}{eol}")
disabled.append(name)
continue
new_lines.append(line)
if not disabled:
return []
new_content = "".join(new_lines)
try:
dir_ = modlist_txt_path.parent
fd, tmp_path = tempfile.mkstemp(dir=dir_, prefix=".modlist_", suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fh.write(new_content)
os.replace(tmp_path, modlist_txt_path)
except Exception:
try:
os.unlink(tmp_path)
except OSError:
pass
raise
except Exception as e:
logger.warning("Could not write modlist.txt at %s: %s", modlist_txt_path, e)
return []
return disabled