diff --git a/ansible/vars/mods_ext.yml b/ansible/vars/mods_ext.yml new file mode 100644 index 0000000..fcb41e1 --- /dev/null +++ b/ansible/vars/mods_ext.yml @@ -0,0 +1,10 @@ +mods: + - slug: fabric-api + source: modrinth + category: core + - slug: cloth-config + source: modrinth + category: config + - slug: spark + source: modrinth + category: monitoring diff --git a/readme.md b/readme.md index 9a51e51..8686a42 100644 --- a/readme.md +++ b/readme.md @@ -61,3 +61,11 @@ - 자세한 과정은 [Notion 문서](https://www.notion.so/MC-2241afe72e6980da8b2ac86e0bcf270e)를 참고하실 수 있습니다. +### 모드 관리 도구 +`scripts/mod_manager.py`를 사용하면 모드 목록(`ansible/vars/mods_ext.yml`)을 기반으로 +다운로드 링크와 체크섬이 포함된 `mods.yml`을 자동으로 생성할 수 있습니다. +간단한 웹 UI를 보려면 다음을 실행합니다. +```bash +streamlit run scripts/mod_manager.py gui +``` + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..fa7e587 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +requests +PyYAML +streamlit diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/mod_manager.py b/scripts/mod_manager.py new file mode 100755 index 0000000..bfbf0b9 --- /dev/null +++ b/scripts/mod_manager.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Manage Minecraft mods defined in YAML files. + +This script fetches mod data from Modrinth and generates the Ansible +``mods.yml`` list with download URLs and SHA256 checksums. +Optionally a minimal GUI using Streamlit can display the current list. +""" +from __future__ import annotations + +import argparse +import hashlib +from pathlib import Path +from typing import Any, Dict, List + +import requests +import yaml + +BASE_URL = "https://api.modrinth.com/v2" +EXTENDED_PATH = Path("ansible/vars/mods_ext.yml") +OUTPUT_PATH = Path("ansible/vars/mods.yml") +VERSION_PATH = Path("ansible/vars/versions.yml") + + +def load_yaml(path: Path) -> Any: + with path.open("r", encoding="utf-8") as f: + return yaml.safe_load(f) + + +def save_yaml(path: Path, data: Any) -> None: + with path.open("w", encoding="utf-8") as f: + yaml.dump(data, f, sort_keys=False) + + +def fetch_modrinth_version(slug: str, game_version: str) -> Dict[str, Any]: + """Return download info for the latest version of a Modrinth project.""" + project = requests.get(f"{BASE_URL}/project/{slug}").json() + project_id = project["id"] + params = { + "loaders": ["fabric"], + "game_versions": [game_version], + "limit": 1, + } + versions = requests.get( + f"{BASE_URL}/project/{project_id}/version", params=params + ).json() + if not versions: + raise ValueError(f"No version found for {slug} on {game_version}") + version = versions[0] + file_info = version["files"][0] + return { + "name": file_info["filename"], + "url": file_info["url"], + } + + +def sha256_from_url(url: str) -> str: + """Download ``url`` and return ``sha256:``.""" + h = hashlib.sha256() + with requests.get(url, stream=True) as resp: + resp.raise_for_status() + for chunk in resp.iter_content(chunk_size=8192): + h.update(chunk) + return "sha256:" + h.hexdigest() + + +def generate_mods() -> None: + versions = load_yaml(VERSION_PATH) + data = load_yaml(EXTENDED_PATH) + game_version = versions["afabric_mc_version"] + mods: List[Dict[str, str]] = [] + for mod in data.get("mods", []): + if mod.get("source") != "modrinth": + continue + info = fetch_modrinth_version(mod["slug"], game_version) + checksum = sha256_from_url(info["url"]) + mods.append( + { + "name": info["name"], + "url": info["url"], + "checksum": checksum, + } + ) + save_yaml(OUTPUT_PATH, {"fabric_mods": mods}) + print(f"Wrote {OUTPUT_PATH} with {len(mods)} entries") + + +def list_mods() -> None: + data = load_yaml(EXTENDED_PATH) + for mod in data.get("mods", []): + cat = mod.get("category", "unknown") + print(f"{mod['slug']}: {cat}") + + +def run_gui() -> None: + import streamlit as st + + data = load_yaml(EXTENDED_PATH) + st.title("Minecraft Mod Manager") + st.write("Mods defined in", EXTENDED_PATH) + for mod in data.get("mods", []): + st.write(f"**{mod['slug']}** - {mod.get('category', 'unknown')}") + + +def main(argv: List[str] | None = None) -> None: + parser = argparse.ArgumentParser(description="Manage mod list") + sub = parser.add_subparsers(dest="cmd", required=True) + sub.add_parser("generate", help="Generate mods.yml from mods_ext.yml") + sub.add_parser("list", help="List mods with categories") + sub.add_parser("gui", help="Launch simple web interface") + args = parser.parse_args(argv) + + if args.cmd == "generate": + generate_mods() + elif args.cmd == "list": + list_mods() + elif args.cmd == "gui": + run_gui() + + +if __name__ == "__main__": + main() diff --git a/tests/test_mod_manager.py b/tests/test_mod_manager.py new file mode 100644 index 0000000..d8bfbe0 --- /dev/null +++ b/tests/test_mod_manager.py @@ -0,0 +1,34 @@ +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import scripts.mod_manager as mm # noqa: E402 + + +class DummyResp: + def __init__(self, content: bytes): + self._content = content + + def iter_content(self, chunk_size=8192): + yield self._content + + def raise_for_status(self): + pass + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + pass + + +def test_sha256_from_url(monkeypatch): + def fake_get(url, stream=False): + return DummyResp(b"test") + + monkeypatch.setattr(mm.requests, "get", fake_get) + expected = ( + "sha256:9f86d081884c7d659a2feaa0c55ad015" + "a3bf4f1b2b0b822cd15d6c15b0f00a08" + ) + assert mm.sha256_from_url("http://example.com") == expected