#!/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/: /.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()