164 lines
5.7 KiB
Python
Executable File
164 lines
5.7 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
symlink_downloads.py — place in the "Sven Co-op" root folder alongside tags.json.
|
|
|
|
Workflow:
|
|
1. Scan svencoop_downloads/models/player/ for .mdl files whose stem matches a
|
|
model ID in tags.json (case-insensitive).
|
|
2. For each match, create a relative symlink in svencoop_addon/models/player/:
|
|
<id>/<id>.mdl -> ../../../../svencoop/models/player/helmet/helmet.mdl
|
|
3. Show matched folders, then prompt to delete them from svencoop_downloads.
|
|
The player/ root itself is never deleted.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import shutil
|
|
import sys
|
|
from collections import defaultdict
|
|
from pathlib import Path
|
|
|
|
HELMET_REL = Path("svencoop") / "models" / "player" / "helmet" / "helmet.mdl"
|
|
ADDON_PLAYERS = Path("svencoop_addon") / "models" / "player"
|
|
DL_PLAYERS = Path("svencoop_downloads") / "models" / "player"
|
|
SYMLINK_TARGET = Path("..") / ".." / ".." / ".." / HELMET_REL
|
|
|
|
|
|
def write_symlink(mdl_path: Path) -> str:
|
|
"""Create or update the symlink. Returns 'created', 'replaced', or 'ok'."""
|
|
target_str = str(SYMLINK_TARGET)
|
|
if mdl_path.is_symlink():
|
|
if os.readlink(mdl_path) == target_str:
|
|
return "ok"
|
|
mdl_path.unlink()
|
|
elif mdl_path.exists():
|
|
mdl_path.unlink()
|
|
mdl_path.parent.mkdir(parents=True, exist_ok=True)
|
|
os.symlink(target_str, mdl_path)
|
|
return "created"
|
|
|
|
|
|
def main():
|
|
dry_run = "--dry-run" in sys.argv
|
|
|
|
script_dir = Path(__file__).resolve().parent
|
|
tags_path = script_dir / "tags.json"
|
|
|
|
if not tags_path.exists():
|
|
sys.exit(f"tags.json not found next to script ({tags_path})")
|
|
|
|
helmet_abs = script_dir / HELMET_REL
|
|
if not helmet_abs.exists():
|
|
sys.exit(f"helmet.mdl not found: {helmet_abs}\nRun from the Sven Co-op root folder.")
|
|
|
|
dl_root = script_dir / DL_PLAYERS
|
|
if not dl_root.is_dir():
|
|
sys.exit(f"Downloads folder not found: {dl_root}")
|
|
|
|
addon_root = script_dir / ADDON_PLAYERS
|
|
|
|
with open(tags_path, encoding="utf-8") as f:
|
|
tags: dict[str, list[str]] = json.load(f)
|
|
|
|
# Build case-insensitive lookup: lowercase stem -> canonical tags.json ID
|
|
tag_lookup: dict[str, str] = {
|
|
model_id.lower(): model_id
|
|
for model_ids in tags.values()
|
|
for model_id in model_ids
|
|
}
|
|
|
|
# Scan downloads: collect all .mdl paths and identify matches
|
|
# folder_mdls: folder -> all .mdl files in that folder
|
|
# matched: list of (dl_mdl_path, canonical_tags_id)
|
|
folder_mdls: dict[Path, list[Path]] = defaultdict(list)
|
|
matched: list[tuple[Path, str]] = []
|
|
|
|
for mdl_path in sorted(dl_root.rglob("*.mdl")):
|
|
parent = mdl_path.parent
|
|
if parent == dl_root:
|
|
continue # skip .mdl files sitting directly in player/ (no subfolder to delete)
|
|
folder_mdls[parent].append(mdl_path)
|
|
tags_id = tag_lookup.get(mdl_path.stem.lower())
|
|
if tags_id:
|
|
matched.append((mdl_path, tags_id))
|
|
|
|
if not matched:
|
|
print("No downloaded .mdl files matched any model ID in tags.json.")
|
|
return
|
|
|
|
# --- Symlink pass ---
|
|
print(f"{'[DRY RUN] ' if dry_run else ''}Symlinking {len(matched)} matched model(s):\n")
|
|
counts = {"created": 0, "replaced": 0, "ok": 0, "error": 0}
|
|
|
|
for dl_mdl, tags_id in matched:
|
|
addon_mdl = addon_root / tags_id / f"{tags_id}.mdl"
|
|
if dry_run:
|
|
existing = "ok" if (addon_mdl.is_symlink() and os.readlink(addon_mdl) == str(SYMLINK_TARGET)) else "would link"
|
|
print(f" {existing:<12} {tags_id} (from {dl_mdl.relative_to(dl_root)})")
|
|
counts["created"] += 1
|
|
else:
|
|
try:
|
|
result = write_symlink(addon_mdl)
|
|
counts[result] += 1
|
|
label = {"created": "linked ", "replaced": "replaced ", "ok": "already ok"}.get(result, result)
|
|
print(f" {label} {tags_id}")
|
|
except OSError as exc:
|
|
counts["error"] += 1
|
|
print(f" ERROR {tags_id}: {exc}")
|
|
|
|
if not dry_run:
|
|
print(
|
|
f"\n {counts['created']} created, {counts['replaced']} replaced, "
|
|
f"{counts['ok']} already correct, {counts['error']} errors"
|
|
)
|
|
|
|
# --- Cleanup prompt ---
|
|
# Any folder containing at least one tagged .mdl is deleted entirely.
|
|
# The player/ root itself is never deleted.
|
|
folders_to_clean: dict[Path, list[Path]] = defaultdict(list)
|
|
for dl_mdl, _ in matched:
|
|
folders_to_clean[dl_mdl.parent].append(dl_mdl)
|
|
|
|
print(f"\nFolders to delete from svencoop_downloads ({len(folders_to_clean)}):\n")
|
|
for folder in sorted(folders_to_clean):
|
|
matched_here = folders_to_clean[folder]
|
|
other = [p for p in folder_mdls[folder] if p not in matched_here]
|
|
print(f" {folder.relative_to(script_dir)}/")
|
|
for m in matched_here:
|
|
print(f" [tagged] {m.name}")
|
|
for o in other:
|
|
print(f" [extra ] {o.name}")
|
|
|
|
print()
|
|
if dry_run:
|
|
print("[DRY RUN] Skipping delete prompt.")
|
|
return
|
|
|
|
try:
|
|
answer = input("Delete these folders from svencoop_downloads? [y/N] ").strip().lower()
|
|
except (EOFError, KeyboardInterrupt):
|
|
print("\nAborted.")
|
|
return
|
|
|
|
if answer != "y":
|
|
print("Skipped cleanup.")
|
|
return
|
|
|
|
deleted = 0
|
|
for folder in sorted(folders_to_clean):
|
|
if folder == dl_root:
|
|
print(f" SKIP (is player/ root): {folder}")
|
|
continue
|
|
try:
|
|
shutil.rmtree(folder)
|
|
print(f" deleted {folder.relative_to(script_dir)}/")
|
|
deleted += 1
|
|
except OSError as exc:
|
|
print(f" ERROR {folder.name}: {exc}")
|
|
|
|
print(f"\nDeleted {deleted} folder(s).")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|