Files
arma-modlist-tools/update_mods.py

191 lines
6.9 KiB
Python

#!/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
"""
from __future__ import annotations
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
total_removed = 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)
# Find local files that no longer exist on the server (orphans)
server_rel = {rel for rel, _, _ in all_files}
orphans = [
f for f in mod_dir.rglob("*") if f.is_file()
and str(f.relative_to(mod_dir)).replace("\\", "/") not in server_rel
]
if not stale and not orphans:
print(f" [=] {folder_name:<{COL_MOD}} {group:<{COL_GROUP}} {checked} files up-to-date")
total_checked += checked
continue
# Download stale files
mod_bytes = 0
if stale:
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)
# Remove orphan files
for orphan in orphans:
tqdm.write(f" [-] orphan removed: {orphan.relative_to(mod_dir)}")
orphan.unlink()
total_checked += checked
total_updated += len(stale)
total_bytes += mod_bytes
total_removed += len(orphans)
parts = [f"{checked} files"]
if stale:
parts.append(f"{len(stale)} updated ({_fmt_bytes(mod_bytes)})")
if orphans:
parts.append(f"{len(orphans)} orphan(s) removed")
print(f" [+] {folder_name:<{COL_MOD}} {group:<{COL_GROUP}} {' '.join(parts)}")
print(f"\n{'='*56}")
print(f" Total: {total_checked} files checked, "
f"{total_updated} updated, "
f"{total_removed} orphan(s) removed, "
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 total_removed == 0 and not not_on_server:
print(" All mods are up-to-date.\n")
if __name__ == "__main__":
main()