R No.6
#!/usr/bin/env python3
import argparse
import re
import shutil
from pathlib import Path
NSP_PATTERN = re.compile(
r""" ^
(?P<name>.+?)\s*\[
(?P<titleid>[0-9A-Fa-f]{16})
\]\[
(?P<version>v\d+)
\]\[
(?P<type>Base|Update|DLC(?:\s*\d+)?)
\]\.(?P<ext>nsp|NSP)$
""",
re.VERBOSE,
)
INVALID_FS_CHARS = r'<>:"/\\|?*'
def sanitize_folder_name(name: str) -> str:
cleaned = " ".join(name.strip().split())
for ch in INVALID_FS_CHARS:
cleaned = cleaned.replace(ch, "_")
return cleaned.rstrip(" .")
def find_nsps(root: Path, recursive: bool):
if recursive:
yield from (p for p in root.rglob("*.nsp") if p.is_file())
yield from (p for p in root.rglob("*.NSP") if p.is_file())
else:
yield from (p for p in root.glob("*.nsp") if p.is_file())
yield from (p for p in root.glob("*.NSP") if p.is_file())
def parse(filename: str):
m = NSP_PATTERN.match(filename)
return m.groupdict() if m else None
def filesize(p: Path) -> int:
try:
return p.stat().st_size
except FileNotFoundError:
return -1
def move_with_collision_handling(src: Path, dst: Path, dry_run: bool) -> str:
"""
Returns one of: 'MOVED', 'SKIPPED_SAME', 'SKIPPED_MISSING', 'RENAMED', 'ERROR'
"""
# Source might have been moved already by a prior run/process
if not src.exists():
print(f"[SKIP] Source missing: {src}")
return "SKIPPED_MISSING"
# If destination exists and is the same size, assume duplicate and skip
if dst.exists():
ssz, dsz = filesize(src), filesize(dst)
if ssz >= 0 and ssz == dsz:
print(f"[SKIP] Same file already exists: {dst}")
return "SKIPPED_SAME"
# Otherwise, find a non-conflicting name
base, ext = dst.stem, dst.suffix
i = 1
new_dst = dst.parent / f"{base} ({i}){ext}"
while new_dst.exists():
# If we encounter same-sized file at a numbered name, skip as same
if filesize(new_dst) == ssz and ssz >= 0:
print(f"[SKIP] Same file already exists: {new_dst}")
return "SKIPPED_SAME"
i += 1
new_dst = dst.parent / f"{base} ({i}){ext}"
print(f"[MOVE] {src.name} -> {new_dst}")
if not dry_run:
try:
new_dst.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(src), str(new_dst))
except FileNotFoundError:
print(f"[SKIP] Source disappeared during move: {src}")
return "SKIPPED_MISSING"
except Exception as e:
print(f"[ERROR] {src} -> {new_dst}: {e}")
return "ERROR"
return "RENAMED"
# Normal move
print(f"[MOVE] {src.name} -> {dst}")
if not dry_run:
try:
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(src), str(dst))
except FileNotFoundError:
print(f"[SKIP] Source disappeared during move: {src}")
return "SKIPPED_MISSING"
except Exception as e:
print(f"[ERROR] {src} -> {dst}: {e}")
return "ERROR"
return "MOVED"
def main():
parser = argparse.ArgumentParser(
description="Group Nintendo Switch NSPs by game name into per-game folders (runs in current directory)."
)
parser.add_argument("--recursive", "-r", action="store_true", help="Include subfolders")
parser.add_argument("--dry-run", action="store_true", help="Show actions without moving files")
args = parser.parse_args()
root = Path.cwd()
print(f"Working directory: {root}")
files = list(find_nsps(root, args.recursive))
if not files:
print("No .nsp files found here. If they’re in subfolders, try --recursive.")
return
total = 0
grouped = 0
skipped_same = 0
skipped_missing = 0
renamed = 0
unmatched = 0
errors = 0
for path in files:
total += 1
info = parse(path.name)
if not info:
unmatched += 1
print(f"[UNMATCHED] {path.relative_to(root)}")
continue
game_folder = sanitize_folder_name(info["name"])
dst = root / game_folder / path.name
result = move_with_collision_handling(path, dst, args.dry_run)
if result == "MOVED":
grouped += 1
elif result == "RENAMED":
grouped += 1
renamed += 1
elif result == "SKIPPED_SAME":
skipped_same += 1
elif result == "SKIPPED_MISSING":
skipped_missing += 1
elif result == "ERROR":
errors += 1
print("\n== Summary ==")
print(f"Total NSP files : {total}")
print(f"Grouped (moved) : {grouped} (renamed due to collision: {renamed})")
print(f"Unmatched : {unmatched}")
print(f"Skipped (same file): {skipped_same}")
print(f"Skipped (missing) : {skipped_missing}")
print(f"Errors : {errors}")
if args.dry_run:
print("\n(Dry run — no files were moved.)")
if __name__ == "__main__":
main()