Initial release: full Arma 3 mod management toolchain
Pipeline: parse HTML presets, compare modlists, download from Caddy file server, create junctions/symlinks to Arma 3 Server directory. Includes update/sync flows, missing-mod reporting, OS compat layer, shared config, dep checker, comprehensive test suite (71 tests). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
33
.gitignore
vendored
Normal file
33
.gitignore
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
# Credentials — never commit
|
||||
config.json
|
||||
|
||||
# Generated output
|
||||
modlist_json/
|
||||
downloads/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.eggs/
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
.venv/
|
||||
env/
|
||||
|
||||
# Editor / OS
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Claude Code local settings
|
||||
.claude/
|
||||
514
README.md
Normal file
514
README.md
Normal file
@@ -0,0 +1,514 @@
|
||||
# arma-modlist-tools
|
||||
|
||||
Python toolchain for managing Arma 3 mod presets: parse launcher exports, compare presets, download mods from a Caddy file server, and create junction/symlink links to an Arma 3 Server installation.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Prerequisites](#prerequisites)
|
||||
2. [Installation](#installation)
|
||||
3. [Configuration](#configuration)
|
||||
4. [Quick Start — Full Pipeline](#quick-start--full-pipeline)
|
||||
5. [Individual Scripts](#individual-scripts)
|
||||
- [check_deps.py](#check_depspy)
|
||||
- [parse_modlist.py](#parse_modlistpy)
|
||||
- [compare_modlists.py](#compare_modlistspy)
|
||||
- [fetch_mods.py](#fetch_modspy)
|
||||
- [link_mods.py](#link_modspy)
|
||||
- [report_missing.py](#report_missingpy)
|
||||
- [sync_missing.py](#sync_missingpy)
|
||||
- [update_mods.py](#update_modspy)
|
||||
- [run.py](#runpy)
|
||||
6. [Folder Structure](#folder-structure)
|
||||
7. [Moving to a New Device](#moving-to-a-new-device)
|
||||
8. [Running Tests](#running-tests)
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Requirement | Version | Notes |
|
||||
|-------------|---------|-------|
|
||||
| Python | >= 3.11 | `python --version` |
|
||||
| requests | any | `pip install requests` |
|
||||
| tqdm | any | `pip install tqdm` |
|
||||
| Windows or Linux | — | Windows uses junctions, Linux uses symlinks |
|
||||
|
||||
Run the dep checker to confirm everything is ready:
|
||||
|
||||
```
|
||||
python check_deps.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Clone the repo
|
||||
git clone https://git.revoluxiant.io.vn/revernomad17/arma-modlist-tools.git
|
||||
cd arma-modlist-tools
|
||||
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Copy config template and fill in your credentials/paths
|
||||
cp config.template.json config.json
|
||||
```
|
||||
|
||||
Edit `config.json` — see [Configuration](#configuration) below.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
All scripts read a single `config.json` in the project root. Copy the template and fill in your values:
|
||||
|
||||
```json
|
||||
{
|
||||
"server": {
|
||||
"base_url": "https://your-caddy-server/arma3mods/",
|
||||
"username": "your_username",
|
||||
"password": "your_password"
|
||||
},
|
||||
"paths": {
|
||||
"arma_dir": "C:\\Path\\To\\Arma 3 Server",
|
||||
"downloads": "downloads",
|
||||
"modlist_html": "modlist_html",
|
||||
"modlist_json": "modlist_json"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Key | Description |
|
||||
|-----|-------------|
|
||||
| `server.base_url` | Root URL of your Caddy file server. Must have a trailing slash. |
|
||||
| `server.username` | HTTP Basic Auth username. |
|
||||
| `server.password` | HTTP Basic Auth password. |
|
||||
| `paths.arma_dir` | Absolute path to the Arma 3 Server directory where links will be created. |
|
||||
| `paths.downloads` | Where mod files are downloaded locally. Relative to project root. |
|
||||
| `paths.modlist_html` | Folder containing exported Arma 3 Launcher preset `.html` files. |
|
||||
| `paths.modlist_json` | Folder where parsed/compared JSON files are saved. |
|
||||
|
||||
> **Note:** `config.json` is in `.gitignore` because it contains credentials. Never commit it.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start — Full Pipeline
|
||||
|
||||
Place your Arma 3 Launcher preset exports (`.html`) into the `modlist_html/` folder, then run:
|
||||
|
||||
```bash
|
||||
python run.py
|
||||
```
|
||||
|
||||
This runs all four steps in sequence:
|
||||
|
||||
```
|
||||
Step 1/4: Parse presets — modlist_html/*.html -> modlist_json/*.json
|
||||
Step 2/4: Compare presets — produces modlist_json/comparison.json
|
||||
Step 3/4: Fetch mods — downloads from server -> downloads/
|
||||
Step 4/4: Link mods — creates junctions/symlinks in Arma 3 Server dir
|
||||
```
|
||||
|
||||
### Skip flags
|
||||
|
||||
```bash
|
||||
python run.py --skip-fetch --skip-link # parse + compare only
|
||||
python run.py --skip-parse --skip-compare --skip-fetch # link only
|
||||
python run.py --skip-parse --skip-compare --skip-fetch --group shared # link one group only
|
||||
```
|
||||
|
||||
| Flag | Skips |
|
||||
|------|-------|
|
||||
| `--skip-parse` | Step 1 (HTML parsing) |
|
||||
| `--skip-compare` | Step 2 (preset comparison) |
|
||||
| `--skip-fetch` | Step 3 (downloading) |
|
||||
| `--skip-link` | Step 4 (linking) |
|
||||
| `--group GROUP` | Link step: only link this one group (e.g. `shared`) |
|
||||
|
||||
---
|
||||
|
||||
## Individual Scripts
|
||||
|
||||
### check_deps.py
|
||||
|
||||
Verify Python version and required packages before running anything else.
|
||||
|
||||
```bash
|
||||
python check_deps.py
|
||||
```
|
||||
|
||||
```
|
||||
Python 3.11.9 OK
|
||||
OS Windows
|
||||
|
||||
requests 2.33.0 OK
|
||||
tqdm 4.67.3 OK
|
||||
|
||||
All checks passed. Ready to run.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### parse_modlist.py
|
||||
|
||||
Parse all `.html` preset exports in `modlist_html/` and save each as JSON in `modlist_json/`.
|
||||
|
||||
**How to get the HTML files:** In Arma 3 Launcher → Mods → Preset → right-click → Export to HTML. Place the exported files in `modlist_html/`.
|
||||
|
||||
```bash
|
||||
python parse_modlist.py
|
||||
```
|
||||
|
||||
```
|
||||
150th_MW_2026_v1.0.html -> modlist_json/150th_MW_2026_v1.0.json (52 mods)
|
||||
150th_WW2_2026_V1.0.html -> modlist_json/150th_WW2_2026_V1.0.json (48 mods)
|
||||
```
|
||||
|
||||
Output JSON per preset:
|
||||
```json
|
||||
{
|
||||
"preset_name": "150th_MW_2026_v1.0",
|
||||
"source_file": "150th_MW_2026_v1.0.html",
|
||||
"mod_count": 52,
|
||||
"mods": [
|
||||
{
|
||||
"name": "CBA_A3",
|
||||
"source": "steam",
|
||||
"url": "https://steamcommunity.com/sharedfiles/filedetails/?id=450814997",
|
||||
"steam_id": "450814997"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### compare_modlists.py
|
||||
|
||||
Compare all presets in `modlist_html/` and produce `modlist_json/comparison.json` with a shared/unique breakdown.
|
||||
|
||||
```bash
|
||||
python compare_modlists.py
|
||||
```
|
||||
|
||||
```
|
||||
Compared: 150th_MW_2026_v1.0, 150th_WW2_2026_V1.0
|
||||
Shared mods : 28
|
||||
Unique to 150th_MW... : 24
|
||||
Unique to 150th_WW2...: 20
|
||||
-> modlist_json/comparison.json
|
||||
```
|
||||
|
||||
Output `comparison.json` structure:
|
||||
```json
|
||||
{
|
||||
"compared_presets": ["Preset_A", "Preset_B"],
|
||||
"shared": {
|
||||
"mod_count": 28,
|
||||
"mods": [...]
|
||||
},
|
||||
"unique": {
|
||||
"Preset_A": { "mod_count": 24, "mods": [...] },
|
||||
"Preset_B": { "mod_count": 20, "mods": [...] }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Requires:** at least 2 `.html` files in `modlist_html/`.
|
||||
|
||||
---
|
||||
|
||||
### fetch_mods.py
|
||||
|
||||
Download all mods from the Caddy file server into `downloads/`, organized by group.
|
||||
|
||||
```bash
|
||||
python fetch_mods.py
|
||||
```
|
||||
|
||||
```
|
||||
Loaded comparison: 150th_MW_2026_v1.0, 150th_WW2_2026_V1.0
|
||||
Total mods to consider: 72
|
||||
|
||||
Building server index... 87 mods indexed
|
||||
|
||||
[1/70] @ace -> downloads/shared/@ace/ (group: shared)
|
||||
addons/ace_common/... 5.2 MB [████████████] 100%
|
||||
...
|
||||
Done 42 downloaded 8.2 MB
|
||||
|
||||
Overwrite existing? [s]kip / [o]verwrite: s
|
||||
```
|
||||
|
||||
- Mods shared across all presets go into `downloads/shared/`
|
||||
- Preset-unique mods go into `downloads/<preset_name>/`
|
||||
- Saves `modlist_json/missing_report.json` listing any mods not found on server
|
||||
- Prompts once if destination folders already exist
|
||||
|
||||
**Requires:** `modlist_json/comparison.json` (run `compare_modlists.py` first).
|
||||
|
||||
---
|
||||
|
||||
### link_mods.py
|
||||
|
||||
Manage junction/symlink links between `downloads/` and the Arma 3 Server directory.
|
||||
|
||||
#### Check status
|
||||
|
||||
```bash
|
||||
python link_mods.py status --group shared
|
||||
```
|
||||
|
||||
```
|
||||
Group : shared
|
||||
Path : downloads/shared
|
||||
Arma : C:\...\Arma 3 Server
|
||||
|
||||
Mod Status
|
||||
----------------------------------------------------------
|
||||
@ace [LINKED]
|
||||
@cba_a3 [------]
|
||||
|
||||
1 / 2 linked
|
||||
```
|
||||
|
||||
#### Create links
|
||||
|
||||
```bash
|
||||
python link_mods.py link --group shared
|
||||
```
|
||||
|
||||
```
|
||||
Linking group: shared -> C:\...\Arma 3 Server
|
||||
|
||||
[+] @ace linked
|
||||
[=] @cba_a3 already linked
|
||||
```
|
||||
|
||||
#### Remove links
|
||||
|
||||
```bash
|
||||
python link_mods.py unlink --group shared
|
||||
```
|
||||
|
||||
Prompts for confirmation before removing links. Removing a link does **not** delete the mod files in `downloads/`.
|
||||
|
||||
#### List available groups
|
||||
|
||||
Omit `--group` to see all available groups:
|
||||
|
||||
```bash
|
||||
python link_mods.py status
|
||||
```
|
||||
|
||||
> **Windows note:** Creates NTFS directory junctions (`mklink /J`). No administrator rights required.
|
||||
> **Linux note:** Creates standard symlinks (`os.symlink`).
|
||||
|
||||
---
|
||||
|
||||
### report_missing.py
|
||||
|
||||
Check which mods from `comparison.json` are missing from the file server. Saves `modlist_json/missing_report.json`.
|
||||
|
||||
```bash
|
||||
python report_missing.py
|
||||
```
|
||||
|
||||
```
|
||||
Checking server index... 87 mods indexed
|
||||
Cross-referencing 72 required mods...
|
||||
|
||||
Missing from server (2):
|
||||
|
||||
steam_id Group Name
|
||||
--------------- ----------------------------- ----------------------------------------
|
||||
2648308937 150th_WW2_2026_V1.0 IFA3 AIO
|
||||
463939057 shared ACE3
|
||||
|
||||
70 / 72 found on server
|
||||
Report saved: modlist_json/missing_report.json
|
||||
```
|
||||
|
||||
**Requires:** `modlist_json/comparison.json`.
|
||||
|
||||
---
|
||||
|
||||
### sync_missing.py
|
||||
|
||||
Re-check the server for mods that were previously missing and download any that have since been added.
|
||||
|
||||
```bash
|
||||
python sync_missing.py
|
||||
```
|
||||
|
||||
```
|
||||
Loading missing report: 2 mods previously missing
|
||||
Re-checking server index... 89 mods indexed
|
||||
|
||||
Newly available: 1 mods
|
||||
Still missing : 1 mods
|
||||
|
||||
[+] @ace -> downloads/shared/
|
||||
Done 8.2 MB
|
||||
|
||||
Missing report updated: 1 still missing
|
||||
|
||||
Linking newly added mods...
|
||||
shared 1 new linked, 27 already linked
|
||||
```
|
||||
|
||||
**Flow:**
|
||||
1. Loads `modlist_json/missing_report.json`
|
||||
2. Re-checks server index for newly available mods
|
||||
3. Downloads newly available mods to the correct group folder
|
||||
4. Updates `missing_report.json` (removes mods now downloaded)
|
||||
5. Runs linker for affected groups — existing links are safely skipped
|
||||
|
||||
**Requires:** `modlist_json/missing_report.json` (run `report_missing.py` or `fetch_mods.py` first).
|
||||
|
||||
---
|
||||
|
||||
### update_mods.py
|
||||
|
||||
Re-download mod files that have changed on the server without changing the modlist structure (same mods, updated file contents/versions).
|
||||
|
||||
Detection uses **file size comparison**: a file is considered stale if it is missing locally or its local size differs from the server-reported size.
|
||||
|
||||
```bash
|
||||
python update_mods.py # check all groups and mods
|
||||
python update_mods.py --group shared # check one group only
|
||||
python update_mods.py --mod @ace # check one specific mod
|
||||
python update_mods.py --force # re-download all files regardless of size
|
||||
python update_mods.py --force --group shared
|
||||
```
|
||||
|
||||
```
|
||||
Building server index... 87 mods indexed
|
||||
|
||||
Mode: size-check
|
||||
Checking 28 mod folder(s)...
|
||||
|
||||
[=] @cba_a3 shared 4 files up-to-date
|
||||
[+] @ace shared 42 files 3 updated (8.2 MB)
|
||||
[=] @rhsusaf 150th_MW 88 files up-to-date
|
||||
|
||||
Total: 134 files checked, 3 updated, 8.2 MB downloaded
|
||||
```
|
||||
|
||||
> **No re-linking needed.** Junctions/symlinks already point at the `downloads/` folders, so updated files are immediately visible to the Arma 3 Server.
|
||||
|
||||
---
|
||||
|
||||
### run.py
|
||||
|
||||
Orchestrator that chains all four pipeline steps. Described in [Quick Start](#quick-start--full-pipeline) above.
|
||||
|
||||
---
|
||||
|
||||
## Folder Structure
|
||||
|
||||
```
|
||||
arma-modlist-tools/
|
||||
|
|
||||
|- arma_modlist_tools/ # Python package (library code)
|
||||
| |- __init__.py # Public exports
|
||||
| |- parser.py # HTML preset parser
|
||||
| |- compare.py # Preset comparison
|
||||
| |- fetcher.py # Caddy server downloader
|
||||
| |- linker.py # Junction/symlink manager
|
||||
| |- reporter.py # Missing-mod report builder
|
||||
| |- config.py # config.json loader
|
||||
| |- compat.py # OS detection + encoding fix
|
||||
|
|
||||
|- modlist_html/ # INPUT: put your .html preset exports here
|
||||
| |- MyPreset_A.html
|
||||
| |- MyPreset_B.html
|
||||
|
|
||||
|- modlist_json/ # OUTPUT: generated JSON files (gitignored)
|
||||
| |- MyPreset_A.json
|
||||
| |- MyPreset_B.json
|
||||
| |- comparison.json
|
||||
| |- missing_report.json
|
||||
|
|
||||
|- downloads/ # OUTPUT: downloaded mod files (gitignored)
|
||||
| |- shared/
|
||||
| | |- @ace/
|
||||
| | |- @cba_a3/
|
||||
| |- MyPreset_A/
|
||||
| |- @rhsusaf/
|
||||
|
|
||||
|- config.json # YOUR config (gitignored — contains credentials)
|
||||
|- config.template.json # Template to copy from
|
||||
|- requirements.txt
|
||||
|
|
||||
|- run.py # Orchestrator (parse + compare + fetch + link)
|
||||
|- parse_modlist.py # Step 1 standalone
|
||||
|- compare_modlists.py # Step 2 standalone
|
||||
|- fetch_mods.py # Step 3 standalone
|
||||
|- link_mods.py # Link management (status/link/unlink)
|
||||
|- report_missing.py # Missing mod report
|
||||
|- sync_missing.py # Sync newly available missing mods
|
||||
|- update_mods.py # Re-download updated mod files
|
||||
|- check_deps.py # Dependency checker
|
||||
|- test_suite.py # Test suite
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Moving to a New Device
|
||||
|
||||
1. **Clone the repo** on the new device:
|
||||
```bash
|
||||
git clone https://git.revoluxiant.io.vn/revernomad17/arma-modlist-tools.git
|
||||
cd arma-modlist-tools
|
||||
```
|
||||
|
||||
2. **Install dependencies:**
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
3. **Create your config:**
|
||||
```bash
|
||||
cp config.template.json config.json
|
||||
# Edit config.json with correct arma_dir and server credentials
|
||||
```
|
||||
|
||||
4. **Add your preset exports:**
|
||||
Copy your `.html` files from the Arma 3 Launcher into `modlist_html/`.
|
||||
|
||||
5. **Run the full pipeline:**
|
||||
```bash
|
||||
python check_deps.py # verify everything is ready
|
||||
python run.py # parse → compare → fetch → link
|
||||
```
|
||||
|
||||
> The `downloads/` folder can be large (several GB of mod files). On a second device you can either let `run.py` re-download everything, or copy the `downloads/` folder manually and run `python run.py --skip-fetch` to skip the download step and just create links.
|
||||
|
||||
---
|
||||
|
||||
## Running Tests
|
||||
|
||||
The test suite covers all modules with 71 tests. No network connection required.
|
||||
|
||||
```bash
|
||||
python test_suite.py
|
||||
```
|
||||
|
||||
```
|
||||
------------------------------------------------------------
|
||||
compat 6 tests
|
||||
config 5 tests
|
||||
parser 9 tests
|
||||
compare 8 tests
|
||||
fetcher 19 tests (pure functions, no network)
|
||||
reporter 8 tests
|
||||
linker 12 tests (uses temp dirs)
|
||||
__init__ 2 tests
|
||||
integration 2 tests
|
||||
------------------------------------------------------------
|
||||
Results: 71 passed, 0 failed, 0 skipped (71 total)
|
||||
```
|
||||
32
arma_modlist_tools/__init__.py
Normal file
32
arma_modlist_tools/__init__.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from .parser import parse_mod_entry, parse_modlist_html, parse_modlist_dir
|
||||
from .compare import compare_presets
|
||||
from .fetcher import (
|
||||
make_session, build_server_index, find_mod_folder,
|
||||
list_mod_files, list_mod_updates, download_file, download_mod_folder,
|
||||
)
|
||||
from .linker import (
|
||||
get_mod_folders, get_link_status, create_junction,
|
||||
remove_junction, link_group, unlink_group,
|
||||
)
|
||||
from .config import load_config, Config
|
||||
from .compat import is_windows, is_linux, get_os_label, fix_console_encoding
|
||||
from .reporter import build_missing_report, save_missing_report
|
||||
|
||||
__all__ = [
|
||||
# parser
|
||||
"parse_mod_entry", "parse_modlist_html", "parse_modlist_dir",
|
||||
# compare
|
||||
"compare_presets",
|
||||
# fetcher
|
||||
"make_session", "build_server_index", "find_mod_folder",
|
||||
"list_mod_files", "list_mod_updates", "download_file", "download_mod_folder",
|
||||
# linker
|
||||
"get_mod_folders", "get_link_status", "create_junction",
|
||||
"remove_junction", "link_group", "unlink_group",
|
||||
# config
|
||||
"load_config", "Config",
|
||||
# compat
|
||||
"is_windows", "is_linux", "get_os_label", "fix_console_encoding",
|
||||
# reporter
|
||||
"build_missing_report", "save_missing_report",
|
||||
]
|
||||
79
arma_modlist_tools/compare.py
Normal file
79
arma_modlist_tools/compare.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""
|
||||
arma_modlist_tools.compare
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Compare two or more Arma 3 mod presets (parsed by :mod:`arma_modlist_tools.parser`)
|
||||
and produce a breakdown of shared and preset-unique mods.
|
||||
|
||||
Typical usage::
|
||||
|
||||
from arma_modlist_tools.parser import parse_modlist_dir
|
||||
from arma_modlist_tools.compare import compare_presets
|
||||
|
||||
presets = parse_modlist_dir("modlist_html")
|
||||
result = compare_presets(*presets)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def _mod_key(mod: dict) -> str:
|
||||
"""Return the identity key for a mod.
|
||||
|
||||
Uses ``steam_id`` when available (canonical Workshop identifier),
|
||||
falls back to ``name`` for local mods that have no Workshop ID.
|
||||
"""
|
||||
return mod["steam_id"] or mod["name"]
|
||||
|
||||
|
||||
def compare_presets(*presets: dict) -> dict:
|
||||
"""
|
||||
Compare two or more preset dicts and return a comparison dict.
|
||||
|
||||
:param presets: Two or more preset dicts as returned by
|
||||
:func:`~arma_modlist_tools.parser.parse_modlist_html`.
|
||||
:returns: Dict with keys:
|
||||
|
||||
- ``compared_presets`` — list of preset names that were compared
|
||||
- ``shared`` — mods present in **every** preset
|
||||
- ``mod_count`` — number of shared mods
|
||||
- ``mods`` — list of mod entry dicts
|
||||
- ``unique`` — per-preset mods not present in any other preset
|
||||
- keyed by ``preset_name``
|
||||
- each value has ``mod_count`` and ``mods``
|
||||
|
||||
:raises ValueError: If fewer than two presets are provided.
|
||||
"""
|
||||
if len(presets) < 2:
|
||||
raise ValueError("compare_presets requires at least two presets")
|
||||
|
||||
# Build per-preset {identity_key -> mod_entry} mappings
|
||||
preset_maps: list[dict[str, dict]] = [
|
||||
{_mod_key(mod): mod for mod in preset["mods"]}
|
||||
for preset in presets
|
||||
]
|
||||
|
||||
# Shared keys = intersection across ALL presets
|
||||
shared_keys: set[str] = set(preset_maps[0].keys())
|
||||
for pm in preset_maps[1:]:
|
||||
shared_keys &= pm.keys()
|
||||
|
||||
# Shared mods: take entries from the first preset (identical across all)
|
||||
shared_mods = [preset_maps[0][k] for k in preset_maps[0] if k in shared_keys]
|
||||
|
||||
# Unique mods per preset: entries whose key is not in the shared set
|
||||
unique: dict[str, dict] = {}
|
||||
for preset, pm in zip(presets, preset_maps):
|
||||
unique_mods = [mod for k, mod in pm.items() if k not in shared_keys]
|
||||
unique[preset["preset_name"]] = {
|
||||
"mod_count": len(unique_mods),
|
||||
"mods": unique_mods,
|
||||
}
|
||||
|
||||
return {
|
||||
"compared_presets": [p["preset_name"] for p in presets],
|
||||
"shared": {
|
||||
"mod_count": len(shared_mods),
|
||||
"mods": shared_mods,
|
||||
},
|
||||
"unique": unique,
|
||||
}
|
||||
108
arma_modlist_tools/compat.py
Normal file
108
arma_modlist_tools/compat.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""
|
||||
arma_modlist_tools.compat
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
OS detection and cross-platform utilities shared by all CLI scripts.
|
||||
|
||||
Supported platforms:
|
||||
- Windows / Windows Server (sys.platform == "win32")
|
||||
- Ubuntu / Ubuntu Server (sys.platform == "linux")
|
||||
|
||||
Typical usage::
|
||||
|
||||
from arma_modlist_tools.compat import is_windows, get_os_label, fix_console_encoding
|
||||
|
||||
fix_console_encoding() # call once at script start on Windows
|
||||
print(get_os_label()) # "Windows Server", "Ubuntu", etc.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import platform
|
||||
import sys
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Platform detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def is_windows() -> bool:
|
||||
"""Return ``True`` on Windows and Windows Server."""
|
||||
return sys.platform == "win32"
|
||||
|
||||
|
||||
def is_linux() -> bool:
|
||||
"""Return ``True`` on Linux (Ubuntu, Ubuntu Server, and other distros)."""
|
||||
return sys.platform == "linux"
|
||||
|
||||
|
||||
def get_os_label() -> str:
|
||||
"""
|
||||
Return a human-readable OS label.
|
||||
|
||||
Possible values: ``"Windows"``, ``"Windows Server"``, ``"Ubuntu"``,
|
||||
``"Ubuntu Server"``, ``"Linux"``, ``"Unknown"``.
|
||||
"""
|
||||
if is_windows():
|
||||
ver = platform.version()
|
||||
# Windows Server versions contain "Server" in the version string
|
||||
# e.g. "10.0.17763 ... Windows Server 2019 ..."
|
||||
if "Server" in platform.version() or "Server" in platform.uname().version:
|
||||
return "Windows Server"
|
||||
return "Windows"
|
||||
|
||||
if is_linux():
|
||||
# Read /etc/os-release for distro name
|
||||
os_release = _read_os_release()
|
||||
name = os_release.get("NAME", "").lower()
|
||||
|
||||
if "ubuntu" in name:
|
||||
# Distinguish desktop vs server: server images have no display server
|
||||
if _is_headless():
|
||||
return "Ubuntu Server"
|
||||
return "Ubuntu"
|
||||
|
||||
return "Linux"
|
||||
|
||||
return "Unknown"
|
||||
|
||||
|
||||
def _read_os_release() -> dict[str, str]:
|
||||
"""Parse /etc/os-release into a dict (Linux only)."""
|
||||
result: dict[str, str] = {}
|
||||
try:
|
||||
with open("/etc/os-release", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if "=" in line and not line.startswith("#"):
|
||||
k, _, v = line.partition("=")
|
||||
result[k] = v.strip('"')
|
||||
except OSError:
|
||||
pass
|
||||
return result
|
||||
|
||||
|
||||
def _is_headless() -> bool:
|
||||
"""Return True if no graphical display server is detected (headless/server)."""
|
||||
import os
|
||||
# Check for common display environment variables
|
||||
return not (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Console encoding
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def fix_console_encoding() -> None:
|
||||
"""
|
||||
Force UTF-8 output on Windows terminals that default to cp1252.
|
||||
|
||||
Call once at the top of any CLI script that uses Unicode characters
|
||||
(checkmarks, arrows, etc.). No-op on Linux.
|
||||
"""
|
||||
if not is_windows():
|
||||
return
|
||||
if sys.stdout.encoding and sys.stdout.encoding.lower() == "utf-8":
|
||||
return
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
|
||||
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
|
||||
109
arma_modlist_tools/config.py
Normal file
109
arma_modlist_tools/config.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""
|
||||
arma_modlist_tools.config
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Load and expose project configuration from ``config.json``.
|
||||
|
||||
Search order for the config file:
|
||||
1. Explicit path passed to :func:`load_config`
|
||||
2. ``config.json`` in the current working directory
|
||||
3. ``config.json`` two levels above this module (project root)
|
||||
|
||||
Typical usage::
|
||||
|
||||
from arma_modlist_tools.config import load_config
|
||||
|
||||
cfg = load_config()
|
||||
print(cfg.server_url)
|
||||
print(cfg.arma_dir)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class Config:
|
||||
"""Typed wrapper around the parsed ``config.json`` dict."""
|
||||
|
||||
def __init__(self, data: dict) -> None:
|
||||
# Validate required keys immediately so callers get a clear error at
|
||||
# load time rather than a confusing AttributeError deep in the pipeline.
|
||||
_ = data["server"]["base_url"]
|
||||
_ = data["server"]["username"]
|
||||
_ = data["server"]["password"]
|
||||
_ = data["paths"]["arma_dir"]
|
||||
_ = data["paths"]["downloads"]
|
||||
_ = data["paths"]["modlist_html"]
|
||||
_ = data["paths"]["modlist_json"]
|
||||
self._data = data
|
||||
|
||||
# ---- server ----
|
||||
|
||||
@property
|
||||
def server_url(self) -> str:
|
||||
return self._data["server"]["base_url"]
|
||||
|
||||
@property
|
||||
def server_auth(self) -> tuple[str, str]:
|
||||
return (self._data["server"]["username"], self._data["server"]["password"])
|
||||
|
||||
# ---- paths ----
|
||||
|
||||
@property
|
||||
def arma_dir(self) -> Path:
|
||||
return Path(self._data["paths"]["arma_dir"])
|
||||
|
||||
@property
|
||||
def downloads(self) -> Path:
|
||||
return Path(self._data["paths"]["downloads"])
|
||||
|
||||
@property
|
||||
def modlist_html(self) -> Path:
|
||||
return Path(self._data["paths"]["modlist_html"])
|
||||
|
||||
@property
|
||||
def modlist_json(self) -> Path:
|
||||
return Path(self._data["paths"]["modlist_json"])
|
||||
|
||||
# ---- derived paths ----
|
||||
|
||||
@property
|
||||
def comparison(self) -> Path:
|
||||
return self.modlist_json / "comparison.json"
|
||||
|
||||
@property
|
||||
def missing_report(self) -> Path:
|
||||
return self.modlist_json / "missing_report.json"
|
||||
|
||||
|
||||
def load_config(path: Path | str | None = None) -> Config:
|
||||
"""
|
||||
Load ``config.json`` and return a :class:`Config` instance.
|
||||
|
||||
:param path: Explicit path to the config file. If ``None``, the function
|
||||
searches the current working directory then the project root.
|
||||
:raises FileNotFoundError: If no config file can be located.
|
||||
:raises KeyError: If required keys are absent from the config file.
|
||||
"""
|
||||
if path is not None:
|
||||
config_path = Path(path)
|
||||
else:
|
||||
# Try CWD first, then project root (two levels above this file)
|
||||
cwd_path = Path.cwd() / "config.json"
|
||||
root_path = Path(__file__).parent.parent / "config.json"
|
||||
if cwd_path.exists():
|
||||
config_path = cwd_path
|
||||
elif root_path.exists():
|
||||
config_path = root_path
|
||||
else:
|
||||
raise FileNotFoundError(
|
||||
"config.json not found. "
|
||||
f"Looked in:\n {cwd_path}\n {root_path}\n"
|
||||
"Create config.json in the project root (copy from the template)."
|
||||
)
|
||||
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
return Config(data)
|
||||
268
arma_modlist_tools/fetcher.py
Normal file
268
arma_modlist_tools/fetcher.py
Normal file
@@ -0,0 +1,268 @@
|
||||
"""
|
||||
arma_modlist_tools.fetcher
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Download Arma 3 mods from a Caddy file server using a comparison dict as input.
|
||||
|
||||
The server is expected to host mods as ``@ModName/`` folders under a base URL,
|
||||
with a ``meta.cpp`` file inside each folder containing the Steam Workshop ID::
|
||||
|
||||
publishedid = 463939057;
|
||||
|
||||
Typical usage::
|
||||
|
||||
from arma_modlist_tools.fetcher import (
|
||||
make_session, build_server_index, find_mod_folder,
|
||||
list_mod_files, download_file, download_mod_folder,
|
||||
)
|
||||
|
||||
session = make_session(("user", "password"))
|
||||
index = build_server_index("https://example.com/arma3mods/", ("user", "pass"))
|
||||
url = find_mod_folder({"steam_id": "463939057", "name": "ace"}, index)
|
||||
files = list_mod_files(url, session)
|
||||
download_mod_folder(url, Path("downloads/shared/@ace"), session)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
_CHUNK_SIZE = 64 * 1024 # 64 KB per read
|
||||
_META_CPP_RE = re.compile(r"publishedid\s*=\s*(\d+)", re.IGNORECASE)
|
||||
_NON_ALNUM_RE = re.compile(r"[^a-z0-9]")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _list_dir(url: str, session: requests.Session) -> list[dict]:
|
||||
"""
|
||||
Fetch a Caddy browse directory listing as JSON.
|
||||
Caddy returns a list of ``{name, size, url, is_dir, ...}`` dicts when the
|
||||
``Accept: application/json`` header is sent.
|
||||
"""
|
||||
resp = session.get(url, headers={"Accept": "application/json"}, timeout=30)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
# Caddy v2 returns a plain list; guard against wrapped responses
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
return data.get("items", [])
|
||||
|
||||
|
||||
def _parse_meta_cpp(text: str) -> str | None:
|
||||
"""Extract ``publishedid`` from a ``meta.cpp`` file, or return ``None``."""
|
||||
m = _META_CPP_RE.search(text)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def _normalize_name(name: str) -> str:
|
||||
"""Strip leading ``@``, lowercase, remove all non-alphanumeric characters."""
|
||||
return _NON_ALNUM_RE.sub("", name.lower().lstrip("@"))
|
||||
|
||||
|
||||
def _folder_url(base: str, name: str) -> str:
|
||||
"""Build a canonical trailing-slash folder URL."""
|
||||
return base.rstrip("/") + "/" + name.strip("/") + "/"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def make_session(auth: tuple[str, str]) -> requests.Session:
|
||||
"""Return a ``requests.Session`` pre-configured with basic auth credentials."""
|
||||
s = requests.Session()
|
||||
s.auth = auth
|
||||
return s
|
||||
|
||||
|
||||
def build_server_index(base_url: str, auth: tuple[str, str]) -> dict:
|
||||
"""
|
||||
Scan the root of the file server and build mod lookup maps.
|
||||
|
||||
For every ``@...`` folder found at *base_url*, the function attempts to
|
||||
fetch ``meta.cpp`` to extract the Steam Workshop ID.
|
||||
|
||||
:param base_url: Root URL of the Caddy file server (trailing slash optional).
|
||||
:param auth: ``(username, password)`` tuple for HTTP Basic Auth.
|
||||
:returns: Dict with keys:
|
||||
|
||||
- ``by_steam_id`` — ``{steam_id: folder_url}``
|
||||
- ``by_name`` — ``{normalized_name: folder_url}``
|
||||
- ``folders`` — raw list of item dicts from the root listing
|
||||
"""
|
||||
session = make_session(auth)
|
||||
root = base_url.rstrip("/") + "/"
|
||||
items = _list_dir(root, session)
|
||||
folders = [it for it in items if it.get("is_dir")]
|
||||
|
||||
by_steam_id: dict[str, str] = {}
|
||||
by_name: dict[str, str] = {}
|
||||
|
||||
for folder in folders:
|
||||
name = folder["name"].strip("/")
|
||||
url = _folder_url(root, name)
|
||||
by_name[_normalize_name(name)] = url
|
||||
|
||||
try:
|
||||
resp = session.get(url + "meta.cpp", timeout=10)
|
||||
if resp.ok:
|
||||
sid = _parse_meta_cpp(resp.text)
|
||||
if sid:
|
||||
by_steam_id[sid] = url
|
||||
except requests.RequestException:
|
||||
pass # meta.cpp missing or unreachable — name-based fallback still works
|
||||
|
||||
return {"by_steam_id": by_steam_id, "by_name": by_name, "folders": folders}
|
||||
|
||||
|
||||
def find_mod_folder(mod: dict, index: dict) -> str | None:
|
||||
"""
|
||||
Return the server folder URL for a mod entry, or ``None`` if not found.
|
||||
|
||||
Lookup order:
|
||||
|
||||
1. ``steam_id`` → ``index["by_steam_id"]`` (exact, reliable)
|
||||
2. Normalized ``name`` → ``index["by_name"]`` (fuzzy fallback for local mods)
|
||||
|
||||
:param mod: Mod entry dict with at least ``"steam_id"`` and ``"name"`` keys.
|
||||
:param index: Index dict returned by :func:`build_server_index`.
|
||||
"""
|
||||
if mod.get("steam_id"):
|
||||
url = index["by_steam_id"].get(mod["steam_id"])
|
||||
if url:
|
||||
return url
|
||||
return index["by_name"].get(_normalize_name(mod.get("name", "")))
|
||||
|
||||
|
||||
def list_mod_files(
|
||||
folder_url: str,
|
||||
session: requests.Session,
|
||||
) -> list[tuple[str, str, int]]:
|
||||
"""
|
||||
Recursively list all files under a mod folder on the server.
|
||||
|
||||
:returns: List of ``(relative_path, absolute_url, size_bytes)`` tuples,
|
||||
where *relative_path* is relative to *folder_url*.
|
||||
"""
|
||||
return _walk(folder_url.rstrip("/") + "/", session, "")
|
||||
|
||||
|
||||
def list_mod_updates(
|
||||
folder_url: str,
|
||||
dest_path: Path,
|
||||
session: requests.Session,
|
||||
) -> list[tuple[str, str, int]]:
|
||||
"""
|
||||
Return only the files that are missing locally or whose local size differs
|
||||
from the server size. Files that exist and match the server size are
|
||||
considered up-to-date and omitted.
|
||||
|
||||
Use this to detect which files need to be re-downloaded after the server
|
||||
has been updated without changing the modlist structure.
|
||||
|
||||
:param folder_url: Server folder URL for the mod (e.g. ``https://…/@ace/``).
|
||||
:param dest_path: Local destination directory for this mod.
|
||||
:param session: Authenticated ``requests.Session``.
|
||||
:returns: Subset of :func:`list_mod_files` results — ``(rel_path, url, size)``.
|
||||
"""
|
||||
stale = []
|
||||
for rel, url, server_size in list_mod_files(folder_url, session):
|
||||
local = dest_path / rel
|
||||
if not local.exists():
|
||||
stale.append((rel, url, server_size))
|
||||
elif server_size and local.stat().st_size != server_size:
|
||||
stale.append((rel, url, server_size))
|
||||
return stale
|
||||
|
||||
|
||||
def _walk(url: str, session: requests.Session, prefix: str) -> list[tuple[str, str, int]]:
|
||||
items = _list_dir(url, session)
|
||||
result = []
|
||||
for item in items:
|
||||
name = item["name"].strip("/")
|
||||
rel = (prefix + "/" + name).lstrip("/")
|
||||
item_url = url.rstrip("/") + "/" + name
|
||||
if item.get("is_dir"):
|
||||
result.extend(_walk(item_url + "/", session, rel))
|
||||
else:
|
||||
result.append((rel, item_url, item.get("size", 0)))
|
||||
return result
|
||||
|
||||
|
||||
def download_file(
|
||||
url: str,
|
||||
dest: Path,
|
||||
session: requests.Session,
|
||||
on_chunk: Callable[[int], None] | None = None,
|
||||
) -> int:
|
||||
"""
|
||||
Stream-download a single file to *dest*.
|
||||
|
||||
:param on_chunk: Optional callback ``(bytes_written)`` called after each
|
||||
chunk is flushed to disk.
|
||||
:returns: Total bytes written.
|
||||
"""
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
resp = session.get(url, stream=True, timeout=120)
|
||||
resp.raise_for_status()
|
||||
written = 0
|
||||
with open(dest, "wb") as fh:
|
||||
for chunk in resp.iter_content(chunk_size=_CHUNK_SIZE):
|
||||
if chunk:
|
||||
fh.write(chunk)
|
||||
written += len(chunk)
|
||||
if on_chunk:
|
||||
on_chunk(len(chunk))
|
||||
return written
|
||||
|
||||
|
||||
def download_mod_folder(
|
||||
folder_url: str,
|
||||
dest_path: Path,
|
||||
session: requests.Session,
|
||||
overwrite: bool = False,
|
||||
on_file: Callable[[str, int, bool], None] | None = None,
|
||||
on_chunk: Callable[[int], None] | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Recursively download all files in a mod folder.
|
||||
|
||||
:param folder_url: Server folder URL (must be browsable by Caddy).
|
||||
:param dest_path: Local destination directory (created if necessary).
|
||||
:param session: Authenticated ``requests.Session``.
|
||||
:param overwrite: If ``False``, existing files are skipped.
|
||||
:param on_file: ``(rel_path, size_bytes, is_skipped)`` — called before
|
||||
each file, whether it will be downloaded or skipped.
|
||||
:param on_chunk: ``(bytes)`` — called per chunk **only** for files that
|
||||
are actually downloaded (not skipped).
|
||||
:returns: ``{"files_downloaded": n, "files_skipped": n, "bytes_downloaded": n}``
|
||||
"""
|
||||
files = list_mod_files(folder_url, session)
|
||||
downloaded = skipped = total_bytes = 0
|
||||
|
||||
for rel, url, size in files:
|
||||
dest_file = dest_path / rel
|
||||
is_skipped = dest_file.exists() and not overwrite
|
||||
|
||||
if on_file:
|
||||
on_file(rel, size, is_skipped)
|
||||
|
||||
if is_skipped:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
n = download_file(url, dest_file, session, on_chunk=on_chunk)
|
||||
total_bytes += n
|
||||
downloaded += 1
|
||||
|
||||
return {
|
||||
"files_downloaded": downloaded,
|
||||
"files_skipped": skipped,
|
||||
"bytes_downloaded": total_bytes,
|
||||
}
|
||||
188
arma_modlist_tools/linker.py
Normal file
188
arma_modlist_tools/linker.py
Normal file
@@ -0,0 +1,188 @@
|
||||
"""
|
||||
arma_modlist_tools.linker
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Manage directory links between downloaded mod folders and the Arma 3 Server
|
||||
directory. Works on both Windows (junction links) and Linux (symlinks).
|
||||
|
||||
Platform behaviour:
|
||||
- **Windows**: junctions via ``cmd /c mklink /J`` — no admin rights required.
|
||||
- **Linux**: symlinks via ``os.symlink()`` — standard directory symlinks.
|
||||
|
||||
Typical usage::
|
||||
|
||||
from arma_modlist_tools.linker import get_link_status, link_group, unlink_group
|
||||
from pathlib import Path
|
||||
|
||||
arma = Path("/opt/arma3server")
|
||||
group = Path("downloads/shared")
|
||||
|
||||
status = get_link_status(group, arma)
|
||||
result = link_group(group, arma)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from .compat import is_windows
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _is_junction(path: Path) -> bool:
|
||||
"""
|
||||
Return ``True`` if *path* is an active directory junction / symlink.
|
||||
|
||||
- **Windows**: checks ``FILE_ATTRIBUTE_REPARSE_POINT`` (0x400) in
|
||||
``os.lstat().st_file_attributes``. ``os.path.islink()`` is unreliable
|
||||
for junctions on Windows.
|
||||
- **Linux**: ``os.path.islink()`` correctly identifies symlinks.
|
||||
"""
|
||||
try:
|
||||
if is_windows():
|
||||
s = os.lstat(str(path))
|
||||
attrs = getattr(s, "st_file_attributes", 0)
|
||||
return bool(attrs & 0x400) # FILE_ATTRIBUTE_REPARSE_POINT
|
||||
else:
|
||||
return os.path.islink(str(path))
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_mod_folders(group_dir: Path) -> list[Path]:
|
||||
"""
|
||||
Return a sorted list of ``@*`` subdirectories inside *group_dir*.
|
||||
|
||||
:param group_dir: The mod category folder (e.g. ``downloads/shared``).
|
||||
"""
|
||||
if not group_dir.is_dir():
|
||||
return []
|
||||
return sorted(
|
||||
p for p in group_dir.iterdir()
|
||||
if p.is_dir() and p.name.startswith("@")
|
||||
)
|
||||
|
||||
|
||||
def get_link_status(group_dir: Path, arma_dir: Path) -> list[dict]:
|
||||
"""
|
||||
Return the link status for every ``@Mod`` folder in *group_dir*.
|
||||
|
||||
:returns: List of dicts with keys:
|
||||
|
||||
- ``name`` — folder name (e.g. ``@ace``)
|
||||
- ``source_path`` — absolute path of the mod folder
|
||||
- ``link_path`` — where the link would/does live in *arma_dir*
|
||||
- ``is_linked`` — ``True`` if a junction/symlink currently exists
|
||||
"""
|
||||
result = []
|
||||
for mod in get_mod_folders(group_dir):
|
||||
link_path = arma_dir / mod.name
|
||||
result.append({
|
||||
"name": mod.name,
|
||||
"source_path": mod.resolve(),
|
||||
"link_path": link_path,
|
||||
"is_linked": _is_junction(link_path),
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def create_junction(link_path: Path, target: Path) -> bool:
|
||||
"""
|
||||
Create a directory junction (Windows) or symlink (Linux) at *link_path*
|
||||
pointing to *target*.
|
||||
|
||||
:returns: ``True`` on success, ``False`` on failure.
|
||||
"""
|
||||
if is_windows():
|
||||
proc = subprocess.run(
|
||||
["cmd", "/c", "mklink", "/J", str(link_path), str(target)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return proc.returncode == 0
|
||||
else:
|
||||
try:
|
||||
os.symlink(str(target), str(link_path))
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def remove_junction(link_path: Path) -> tuple[bool, str]:
|
||||
"""
|
||||
Remove the junction / symlink at *link_path*.
|
||||
|
||||
- **Windows**: ``os.rmdir()`` removes the junction pointer without touching
|
||||
the target directory's contents.
|
||||
- **Linux**: ``os.unlink()`` removes the symlink without touching the target.
|
||||
|
||||
:returns: ``(True, "")`` on success, ``(False, error_message)`` on failure.
|
||||
"""
|
||||
try:
|
||||
if is_windows():
|
||||
os.rmdir(str(link_path))
|
||||
else:
|
||||
os.unlink(str(link_path))
|
||||
return True, ""
|
||||
except OSError as exc:
|
||||
return False, str(exc)
|
||||
|
||||
|
||||
def link_group(group_dir: Path, arma_dir: Path) -> dict:
|
||||
"""
|
||||
Create links for every unlinked ``@Mod`` in *group_dir*.
|
||||
|
||||
:returns: ``{"linked": n, "already_linked": n, "failed": n, "errors": {name: msg}}``
|
||||
"""
|
||||
status = get_link_status(group_dir, arma_dir)
|
||||
linked = already_linked = failed = 0
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
for s in status:
|
||||
if s["is_linked"]:
|
||||
already_linked += 1
|
||||
continue
|
||||
if s["link_path"].exists():
|
||||
failed += 1
|
||||
errors[s["name"]] = "path exists but is not a junction/symlink"
|
||||
continue
|
||||
ok = create_junction(s["link_path"], s["source_path"])
|
||||
if ok:
|
||||
linked += 1
|
||||
else:
|
||||
failed += 1
|
||||
errors[s["name"]] = "link creation failed"
|
||||
|
||||
return {"linked": linked, "already_linked": already_linked, "failed": failed, "errors": errors}
|
||||
|
||||
|
||||
def unlink_group(group_dir: Path, arma_dir: Path) -> dict:
|
||||
"""
|
||||
Remove links for every linked ``@Mod`` in *group_dir*.
|
||||
|
||||
:returns: ``{"unlinked": n, "not_linked": n, "failed": n, "errors": {name: msg}}``
|
||||
"""
|
||||
status = get_link_status(group_dir, arma_dir)
|
||||
unlinked = not_linked = failed = 0
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
for s in status:
|
||||
if not s["is_linked"]:
|
||||
not_linked += 1
|
||||
continue
|
||||
ok, err = remove_junction(s["link_path"])
|
||||
if ok:
|
||||
unlinked += 1
|
||||
else:
|
||||
failed += 1
|
||||
errors[s["name"]] = err
|
||||
|
||||
return {"unlinked": unlinked, "not_linked": not_linked, "failed": failed, "errors": errors}
|
||||
146
arma_modlist_tools/parser.py
Normal file
146
arma_modlist_tools/parser.py
Normal file
@@ -0,0 +1,146 @@
|
||||
"""
|
||||
arma_modlist_tools.parser
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Parse Arma 3 Launcher mod preset HTML files (.html exported from the launcher)
|
||||
into plain Python dicts / lists suitable for JSON serialisation.
|
||||
|
||||
Typical usage::
|
||||
|
||||
from arma_modlist_tools.parser import parse_modlist_html, parse_modlist_dir
|
||||
|
||||
# single file
|
||||
preset = parse_modlist_html("modlist_html/my_preset.html")
|
||||
|
||||
# whole folder
|
||||
presets = parse_modlist_dir("modlist_html")
|
||||
"""
|
||||
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public types (plain dicts — keep it dependency-free)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# ModEntry:
|
||||
# name : str display name from the launcher
|
||||
# source : "steam" | "local" | "unknown"
|
||||
# url : str | None full workshop / local path URL
|
||||
# steam_id : str | None numeric workshop item ID extracted from the URL
|
||||
|
||||
# Preset:
|
||||
# preset_name : str stem of the source filename
|
||||
# source_file : str basename of the source filename
|
||||
# mod_count : int
|
||||
# mods : list[ModEntry]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Low-level helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_STEAM_ID_RE = re.compile(r"[?&]id=(\d+)")
|
||||
|
||||
|
||||
def _extract_steam_id(url: str) -> str | None:
|
||||
"""Return the numeric workshop item ID from a Steam URL, or None."""
|
||||
m = _STEAM_ID_RE.search(url)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def _source_from_class(css_class: str) -> str:
|
||||
"""Map a span CSS class to a source label."""
|
||||
if "from-steam" in css_class:
|
||||
return "steam"
|
||||
if "from-local" in css_class:
|
||||
return "local"
|
||||
return "unknown"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def parse_mod_entry(tr_element: ET.Element) -> dict | None:
|
||||
"""
|
||||
Parse a single ``<tr data-type="ModContainer">`` element into a mod dict.
|
||||
|
||||
Returns ``None`` if the element does not contain a display name (i.e. it
|
||||
is not a valid mod row).
|
||||
"""
|
||||
name: str | None = None
|
||||
source: str = "unknown"
|
||||
url: str | None = None
|
||||
steam_id: str | None = None
|
||||
|
||||
for td in tr_element:
|
||||
dtype = td.get("data-type")
|
||||
|
||||
if dtype == "DisplayName":
|
||||
name = (td.text or "").strip()
|
||||
continue
|
||||
|
||||
for span in td.iter("span"):
|
||||
css = span.get("class", "")
|
||||
if "from-" in css:
|
||||
source = _source_from_class(css)
|
||||
|
||||
for a in td.iter("a"):
|
||||
if a.get("data-type") == "Link":
|
||||
href = (a.get("href") or "").strip()
|
||||
if href:
|
||||
url = href
|
||||
steam_id = _extract_steam_id(href)
|
||||
|
||||
if name is None:
|
||||
return None
|
||||
|
||||
return {"name": name, "source": source, "url": url, "steam_id": steam_id}
|
||||
|
||||
|
||||
def parse_modlist_html(filepath: str | Path) -> dict:
|
||||
"""
|
||||
Parse an Arma 3 Launcher preset HTML file and return a preset dict.
|
||||
|
||||
:param filepath: Path to the ``.html`` preset file.
|
||||
:returns: Dict with keys ``preset_name``, ``source_file``, ``mod_count``,
|
||||
and ``mods`` (list of mod entry dicts).
|
||||
:raises FileNotFoundError: If *filepath* does not exist.
|
||||
:raises ET.ParseError: If the file is not valid XML/HTML.
|
||||
"""
|
||||
path = Path(filepath)
|
||||
tree = ET.parse(path)
|
||||
root = tree.getroot()
|
||||
|
||||
mods = []
|
||||
for tr in root.iter("tr"):
|
||||
if tr.get("data-type") != "ModContainer":
|
||||
continue
|
||||
entry = parse_mod_entry(tr)
|
||||
if entry is not None:
|
||||
mods.append(entry)
|
||||
|
||||
return {
|
||||
"preset_name": path.stem,
|
||||
"source_file": path.name,
|
||||
"mod_count": len(mods),
|
||||
"mods": mods,
|
||||
}
|
||||
|
||||
|
||||
def parse_modlist_dir(directory: str | Path) -> list[dict]:
|
||||
"""
|
||||
Parse all ``.html`` preset files in *directory* and return a list of
|
||||
preset dicts (one per file, sorted by filename).
|
||||
|
||||
:param directory: Folder containing ``.html`` preset files.
|
||||
:returns: List of preset dicts as returned by :func:`parse_modlist_html`.
|
||||
:raises NotADirectoryError: If *directory* does not exist or is not a dir.
|
||||
"""
|
||||
d = Path(directory)
|
||||
if not d.is_dir():
|
||||
raise NotADirectoryError(f"Not a directory: {d}")
|
||||
|
||||
return [parse_modlist_html(f) for f in sorted(d.glob("*.html"))]
|
||||
95
arma_modlist_tools/reporter.py
Normal file
95
arma_modlist_tools/reporter.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
arma_modlist_tools.reporter
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Build and persist a report of mods that are required by the modlists but
|
||||
absent from the file server.
|
||||
|
||||
The report includes a ``group`` field per missing mod so downstream tools
|
||||
(``sync_missing.py``) know exactly where to place it when it becomes
|
||||
available on the server, without needing to re-read ``comparison.json``.
|
||||
|
||||
Typical usage::
|
||||
|
||||
from arma_modlist_tools.reporter import build_missing_report, save_missing_report
|
||||
|
||||
report = build_missing_report(comparison, server_index)
|
||||
save_missing_report(report, cfg.missing_report)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def build_missing_report(comparison: dict, server_index: dict) -> dict:
|
||||
"""
|
||||
Cross-reference every mod in *comparison* against *server_index* and
|
||||
return a report of mods that are not on the server.
|
||||
|
||||
:param comparison: Dict as returned by :func:`~arma_modlist_tools.compare.compare_presets`.
|
||||
:param server_index: Dict as returned by :func:`~arma_modlist_tools.fetcher.build_server_index`.
|
||||
:returns: Report dict::
|
||||
|
||||
{
|
||||
"generated_at": "2026-04-07T12:00:00+00:00",
|
||||
"total_mods": 80,
|
||||
"on_server": 2,
|
||||
"missing": 78,
|
||||
"missing_mods": [
|
||||
{
|
||||
"name": "CBA_A3",
|
||||
"steam_id": "450814997",
|
||||
"url": "https://steamcommunity.com/...",
|
||||
"group": "shared"
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
"""
|
||||
by_steam_id: dict = server_index.get("by_steam_id", {})
|
||||
by_name: dict = server_index.get("by_name", {})
|
||||
|
||||
from .fetcher import _normalize_name # reuse existing helper
|
||||
|
||||
def _on_server(mod: dict) -> bool:
|
||||
if mod.get("steam_id") and mod["steam_id"] in by_steam_id:
|
||||
return True
|
||||
return _normalize_name(mod.get("name", "")) in by_name
|
||||
|
||||
# Flatten all mods with their group label
|
||||
all_mods: list[tuple[dict, str]] = []
|
||||
for mod in comparison["shared"]["mods"]:
|
||||
all_mods.append((mod, "shared"))
|
||||
for preset_name, data in comparison["unique"].items():
|
||||
for mod in data["mods"]:
|
||||
all_mods.append((mod, preset_name))
|
||||
|
||||
missing_mods = []
|
||||
on_server_count = 0
|
||||
|
||||
for mod, group in all_mods:
|
||||
if _on_server(mod):
|
||||
on_server_count += 1
|
||||
else:
|
||||
missing_mods.append({
|
||||
"name": mod["name"],
|
||||
"steam_id": mod.get("steam_id"),
|
||||
"url": mod.get("url"),
|
||||
"group": group,
|
||||
})
|
||||
|
||||
return {
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"total_mods": len(all_mods),
|
||||
"on_server": on_server_count,
|
||||
"missing": len(missing_mods),
|
||||
"missing_mods": missing_mods,
|
||||
}
|
||||
|
||||
|
||||
def save_missing_report(report: dict, path: Path) -> None:
|
||||
"""Write *report* as indented JSON to *path*, creating parent dirs as needed."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
92
check_deps.py
Normal file
92
check_deps.py
Normal file
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Verify Python version, OS, and required packages before running the toolchain.
|
||||
|
||||
Usage:
|
||||
python check_deps.py
|
||||
"""
|
||||
|
||||
import importlib.metadata
|
||||
import sys
|
||||
|
||||
# Must not import arma_modlist_tools here — that's what we're checking FOR.
|
||||
|
||||
MIN_PYTHON = (3, 11)
|
||||
REQUIRED_PACKAGES = ["requests", "tqdm"]
|
||||
|
||||
|
||||
def _python_ok() -> bool:
|
||||
return sys.version_info >= MIN_PYTHON
|
||||
|
||||
|
||||
def _get_os_label() -> str:
|
||||
"""Inline OS detection — avoids importing compat before deps are confirmed."""
|
||||
import platform
|
||||
if sys.platform == "win32":
|
||||
if "Server" in platform.version() or "Server" in platform.uname().version:
|
||||
return "Windows Server"
|
||||
return "Windows"
|
||||
if sys.platform == "linux":
|
||||
try:
|
||||
with open("/etc/os-release", encoding="utf-8") as f:
|
||||
text = f.read().lower()
|
||||
if "ubuntu" in text:
|
||||
import os
|
||||
headless = not (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
|
||||
return "Ubuntu Server" if headless else "Ubuntu"
|
||||
except OSError:
|
||||
pass
|
||||
return "Linux"
|
||||
return "Unknown"
|
||||
|
||||
|
||||
def _pkg_version(name: str) -> str | None:
|
||||
try:
|
||||
return importlib.metadata.version(name)
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
return None
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print()
|
||||
|
||||
# Python version
|
||||
ver = sys.version.split()[0]
|
||||
py_ok = _python_ok()
|
||||
status = "OK" if py_ok else f"NEED >= {MIN_PYTHON[0]}.{MIN_PYTHON[1]}"
|
||||
print(f" {'Python':<14} {ver:<12} {status}")
|
||||
|
||||
# OS
|
||||
os_label = _get_os_label()
|
||||
print(f" {'OS':<14} {os_label}")
|
||||
print()
|
||||
|
||||
# Packages
|
||||
missing = []
|
||||
for pkg in REQUIRED_PACKAGES:
|
||||
v = _pkg_version(pkg)
|
||||
if v:
|
||||
print(f" {pkg:<14} {v:<12} OK")
|
||||
else:
|
||||
print(f" {pkg:<14} {'---':<12} MISSING")
|
||||
missing.append(pkg)
|
||||
|
||||
print()
|
||||
|
||||
if not py_ok:
|
||||
print(f" Python {MIN_PYTHON[0]}.{MIN_PYTHON[1]}+ is required.")
|
||||
sys.exit(1)
|
||||
|
||||
if missing:
|
||||
print(f" {len(missing)} package(s) missing. Install with:")
|
||||
print(f" pip install {' '.join(missing)}")
|
||||
print()
|
||||
print(" Run check_deps.py again to verify.")
|
||||
sys.exit(1)
|
||||
|
||||
print(" All checks passed. Ready to run.")
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
32
compare_modlists.py
Normal file
32
compare_modlists.py
Normal file
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CLI entry point: compare all mod presets in modlist_html/ -> modlist_json/comparison.json."""
|
||||
|
||||
import json
|
||||
|
||||
from arma_modlist_tools import parse_modlist_dir, compare_presets
|
||||
from arma_modlist_tools.config import load_config
|
||||
|
||||
|
||||
def main():
|
||||
cfg = load_config()
|
||||
|
||||
presets = parse_modlist_dir(cfg.modlist_html)
|
||||
if len(presets) < 2:
|
||||
print("Need at least 2 preset files in modlist_html/ to compare.")
|
||||
return
|
||||
|
||||
result = compare_presets(*presets)
|
||||
|
||||
cfg.modlist_json.mkdir(exist_ok=True)
|
||||
cfg.comparison.write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
shared = result["shared"]["mod_count"]
|
||||
print(f"Compared: {', '.join(result['compared_presets'])}")
|
||||
print(f" Shared mods : {shared}")
|
||||
for preset_name, data in result["unique"].items():
|
||||
print(f" Unique to {preset_name}: {data['mod_count']}")
|
||||
print(f" -> {cfg.comparison}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
13
config.template.json
Normal file
13
config.template.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"server": {
|
||||
"base_url": "https://your-caddy-server/arma3mods/",
|
||||
"username": "your_username",
|
||||
"password": "your_password"
|
||||
},
|
||||
"paths": {
|
||||
"arma_dir": "C:\\Path\\To\\Arma 3 Server",
|
||||
"downloads": "downloads",
|
||||
"modlist_html": "modlist_html",
|
||||
"modlist_json": "modlist_json"
|
||||
}
|
||||
}
|
||||
205
fetch_mods.py
Normal file
205
fetch_mods.py
Normal file
@@ -0,0 +1,205 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
CLI entry point: download mods from the Caddy file server using comparison.json.
|
||||
|
||||
Folder layout produced:
|
||||
downloads/
|
||||
shared/ <- mods common to all presets
|
||||
@ace/
|
||||
@cba_a3/
|
||||
150th_MW_2026_v1.0/ <- preset-unique mods
|
||||
@rhsusaf/
|
||||
150th_WW2_2026_V1.0/
|
||||
@ifa3_aio/
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
|
||||
from tqdm import tqdm
|
||||
|
||||
from arma_modlist_tools.compat import fix_console_encoding
|
||||
from arma_modlist_tools.config import load_config
|
||||
from arma_modlist_tools.fetcher import (
|
||||
make_session,
|
||||
build_server_index,
|
||||
find_mod_folder,
|
||||
list_mod_files,
|
||||
download_file,
|
||||
)
|
||||
from arma_modlist_tools.reporter import build_missing_report, save_missing_report
|
||||
|
||||
fix_console_encoding()
|
||||
|
||||
|
||||
def _fmt_bytes(n: int) -> str:
|
||||
for unit in ("B", "KB", "MB", "GB"):
|
||||
if n < 1024:
|
||||
return f"{n:.1f} {unit}"
|
||||
n /= 1024
|
||||
return f"{n:.1f} TB"
|
||||
|
||||
|
||||
def _build_download_queue(comparison: dict) -> list[tuple[dict, str]]:
|
||||
"""Return flat list of (mod_entry, group_name) for every mod."""
|
||||
queue = []
|
||||
for mod in comparison["shared"]["mods"]:
|
||||
queue.append((mod, "shared"))
|
||||
for preset_name, data in comparison["unique"].items():
|
||||
for mod in data["mods"]:
|
||||
queue.append((mod, preset_name))
|
||||
return queue
|
||||
|
||||
|
||||
def main() -> None:
|
||||
cfg = load_config()
|
||||
|
||||
# ---- Load comparison ----
|
||||
if not cfg.comparison.exists():
|
||||
print(f"ERROR: {cfg.comparison} not found. Run compare_modlists.py first.")
|
||||
sys.exit(1)
|
||||
comparison = json.loads(cfg.comparison.read_text(encoding="utf-8"))
|
||||
queue = _build_download_queue(comparison)
|
||||
|
||||
print(f"Loaded comparison: {', '.join(comparison['compared_presets'])}")
|
||||
print(f"Total mods to consider: {len(queue)}\n")
|
||||
|
||||
# ---- Build server index ----
|
||||
print("Building server index (fetching meta.cpp for each server folder)...")
|
||||
index = build_server_index(cfg.server_url, cfg.server_auth)
|
||||
tqdm.write(f" Indexed {len(index['by_steam_id'])} mods by steam_id, "
|
||||
f"{len(index['by_name'])} by name\n")
|
||||
|
||||
# ---- Resolve mods against server ----
|
||||
print("Resolving mods against server index...")
|
||||
session = make_session(cfg.server_auth)
|
||||
resolved: list[tuple[dict, str, str]] = []
|
||||
not_found: list[dict] = []
|
||||
|
||||
for mod, group in queue:
|
||||
url = find_mod_folder(mod, index)
|
||||
if url:
|
||||
resolved.append((mod, group, url))
|
||||
else:
|
||||
not_found.append(mod)
|
||||
|
||||
tqdm.write(f" {len(resolved)} matched "
|
||||
f"{len(not_found)} not found on server\n")
|
||||
|
||||
# ---- Save missing report ----
|
||||
report = build_missing_report(comparison, index)
|
||||
save_missing_report(report, cfg.missing_report)
|
||||
if not_found:
|
||||
tqdm.write(f" Missing report saved: {cfg.missing_report} "
|
||||
f"({report['missing']} mods)\n")
|
||||
|
||||
if not resolved:
|
||||
print("Nothing to download.")
|
||||
sys.exit(0)
|
||||
|
||||
# ---- Pre-scan file lists ----
|
||||
print("Scanning server for file lists...")
|
||||
mod_file_lists: list[tuple[dict, str, str, list]] = []
|
||||
conflicts: list[str] = []
|
||||
|
||||
with tqdm(total=len(resolved), unit="mod", desc=" Scanning", leave=False) as bar:
|
||||
for mod, group, folder_url in resolved:
|
||||
folder_name = folder_url.rstrip("/").split("/")[-1]
|
||||
dest_path = cfg.downloads / group / folder_name
|
||||
files = list_mod_files(folder_url, session)
|
||||
mod_file_lists.append((mod, group, folder_url, files))
|
||||
if dest_path.exists():
|
||||
conflicts.append(str(dest_path))
|
||||
bar.update(1)
|
||||
|
||||
total_bytes = sum(size for _, _, _, files in mod_file_lists for _, _, size in files)
|
||||
tqdm.write(f" Total download size: {_fmt_bytes(total_bytes)}\n")
|
||||
|
||||
# ---- Conflict resolution (ask once) ----
|
||||
overwrite = False
|
||||
if conflicts:
|
||||
print(f"Conflict: {len(conflicts)} destination folder(s) already exist:")
|
||||
for c in conflicts[:5]:
|
||||
print(f" {c}")
|
||||
if len(conflicts) > 5:
|
||||
print(f" ... and {len(conflicts) - 5} more")
|
||||
while True:
|
||||
choice = input("\nOverwrite existing? [s]kip / [o]verwrite: ").strip().lower()
|
||||
if choice in ("s", "skip"):
|
||||
break
|
||||
if choice in ("o", "overwrite"):
|
||||
overwrite = True
|
||||
break
|
||||
print(" Please enter 's' or 'o'.")
|
||||
print()
|
||||
|
||||
# ---- Download ----
|
||||
print(f"Starting downloads: {len(mod_file_lists)} mods\n")
|
||||
|
||||
total_downloaded_bytes = total_downloaded_files = total_skipped_files = 0
|
||||
|
||||
with tqdm(
|
||||
total=len(mod_file_lists),
|
||||
unit="mod",
|
||||
desc="Overall",
|
||||
position=0,
|
||||
dynamic_ncols=True,
|
||||
) as mod_bar:
|
||||
for i, (mod, group, folder_url, files) in enumerate(mod_file_lists, 1):
|
||||
folder_name = folder_url.rstrip("/").split("/")[-1]
|
||||
dest_path = cfg.downloads / group / folder_name
|
||||
|
||||
tqdm.write(
|
||||
f"\n[{i}/{len(mod_file_lists)}] {folder_name}"
|
||||
f" -> {dest_path}/"
|
||||
f" (group: {group})"
|
||||
)
|
||||
|
||||
mod_bytes = mod_files_dl = mod_files_skip = 0
|
||||
|
||||
for rel, file_url, size in files:
|
||||
dest_file = dest_path / rel
|
||||
if dest_file.exists() and not overwrite:
|
||||
tqdm.write(f" SKIP {rel}")
|
||||
mod_files_skip += 1
|
||||
continue
|
||||
|
||||
with tqdm(
|
||||
total=size if size else None,
|
||||
unit="B", unit_scale=True, unit_divisor=1024,
|
||||
desc=f" {rel[-45:]:45s}",
|
||||
position=1, leave=False, dynamic_ncols=True,
|
||||
) as file_bar:
|
||||
n = download_file(
|
||||
file_url, dest_file, session,
|
||||
on_chunk=lambda b: file_bar.update(b),
|
||||
)
|
||||
|
||||
mod_bytes += n
|
||||
mod_files_dl += 1
|
||||
|
||||
total_downloaded_bytes += mod_bytes
|
||||
total_downloaded_files += mod_files_dl
|
||||
total_skipped_files += mod_files_skip
|
||||
|
||||
tqdm.write(
|
||||
f" Done {mod_files_dl} downloaded"
|
||||
+ (f" {mod_files_skip} skipped" if mod_files_skip else "")
|
||||
+ f" {_fmt_bytes(mod_bytes)}"
|
||||
)
|
||||
mod_bar.update(1)
|
||||
|
||||
# ---- Summary ----
|
||||
print(f"\n{'─' * 50}")
|
||||
print(f" Mods processed : {len(mod_file_lists)}")
|
||||
print(f" Files downloaded: {total_downloaded_files}")
|
||||
print(f" Files skipped : {total_skipped_files}")
|
||||
print(f" Mods not found : {len(not_found)}")
|
||||
print(f" Total downloaded: {_fmt_bytes(total_downloaded_bytes)}")
|
||||
print(f" Output root : {cfg.downloads.resolve()}")
|
||||
if not_found:
|
||||
print(f" Missing report : {cfg.missing_report}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
183
link_mods.py
Normal file
183
link_mods.py
Normal file
@@ -0,0 +1,183 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
CLI entry point: manage Arma 3 Server junction/symlink links for downloaded mods.
|
||||
|
||||
Usage:
|
||||
python link_mods.py status --group shared
|
||||
python link_mods.py link --group shared
|
||||
python link_mods.py unlink --group shared
|
||||
|
||||
Omit --group to list available groups.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
from arma_modlist_tools.compat import fix_console_encoding
|
||||
from arma_modlist_tools.config import load_config
|
||||
from arma_modlist_tools.linker import get_link_status, link_group, unlink_group, create_junction, remove_junction
|
||||
|
||||
fix_console_encoding()
|
||||
|
||||
|
||||
def _available_groups(cfg) -> list[str]:
|
||||
if not cfg.downloads.is_dir():
|
||||
return []
|
||||
return sorted(p.name for p in cfg.downloads.iterdir() if p.is_dir())
|
||||
|
||||
|
||||
def _print_separator(width: int = 60) -> None:
|
||||
print(" " + "-" * width)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Subcommands
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def cmd_status(cfg, group: str) -> None:
|
||||
group_dir = cfg.downloads / group
|
||||
if not group_dir.is_dir():
|
||||
print(f"ERROR: group folder not found: {group_dir}")
|
||||
sys.exit(1)
|
||||
|
||||
status = get_link_status(group_dir, cfg.arma_dir)
|
||||
|
||||
print()
|
||||
print(f" Group : {group}")
|
||||
print(f" Path : {group_dir}")
|
||||
print(f" Arma : {cfg.arma_dir}")
|
||||
print()
|
||||
print(f" {'Mod':<50} Status")
|
||||
_print_separator()
|
||||
|
||||
for s in status:
|
||||
icon = "[LINKED]" if s["is_linked"] else "[------]"
|
||||
print(f" {s['name']:<50} {icon}")
|
||||
|
||||
linked = sum(1 for s in status if s["is_linked"])
|
||||
print()
|
||||
print(f" {linked} / {len(status)} linked")
|
||||
print()
|
||||
|
||||
|
||||
def cmd_link(cfg, group: str) -> None:
|
||||
group_dir = cfg.downloads / group
|
||||
if not group_dir.is_dir():
|
||||
print(f"ERROR: group folder not found: {group_dir}")
|
||||
sys.exit(1)
|
||||
|
||||
status = get_link_status(group_dir, cfg.arma_dir)
|
||||
if not status:
|
||||
print(f"No @Mod folders found in {group_dir}")
|
||||
sys.exit(0)
|
||||
|
||||
print()
|
||||
print(f" Linking group: {group} -> {cfg.arma_dir}")
|
||||
print()
|
||||
|
||||
linked = already_linked = failed = 0
|
||||
|
||||
for s in status:
|
||||
if s["is_linked"]:
|
||||
print(f" [=] {s['name']:<48} already linked")
|
||||
already_linked += 1
|
||||
continue
|
||||
if s["link_path"].exists():
|
||||
print(f" [!] {s['name']:<48} SKIP — path exists, not a junction")
|
||||
failed += 1
|
||||
continue
|
||||
ok = create_junction(s["link_path"], s["source_path"])
|
||||
if ok:
|
||||
print(f" [+] {s['name']:<48} linked")
|
||||
linked += 1
|
||||
else:
|
||||
print(f" [X] {s['name']:<48} FAILED")
|
||||
failed += 1
|
||||
|
||||
print()
|
||||
print(f" Done: {linked} linked, {already_linked} already linked, {failed} failed")
|
||||
print()
|
||||
|
||||
|
||||
def cmd_unlink(cfg, group: str) -> None:
|
||||
group_dir = cfg.downloads / group
|
||||
if not group_dir.is_dir():
|
||||
print(f"ERROR: group folder not found: {group_dir}")
|
||||
sys.exit(1)
|
||||
|
||||
status = get_link_status(group_dir, cfg.arma_dir)
|
||||
linked_count = sum(1 for s in status if s["is_linked"])
|
||||
|
||||
if linked_count == 0:
|
||||
print(f"\n Nothing to unlink — 0 active links in group '{group}'.\n")
|
||||
sys.exit(0)
|
||||
|
||||
print()
|
||||
print(f" WARNING: This will remove {linked_count} link(s) from Arma 3 Server:")
|
||||
print(f" {cfg.arma_dir}")
|
||||
print(f" Group: {group} ({linked_count} linked mod(s))")
|
||||
print()
|
||||
|
||||
choice = input(" Continue? [y/N]: ").strip().lower()
|
||||
if choice not in ("y", "yes"):
|
||||
print(" Aborted.\n")
|
||||
sys.exit(0)
|
||||
|
||||
print()
|
||||
|
||||
unlinked = not_linked = failed = 0
|
||||
|
||||
for s in status:
|
||||
if not s["is_linked"]:
|
||||
print(f" [=] {s['name']:<48} not linked")
|
||||
not_linked += 1
|
||||
continue
|
||||
ok, err = remove_junction(s["link_path"])
|
||||
if ok:
|
||||
print(f" [-] {s['name']:<48} unlinked")
|
||||
unlinked += 1
|
||||
else:
|
||||
print(f" [X] {s['name']:<48} FAILED: {err}")
|
||||
failed += 1
|
||||
|
||||
print()
|
||||
print(f" Done: {unlinked} removed, {not_linked} not linked"
|
||||
+ (f", {failed} failed" if failed else ""))
|
||||
print()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main() -> None:
|
||||
cfg = load_config()
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Manage Arma 3 Server junction/symlink links for downloaded mods."
|
||||
)
|
||||
parser.add_argument("command", choices=["status", "link", "unlink"])
|
||||
parser.add_argument("--group", "-g", metavar="GROUP")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.group:
|
||||
groups = _available_groups(cfg)
|
||||
if groups:
|
||||
print(f"\nAvailable groups in '{cfg.downloads}':")
|
||||
for g in groups:
|
||||
print(f" {g}")
|
||||
print(f"\nUsage: python link_mods.py {args.command} --group <GROUP>\n")
|
||||
else:
|
||||
print(f"No groups found in '{cfg.downloads}'. Run fetch_mods.py first.")
|
||||
sys.exit(0)
|
||||
|
||||
if args.command == "status":
|
||||
cmd_status(cfg, args.group)
|
||||
elif args.command == "link":
|
||||
cmd_link(cfg, args.group)
|
||||
elif args.command == "unlink":
|
||||
cmd_unlink(cfg, args.group)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
634
modlist_html/150th_MW_2026_v1.0.html
Normal file
634
modlist_html/150th_MW_2026_v1.0.html
Normal file
@@ -0,0 +1,634 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<html>
|
||||
<!--Created by Arma 3 Launcher: https://arma3.com-->
|
||||
<head>
|
||||
<meta name="arma:Type" content="list" />
|
||||
<meta name="generator" content="Arma 3 Launcher - https://arma3.com" />
|
||||
<title>Arma 3</title>
|
||||
<link href="https://fonts.googleapis.com/css?family=Roboto" rel="stylesheet" type="text/css" />
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
color: #fff;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
body, th, td {
|
||||
font: 95%/1.3 Roboto, Segoe UI, Tahoma, Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 3px 30px 3px 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
padding: 20px 20px 0 20px;
|
||||
color: white;
|
||||
font-weight: 200;
|
||||
font-family: segoe ui;
|
||||
font-size: 3em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
em {
|
||||
font-variant: italic;
|
||||
color:silver;
|
||||
}
|
||||
|
||||
.before-list {
|
||||
padding: 5px 20px 10px 20px;
|
||||
}
|
||||
|
||||
.mod-list {
|
||||
background: #222222;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.dlc-list {
|
||||
background: #222222;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 20px;
|
||||
color:gray;
|
||||
}
|
||||
|
||||
.whups {
|
||||
color:gray;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #D18F21;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color:#F1AF41;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.from-steam {
|
||||
color: #449EBD;
|
||||
}
|
||||
.from-local {
|
||||
color: gray;
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Arma 3 Mods</h1>
|
||||
<p class="before-list">
|
||||
<em>To import this preset, drag this file onto the Launcher window. Or click the MODS tab, then PRESET in the top right, then IMPORT at the bottom, and finally select this file.</em>
|
||||
</p>
|
||||
<div class="mod-list">
|
||||
<table>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">[ANDIA] - FUBAR System (DEV)</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3650593763" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3650593763</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">A3 Thermal Improvement</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2041057379" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2041057379</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">ace</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=463939057" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=463939057</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">ACE 3 Extension (Animations and Actions)</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=766491311" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=766491311</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Advanced Rappelling</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=713709341" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=713709341</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Advanced Sling Loading</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=615007497" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=615007497</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Advanced Towing</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=639837898" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=639837898</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Al Salman 2.0</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2857846877" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2857846877</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Ambient Modules</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2816705133" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2816705133</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Animated Grenade Throwing</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2935338016" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2935338016</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Archie, Summer</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3620961988" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3620961988</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Archie, Winter</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3640984328" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3640984328</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">BackpackOnChest</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=820924072" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=820924072</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Better Inventory</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2791403093" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2791403093</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">BettIR NVG</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2260572637" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2260572637</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Breach - Rewrite</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3283645995" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3283645995</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">CBA_A3</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=450814997" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=450814997</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">CES Resupply</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2744348250" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2744348250</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Cold War Factions</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2966202074" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2966202074</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Crows Electronic Warfare</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2515887728" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2515887728</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Crows Zeus Additions</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2447965207" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2447965207</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">CUP Terrains - Core</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=583496184" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=583496184</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">CUP Terrains - Maps</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=583544987" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=583544987</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">CUP Terrains - Maps 2.0</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=1981964169" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=1981964169</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Devourerking's Necroplague Mutants</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2616555444" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2616555444</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">DUI - Squad Radar</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=1638341685" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=1638341685</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Enhanced GPS</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2480263219" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2480263219</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Enhanced Map</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2467589125" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2467589125</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Enhanced Movement</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=333310405" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=333310405</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Improved Melee System</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2291129343" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2291129343</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">JCA - Infantry Arsenal</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3333302397" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3333302397</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">JCA - Infantry Equipment</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3473383676" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3473383676</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">JCA - QOL Essentials V2</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3032405142" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3032405142</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">NIArms All in One - RHS Compatibility.</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=1400574293" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=1400574293</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">NIArms All In One (V14 Onwards)</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2595680138" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2595680138</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">NIArms All in One- ACE Compatibility</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=1400566170" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=1400566170</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">NIArms for Unconventional Actors</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2915856399" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2915856399</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Novogorsk</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2979021411" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2979021411</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Pook Boat Pack</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=1529074643" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=1529074643</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Realistic Ragdoll Physics</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3639557777" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3639557777</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">RHS Additions - Rewrite</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3553838240" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3553838240</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">RHSAFRF</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=843425103" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=843425103</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">RHSGREF</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=843593391" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=843593391</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">RHSSAF</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=843632231" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=843632231</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">RHSUSAF</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=843577117" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=843577117</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Root's Anomalies - Zeus Module</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2882374586" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2882374586</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Saint Kapaulio</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=939686262" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=939686262</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Some Effects Rework: Impacts</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3596299237" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3596299237</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Speshal Core</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3283642267" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3283642267</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Task Force Arrowhead Radio (BETA!!!)</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=894678801" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=894678801</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Unconventional Actors</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2890312862" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2890312862</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">W28</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3205870510" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3205870510</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">WBK Immersive Animations</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3165450999" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3165450999</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Weather Plus</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2735613231" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2735613231</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">WebKnight Flashlights and Headlamps</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2572487482" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2572487482</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">WebKnight's Zombies and Creatures</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2789152015" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2789152015</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Zeus Enhanced</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=1779063631" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=1779063631</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Zeus Immersion Sounds</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2461386136" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2461386136</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Zombies and Demons</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=501966277" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=501966277</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Zombies and Demons ACE integration</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=1606871585" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=1606871585</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<span>Created by Arma 3 Launcher by Bohemia Interactive.</span>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
463
modlist_html/150th_WW2_2026_V1.0.html
Normal file
463
modlist_html/150th_WW2_2026_V1.0.html
Normal file
@@ -0,0 +1,463 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<html>
|
||||
<!--Created by Arma 3 Launcher: https://arma3.com-->
|
||||
<head>
|
||||
<meta name="arma:Type" content="list" />
|
||||
<meta name="generator" content="Arma 3 Launcher - https://arma3.com" />
|
||||
<title>Arma 3</title>
|
||||
<link href="https://fonts.googleapis.com/css?family=Roboto" rel="stylesheet" type="text/css" />
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
color: #fff;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
body, th, td {
|
||||
font: 95%/1.3 Roboto, Segoe UI, Tahoma, Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 3px 30px 3px 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
padding: 20px 20px 0 20px;
|
||||
color: white;
|
||||
font-weight: 200;
|
||||
font-family: segoe ui;
|
||||
font-size: 3em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
em {
|
||||
font-variant: italic;
|
||||
color:silver;
|
||||
}
|
||||
|
||||
.before-list {
|
||||
padding: 5px 20px 10px 20px;
|
||||
}
|
||||
|
||||
.mod-list {
|
||||
background: #222222;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.dlc-list {
|
||||
background: #222222;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 20px;
|
||||
color:gray;
|
||||
}
|
||||
|
||||
.whups {
|
||||
color:gray;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #D18F21;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color:#F1AF41;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.from-steam {
|
||||
color: #449EBD;
|
||||
}
|
||||
.from-local {
|
||||
color: gray;
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Arma 3 Mods</h1>
|
||||
<p class="before-list">
|
||||
<em>To import this preset, drag this file onto the Launcher window. Or click the MODS tab, then PRESET in the top right, then IMPORT at the bottom, and finally select this file.</em>
|
||||
</p>
|
||||
<div class="mod-list">
|
||||
<table>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">[ANDIA] - FUBAR System (DEV)</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3650593763" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3650593763</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">[SWU] Immersion Sound Pack</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=946763963" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=946763963</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">150th Languard Additional Ace Flags</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3467298844" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3467298844</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">ace</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=463939057" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=463939057</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">ACE 3 Extension (Animations and Actions)</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=766491311" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=766491311</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Ambient Modules</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2816705133" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2816705133</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Animated Grenade Throwing</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2935338016" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2935338016</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">BackpackOnChest</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=820924072" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=820924072</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">CBA_A3</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=450814997" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=450814997</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Crows Zeus Additions</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2447965207" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2447965207</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">CUP Terrains - Core</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=583496184" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=583496184</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Darkest December 1944</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3373660050" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3373660050</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">DUI - Squad Radar</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=1638341685" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=1638341685</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Eifel Forest 1944</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3445102949" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3445102949</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Enhanced Movement</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=333310405" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=333310405</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Enhanced Movement Rework</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2034363662" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2034363662</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Flying Legends</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2012417505" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2012417505</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Groesbeek Heights 1944</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3633127181" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3633127181</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Hurtgen Forest</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3214719358" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3214719358</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">IFA3 AIO</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2648308937" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2648308937</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Improved Melee System</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2291129343" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2291129343</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">JM's Second Assault</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3493205282" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3493205282</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">M24 Chaffee</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3301951691" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3301951691</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Market Garden Grave Bridge</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3524533280" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3524533280</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">MRPH Infantry Charge</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3560796660" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3560796660</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Pegasus Bridge</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3391363624" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3391363624</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Realistic Ragdoll Physics</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3639557777" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3639557777</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Saint Pierre Du Mont</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3494072989" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3494072989</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Secret Weapons Reloaded</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2710902874" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2710902874</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Some Effects Rework: Impacts</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3596299237" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3596299237</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Spearhead 1944 - IFA3 SPE Tank Overhaul</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3305447657" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3305447657</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Spearhead 1944 - Secret Weapons Compatibility</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3014048725" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=3014048725</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Task Force Arrowhead Radio (BETA!!!)</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=894678801" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=894678801</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">US GEAr: Units (IFA3)</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=1496363537" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=1496363537</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">US General Equipment and Accessories</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=1399447232" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=1399447232</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">WebKnight's Zombies and Creatures</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2789152015" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2789152015</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Zeus Enhanced</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=1779063631" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=1779063631</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">ZEUS WARGAME [RTS mod]</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=2932697000" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=2932697000</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">ZluskeN Whistle and Bugle Mod</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=884372152" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=884372152</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Zombies and Demons</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=501966277" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=501966277</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">Zombies and Demons ACE integration</td>
|
||||
<td>
|
||||
<span class="from-steam">Steam</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=1606871585" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=1606871585</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<span>Created by Arma 3 Launcher by Bohemia Interactive.</span>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
26
parse_modlist.py
Normal file
26
parse_modlist.py
Normal file
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CLI entry point: parse all mod preset HTMLs in modlist_html/ -> modlist_json/."""
|
||||
|
||||
import json
|
||||
|
||||
from arma_modlist_tools import parse_modlist_dir
|
||||
from arma_modlist_tools.config import load_config
|
||||
|
||||
|
||||
def main():
|
||||
cfg = load_config()
|
||||
cfg.modlist_json.mkdir(exist_ok=True)
|
||||
|
||||
presets = parse_modlist_dir(cfg.modlist_html)
|
||||
if not presets:
|
||||
print(f"No .html files found in {cfg.modlist_html}/")
|
||||
return
|
||||
|
||||
for preset in presets:
|
||||
out_file = cfg.modlist_json / (preset["preset_name"] + ".json")
|
||||
out_file.write_text(json.dumps(preset, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
print(f"{preset['source_file']} -> {out_file} ({preset['mod_count']} mods)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
64
report_missing.py
Normal file
64
report_missing.py
Normal file
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
CLI entry point: cross-reference comparison.json against the file server and
|
||||
report which required mods are missing.
|
||||
|
||||
Usage:
|
||||
python report_missing.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
|
||||
from arma_modlist_tools.compat import fix_console_encoding
|
||||
from arma_modlist_tools.config import load_config
|
||||
from arma_modlist_tools.fetcher import build_server_index
|
||||
from arma_modlist_tools.reporter import build_missing_report, save_missing_report
|
||||
|
||||
fix_console_encoding()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
cfg = load_config()
|
||||
|
||||
if not cfg.comparison.exists():
|
||||
print(f"ERROR: {cfg.comparison} not found. Run compare_modlists.py first.")
|
||||
sys.exit(1)
|
||||
|
||||
comparison = json.loads(cfg.comparison.read_text(encoding="utf-8"))
|
||||
total_mods = (
|
||||
comparison["shared"]["mod_count"]
|
||||
+ sum(d["mod_count"] for d in comparison["unique"].values())
|
||||
)
|
||||
|
||||
# ---- Build server index ----
|
||||
print(f"\nChecking server index...")
|
||||
index = build_server_index(cfg.server_url, cfg.server_auth)
|
||||
print(f" {len(index['by_steam_id'])} mods indexed\n")
|
||||
|
||||
# ---- Build report ----
|
||||
print(f"Cross-referencing {total_mods} required mods...")
|
||||
report = build_missing_report(comparison, index)
|
||||
save_missing_report(report, cfg.missing_report)
|
||||
|
||||
# ---- Print table ----
|
||||
missing = report["missing_mods"]
|
||||
if missing:
|
||||
print(f"\n Missing from server ({len(missing)}):\n")
|
||||
print(f" {'steam_id':<14} {'Group':<28} Name")
|
||||
print(f" {'-'*14} {'-'*28} {'-'*40}")
|
||||
for m in missing:
|
||||
sid = m["steam_id"] or "---"
|
||||
group = m["group"]
|
||||
print(f" {sid:<14} {group:<28} {m['name']}")
|
||||
else:
|
||||
print("\n All required mods are on the server.")
|
||||
|
||||
print()
|
||||
print(f" {report['on_server']} / {report['total_mods']} found on server")
|
||||
print(f" Report saved: {cfg.missing_report}")
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
2
requirements.txt
Normal file
2
requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
requests
|
||||
tqdm
|
||||
228
run.py
Normal file
228
run.py
Normal file
@@ -0,0 +1,228 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Orchestrator: run the full pipeline in sequence.
|
||||
|
||||
Steps:
|
||||
1. parse — parse HTML presets -> modlist_json/
|
||||
2. compare — compare presets -> comparison.json
|
||||
3. fetch — download mods from server (also saves missing_report.json)
|
||||
4. link — create junctions/symlinks to Arma 3 Server
|
||||
|
||||
Usage:
|
||||
python run.py # full pipeline, all groups
|
||||
python run.py --skip-fetch --skip-link # parse + compare only
|
||||
python run.py --skip-parse --skip-compare --skip-fetch --group shared
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
from tqdm import tqdm
|
||||
|
||||
from arma_modlist_tools.compat import fix_console_encoding
|
||||
from arma_modlist_tools.config import load_config
|
||||
from arma_modlist_tools.parser import parse_modlist_dir
|
||||
from arma_modlist_tools.compare import compare_presets
|
||||
from arma_modlist_tools.fetcher import (
|
||||
build_server_index, find_mod_folder,
|
||||
list_mod_files, download_file, make_session,
|
||||
)
|
||||
from arma_modlist_tools.linker import link_group
|
||||
from arma_modlist_tools.reporter import build_missing_report, save_missing_report
|
||||
|
||||
fix_console_encoding()
|
||||
|
||||
|
||||
def _fmt_bytes(n: int) -> str:
|
||||
for unit in ("B", "KB", "MB", "GB"):
|
||||
if n < 1024:
|
||||
return f"{n:.1f} {unit}"
|
||||
n /= 1024
|
||||
return f"{n:.1f} TB"
|
||||
|
||||
|
||||
def _header(step: int, total: int, name: str) -> None:
|
||||
print(f"\n{'='*56}")
|
||||
print(f" Step {step}/{total}: {name}")
|
||||
print(f"{'='*56}\n")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step implementations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def step_parse(cfg) -> None:
|
||||
cfg.modlist_json.mkdir(exist_ok=True)
|
||||
presets = parse_modlist_dir(cfg.modlist_html)
|
||||
if not presets:
|
||||
print(f" No .html files found in {cfg.modlist_html}/")
|
||||
return
|
||||
for preset in presets:
|
||||
out = cfg.modlist_json / (preset["preset_name"] + ".json")
|
||||
out.write_text(json.dumps(preset, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
print(f" {preset['source_file']} -> {out} ({preset['mod_count']} mods)")
|
||||
|
||||
|
||||
def step_compare(cfg) -> None:
|
||||
presets = parse_modlist_dir(cfg.modlist_html)
|
||||
if len(presets) < 2:
|
||||
print(" Need at least 2 preset files to compare.")
|
||||
return
|
||||
result = compare_presets(*presets)
|
||||
cfg.modlist_json.mkdir(exist_ok=True)
|
||||
cfg.comparison.write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
print(f" Compared: {', '.join(result['compared_presets'])}")
|
||||
print(f" Shared: {result['shared']['mod_count']} | ", end="")
|
||||
print(" ".join(f"{k}: {v['mod_count']} unique" for k, v in result["unique"].items()))
|
||||
print(f" -> {cfg.comparison}")
|
||||
|
||||
|
||||
def step_fetch(cfg) -> None:
|
||||
if not cfg.comparison.exists():
|
||||
print(f" ERROR: {cfg.comparison} not found. Run parse + compare first.")
|
||||
return
|
||||
|
||||
comparison = json.loads(cfg.comparison.read_text(encoding="utf-8"))
|
||||
queue: list[tuple[dict, str]] = []
|
||||
for mod in comparison["shared"]["mods"]:
|
||||
queue.append((mod, "shared"))
|
||||
for preset_name, data in comparison["unique"].items():
|
||||
for mod in data["mods"]:
|
||||
queue.append((mod, preset_name))
|
||||
|
||||
print(f" Building server index...")
|
||||
index = build_server_index(cfg.server_url, cfg.server_auth)
|
||||
print(f" Indexed {len(index['by_steam_id'])} mods\n")
|
||||
|
||||
session = make_session(cfg.server_auth)
|
||||
resolved = []
|
||||
for mod, group in queue:
|
||||
url = find_mod_folder(mod, index)
|
||||
if url:
|
||||
resolved.append((mod, group, url))
|
||||
|
||||
# Save missing report
|
||||
report = build_missing_report(comparison, index)
|
||||
save_missing_report(report, cfg.missing_report)
|
||||
print(f" {len(resolved)} / {len(queue)} mods found on server")
|
||||
if report["missing"]:
|
||||
print(f" {report['missing']} missing — saved to {cfg.missing_report}")
|
||||
|
||||
if not resolved:
|
||||
print(" Nothing to download.")
|
||||
return
|
||||
|
||||
# Scan + download
|
||||
print()
|
||||
mod_file_lists = []
|
||||
for mod, group, folder_url in resolved:
|
||||
folder_name = folder_url.rstrip("/").split("/")[-1]
|
||||
dest_path = cfg.downloads / group / folder_name
|
||||
files = list_mod_files(folder_url, session)
|
||||
mod_file_lists.append((mod, group, folder_url, dest_path, files))
|
||||
|
||||
total_bytes = 0
|
||||
with tqdm(total=len(mod_file_lists), unit="mod", desc=" Fetching", position=0, dynamic_ncols=True) as mod_bar:
|
||||
for mod, group, folder_url, dest_path, files in mod_file_lists:
|
||||
folder_name = folder_url.rstrip("/").split("/")[-1]
|
||||
mod_bytes = 0
|
||||
for rel, file_url, size in files:
|
||||
dest_file = dest_path / rel
|
||||
if dest_file.exists():
|
||||
continue
|
||||
with tqdm(
|
||||
total=size if size else None,
|
||||
unit="B", unit_scale=True, unit_divisor=1024,
|
||||
desc=f" {rel[-40:]:40s}",
|
||||
position=1, leave=False, dynamic_ncols=True,
|
||||
) as file_bar:
|
||||
n = download_file(file_url, dest_file, session,
|
||||
on_chunk=lambda b: file_bar.update(b))
|
||||
mod_bytes += n
|
||||
total_bytes += mod_bytes
|
||||
mod_bar.update(1)
|
||||
|
||||
print(f"\n Done. {len(mod_file_lists)} mods {_fmt_bytes(total_bytes)}")
|
||||
|
||||
|
||||
def step_link(cfg, groups: list[str]) -> None:
|
||||
if not cfg.arma_dir.exists():
|
||||
print(f" NOTE: Arma dir not found ({cfg.arma_dir}) — skipping link step.")
|
||||
return
|
||||
|
||||
for group in groups:
|
||||
group_dir = cfg.downloads / group
|
||||
if not group_dir.is_dir():
|
||||
print(f" SKIP {group} — folder not found")
|
||||
continue
|
||||
result = link_group(group_dir, cfg.arma_dir)
|
||||
print(f" {group:<32} "
|
||||
f"{result['linked']} linked, "
|
||||
f"{result['already_linked']} already linked"
|
||||
+ (f", {result['failed']} failed" if result["failed"] else ""))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main() -> None:
|
||||
cfg = load_config()
|
||||
|
||||
parser = argparse.ArgumentParser(description="Run the full mod management pipeline.")
|
||||
parser.add_argument("--skip-parse", action="store_true")
|
||||
parser.add_argument("--skip-compare", action="store_true")
|
||||
parser.add_argument("--skip-fetch", action="store_true")
|
||||
parser.add_argument("--skip-link", action="store_true")
|
||||
parser.add_argument("--group", "-g", metavar="GROUP",
|
||||
help="Link only this group (default: all groups in downloads/)")
|
||||
args = parser.parse_args()
|
||||
|
||||
steps = [
|
||||
(not args.skip_parse, "Parse presets"),
|
||||
(not args.skip_compare, "Compare presets"),
|
||||
(not args.skip_fetch, "Fetch mods"),
|
||||
(not args.skip_link, "Link mods"),
|
||||
]
|
||||
active_count = sum(1 for run, _ in steps if run)
|
||||
step_num = 0
|
||||
|
||||
if args.skip_parse and args.skip_compare and args.skip_fetch and args.skip_link:
|
||||
print("All steps skipped — nothing to do.")
|
||||
sys.exit(0)
|
||||
|
||||
for (run, name), fn_args in zip(steps, [
|
||||
(cfg,),
|
||||
(cfg,),
|
||||
(cfg,),
|
||||
None, # handled separately
|
||||
]):
|
||||
if not run:
|
||||
continue
|
||||
step_num += 1
|
||||
_header(step_num, active_count, name)
|
||||
|
||||
if name == "Link mods":
|
||||
if args.group:
|
||||
groups = [args.group]
|
||||
else:
|
||||
groups = sorted(
|
||||
p.name for p in cfg.downloads.iterdir()
|
||||
if cfg.downloads.is_dir() and p.is_dir()
|
||||
) if cfg.downloads.is_dir() else []
|
||||
step_link(cfg, groups)
|
||||
elif name == "Parse presets":
|
||||
step_parse(cfg)
|
||||
elif name == "Compare presets":
|
||||
step_compare(cfg)
|
||||
elif name == "Fetch mods":
|
||||
step_fetch(cfg)
|
||||
|
||||
print(f"\n{'='*56}")
|
||||
print(" Pipeline complete.")
|
||||
print(f"{'='*56}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
149
sync_missing.py
Normal file
149
sync_missing.py
Normal file
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
CLI entry point: fetch mods that were previously missing but have since been
|
||||
added to the file server, then link them.
|
||||
|
||||
Flow:
|
||||
1. Load missing_report.json
|
||||
2. Re-check server index for newly available mods
|
||||
3. Download newly available mods to their correct group folder
|
||||
4. Update missing_report.json (remove the ones now downloaded)
|
||||
5. Run link_group for each affected group
|
||||
|
||||
Usage:
|
||||
python sync_missing.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
|
||||
from tqdm import tqdm
|
||||
|
||||
from arma_modlist_tools.compat import fix_console_encoding
|
||||
from arma_modlist_tools.config import load_config
|
||||
from arma_modlist_tools.fetcher import (
|
||||
build_server_index, find_mod_folder,
|
||||
list_mod_files, download_file, make_session,
|
||||
)
|
||||
from arma_modlist_tools.linker import link_group
|
||||
from arma_modlist_tools.reporter import save_missing_report
|
||||
|
||||
fix_console_encoding()
|
||||
|
||||
|
||||
def _fmt_bytes(n: int) -> str:
|
||||
for unit in ("B", "KB", "MB", "GB"):
|
||||
if n < 1024:
|
||||
return f"{n:.1f} {unit}"
|
||||
n /= 1024
|
||||
return f"{n:.1f} TB"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
cfg = load_config()
|
||||
|
||||
# ---- Load missing report ----
|
||||
if not cfg.missing_report.exists():
|
||||
print(f"\nERROR: {cfg.missing_report} not found.")
|
||||
print("Run report_missing.py or fetch_mods.py first.\n")
|
||||
sys.exit(1)
|
||||
|
||||
report = json.loads(cfg.missing_report.read_text(encoding="utf-8"))
|
||||
previously_missing = report["missing_mods"]
|
||||
|
||||
if not previously_missing:
|
||||
print("\nNo mods in missing report — nothing to sync.\n")
|
||||
sys.exit(0)
|
||||
|
||||
print(f"\nLoading missing report: {len(previously_missing)} mods previously missing")
|
||||
|
||||
# ---- Re-check server index ----
|
||||
print("Re-checking server index...")
|
||||
index = build_server_index(cfg.server_url, cfg.server_auth)
|
||||
print(f" {len(index['by_steam_id'])} mods indexed\n")
|
||||
|
||||
session = make_session(cfg.server_auth)
|
||||
|
||||
# ---- Resolve which are now available ----
|
||||
now_available: list[tuple[dict, str]] = [] # (mod, folder_url)
|
||||
still_missing: list[dict] = []
|
||||
|
||||
for mod in previously_missing:
|
||||
url = find_mod_folder(mod, index)
|
||||
if url:
|
||||
now_available.append((mod, url))
|
||||
else:
|
||||
still_missing.append(mod)
|
||||
|
||||
print(f" Newly available: {len(now_available)} mods")
|
||||
print(f" Still missing : {len(still_missing)} mods\n")
|
||||
|
||||
if not now_available:
|
||||
print("No new mods available on server yet.\n")
|
||||
sys.exit(0)
|
||||
|
||||
# ---- Download newly available mods ----
|
||||
affected_groups: set[str] = set()
|
||||
total_bytes = 0
|
||||
|
||||
with tqdm(total=len(now_available), unit="mod", desc="Downloading", position=0, dynamic_ncols=True) as mod_bar:
|
||||
for mod, folder_url in now_available:
|
||||
group = mod["group"]
|
||||
folder_name = folder_url.rstrip("/").split("/")[-1]
|
||||
dest_path = cfg.downloads / group / folder_name
|
||||
|
||||
tqdm.write(f" [+] {folder_name:<48} -> downloads/{group}/")
|
||||
|
||||
files = list_mod_files(folder_url, session)
|
||||
mod_bytes = 0
|
||||
|
||||
for rel, file_url, size in files:
|
||||
dest_file = dest_path / rel
|
||||
if dest_file.exists():
|
||||
continue
|
||||
with tqdm(
|
||||
total=size if size else None,
|
||||
unit="B", unit_scale=True, unit_divisor=1024,
|
||||
desc=f" {rel[-45:]:45s}",
|
||||
position=1, leave=False, dynamic_ncols=True,
|
||||
) as file_bar:
|
||||
n = download_file(
|
||||
file_url, dest_file, session,
|
||||
on_chunk=lambda b: file_bar.update(b),
|
||||
)
|
||||
mod_bytes += n
|
||||
|
||||
tqdm.write(f" Done {_fmt_bytes(mod_bytes)}")
|
||||
total_bytes += mod_bytes
|
||||
affected_groups.add(group)
|
||||
mod_bar.update(1)
|
||||
|
||||
print(f"\n Downloaded: {len(now_available)} mods {_fmt_bytes(total_bytes)}")
|
||||
|
||||
# ---- Update missing report ----
|
||||
report["missing_mods"] = still_missing
|
||||
report["missing"] = len(still_missing)
|
||||
report["on_server"] = report["total_mods"] - len(still_missing)
|
||||
save_missing_report(report, cfg.missing_report)
|
||||
print(f" Missing report updated: {len(still_missing)} still missing\n")
|
||||
|
||||
# ---- Link newly downloaded mods ----
|
||||
if not cfg.arma_dir.exists():
|
||||
print(f" NOTE: Arma dir not found ({cfg.arma_dir}) — skipping link step.")
|
||||
print(" Run link_mods.py manually when ready.\n")
|
||||
sys.exit(0)
|
||||
|
||||
print("Linking newly added mods...")
|
||||
for group in sorted(affected_groups):
|
||||
group_dir = cfg.downloads / group
|
||||
result = link_group(group_dir, cfg.arma_dir)
|
||||
print(f" {group:<30} "
|
||||
f"{result['linked']} new linked, "
|
||||
f"{result['already_linked']} already linked"
|
||||
+ (f", {result['failed']} failed" if result["failed"] else ""))
|
||||
|
||||
print("\nDone.\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1145
test_suite.py
Normal file
1145
test_suite.py
Normal file
File diff suppressed because it is too large
Load Diff
168
update_mods.py
Normal file
168
update_mods.py
Normal file
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
CLI entry point: re-download mod files that have changed on the server.
|
||||
|
||||
Use this after you have updated mod files on the Caddy server without
|
||||
changing the modlist structure (same mods, same Steam IDs, new file versions).
|
||||
|
||||
Detection method: compare local file sizes against server file sizes.
|
||||
A file is considered stale when it is missing locally OR its local size
|
||||
differs from the server-reported size. Use --force to ignore size checks
|
||||
and re-download every file unconditionally.
|
||||
|
||||
Usage:
|
||||
python update_mods.py # check all groups/mods
|
||||
python update_mods.py --group shared # limit to one group
|
||||
python update_mods.py --mod @ace # limit to one mod folder
|
||||
python update_mods.py --force # re-download everything
|
||||
python update_mods.py --force --group shared # force-update one group
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
from tqdm import tqdm
|
||||
|
||||
from arma_modlist_tools.compat import fix_console_encoding
|
||||
from arma_modlist_tools.config import load_config
|
||||
from arma_modlist_tools.fetcher import (
|
||||
build_server_index, find_mod_folder,
|
||||
list_mod_files, list_mod_updates,
|
||||
download_file, make_session,
|
||||
)
|
||||
|
||||
fix_console_encoding()
|
||||
|
||||
|
||||
def _fmt_bytes(n: int) -> str:
|
||||
for unit in ("B", "KB", "MB", "GB"):
|
||||
if n < 1024:
|
||||
return f"{n:.1f} {unit}"
|
||||
n /= 1024
|
||||
return f"{n:.1f} TB"
|
||||
|
||||
|
||||
def _collect_targets(cfg, group_filter: str | None, mod_filter: str | None) -> list[tuple[str, str, object]]:
|
||||
"""
|
||||
Walk downloads/ and return (group, folder_name, folder_path) for each
|
||||
@Mod folder that passes the group/mod filters.
|
||||
"""
|
||||
targets = []
|
||||
if not cfg.downloads.is_dir():
|
||||
return targets
|
||||
for group_dir in sorted(cfg.downloads.iterdir()):
|
||||
if not group_dir.is_dir():
|
||||
continue
|
||||
if group_filter and group_dir.name != group_filter:
|
||||
continue
|
||||
for mod_dir in sorted(group_dir.iterdir()):
|
||||
if not mod_dir.is_dir() or not mod_dir.name.startswith("@"):
|
||||
continue
|
||||
if mod_filter and mod_dir.name != mod_filter:
|
||||
continue
|
||||
targets.append((group_dir.name, mod_dir.name, mod_dir))
|
||||
return targets
|
||||
|
||||
|
||||
def main() -> None:
|
||||
cfg = load_config()
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Re-download mod files that have changed on the server."
|
||||
)
|
||||
parser.add_argument("--group", "-g", metavar="GROUP",
|
||||
help="Only check mods in this group folder (e.g. shared)")
|
||||
parser.add_argument("--mod", "-m", metavar="MOD",
|
||||
help="Only check this mod folder name (e.g. @ace)")
|
||||
parser.add_argument("--force", action="store_true",
|
||||
help="Re-download all files regardless of size match")
|
||||
args = parser.parse_args()
|
||||
|
||||
# ---- Collect local mod folders to check ----
|
||||
targets = _collect_targets(cfg, args.group, args.mod)
|
||||
if not targets:
|
||||
print("\nNo mod folders found matching the given filters.\n")
|
||||
sys.exit(0)
|
||||
|
||||
# ---- Build server index ----
|
||||
print(f"\nBuilding server index...")
|
||||
index = build_server_index(cfg.server_url, cfg.server_auth)
|
||||
print(f" {len(index['by_steam_id'])} mods indexed\n")
|
||||
|
||||
session = make_session(cfg.server_auth)
|
||||
|
||||
mode = "force" if args.force else "size-check"
|
||||
print(f" Mode: {mode}")
|
||||
print(f" Checking {len(targets)} mod folder(s)...\n")
|
||||
|
||||
# Column widths for the summary table
|
||||
COL_MOD = 44
|
||||
COL_GROUP = 24
|
||||
|
||||
total_checked = total_updated = total_bytes = 0
|
||||
not_on_server = []
|
||||
|
||||
for group, folder_name, mod_dir in targets:
|
||||
# Find this mod on the server by name (no steam_id available from local dir)
|
||||
mod_stub = {"name": folder_name, "steam_id": None}
|
||||
folder_url = find_mod_folder(mod_stub, index)
|
||||
|
||||
if not folder_url:
|
||||
tqdm.write(f" [?] {folder_name:<{COL_MOD}} {group:<{COL_GROUP}} not found on server")
|
||||
not_on_server.append(f"{group}/{folder_name}")
|
||||
continue
|
||||
|
||||
# Determine which files need downloading
|
||||
if args.force:
|
||||
stale = list_mod_files(folder_url, session)
|
||||
else:
|
||||
stale = list_mod_updates(folder_url, mod_dir, session)
|
||||
|
||||
all_files = list_mod_files(folder_url, session) if not args.force else stale
|
||||
checked = len(all_files) if not args.force else len(stale)
|
||||
|
||||
if not stale:
|
||||
print(f" [=] {folder_name:<{COL_MOD}} {group:<{COL_GROUP}} {checked} files up-to-date")
|
||||
total_checked += checked
|
||||
continue
|
||||
|
||||
# Download stale files
|
||||
mod_bytes = 0
|
||||
with tqdm(
|
||||
total=len(stale), unit="file",
|
||||
desc=f" {folder_name[-COL_MOD:]:<{COL_MOD}}",
|
||||
position=0, leave=True, dynamic_ncols=True,
|
||||
) as file_bar:
|
||||
for rel, url, size in stale:
|
||||
dest_file = mod_dir / rel
|
||||
with tqdm(
|
||||
total=size if size else None,
|
||||
unit="B", unit_scale=True, unit_divisor=1024,
|
||||
desc=f" {rel[-40:]:40s}",
|
||||
position=1, leave=False, dynamic_ncols=True,
|
||||
) as chunk_bar:
|
||||
n = download_file(url, dest_file, session,
|
||||
on_chunk=lambda b: chunk_bar.update(b))
|
||||
mod_bytes += n
|
||||
file_bar.update(1)
|
||||
|
||||
total_checked += checked
|
||||
total_updated += len(stale)
|
||||
total_bytes += mod_bytes
|
||||
print(f" [+] {folder_name:<{COL_MOD}} {group:<{COL_GROUP}} "
|
||||
f"{checked} files {len(stale)} updated ({_fmt_bytes(mod_bytes)})")
|
||||
|
||||
print(f"\n{'='*56}")
|
||||
print(f" Total: {total_checked} files checked, "
|
||||
f"{total_updated} updated, "
|
||||
f"{_fmt_bytes(total_bytes)} downloaded")
|
||||
if not_on_server:
|
||||
print(f" Not found on server ({len(not_on_server)}): {', '.join(not_on_server)}")
|
||||
print(f"{'='*56}\n")
|
||||
|
||||
if total_updated == 0 and not not_on_server:
|
||||
print(" All mods are up-to-date.\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user