Skip to content

Instantly share code, notes, and snippets.

@acarapetis
Last active February 24, 2024 20:19
Show Gist options
  • Save acarapetis/b5e9cf199ea2a468ace2f756ac287268 to your computer and use it in GitHub Desktop.
Save acarapetis/b5e9cf199ea2a468ace2f756ac287268 to your computer and use it in GitHub Desktop.
Stormgate replay renamer - See https://github.com/acarapetis/shroudstone for newest version
#!/usr/bin/env python3
# This script works, but a nicer version is now available at https://github.com/acarapetis/shroudstone
from pathlib import Path
from typing import NamedTuple
from urllib.request import urlopen
from datetime import datetime, timezone, timedelta
from time import sleep
import json
import pandas as pd
import re
replay_dir = Path(
# Set this to the correct path for your OS - you'll need to update your username and also the UUID at the end.
# Here I'm using WSL, thus the /mnt/c. If you're running python directly on windows it'd start with C:\ or similar.
r"/mnt/c/Users/Anthony/AppData/Local/Stormgate/Saved/Replays/c6b4eb4e-4994-4e96-b098-3e1953a02033"
)
my_player_id = (
# Set this to your player ID.
# To find this, find your player page by searching on https://stormgateworld.com/leaderboards/ranked_1v1
# and then extract the six-character gibberish string from the URL.
"R28aVH"
)
TOLERANCE = timedelta(seconds=90)
def main():
unrenamed_replays = [
x
for x in map(ReplayFile.from_path, replay_dir.glob("CL*.SGReplay"))
if x is not None
]
if not unrenamed_replays:
print(
"No replays found to rename! "
f"If you weren't expecting this, check your replay_dir '{replay_dir}' is correct."
)
return
earliest_time = min(x.time for x in unrenamed_replays)
matches = player_matches_since(my_player_id, earliest_time)
for r in unrenamed_replays:
match = find_match(matches, r)
if match is not None:
try:
rename_replay(r, match)
except Exception as e:
print(f"Unexpected error handling {r.path}: {e}")
else:
print(f"No match found for {r.path.name} in stormgateworld match history.")
def player_matches_since(player_id: str, time: datetime):
page = 1
matches = player_matches(player_id, page=page)
if matches is None:
raise Exception(
"Could not find any matches for {player_id=} - check this is correct!"
)
while matches.created_at.min() > time:
sleep(1) # Be nice to the API servers! You can wait a few seconds!
print(f"Got matches back to {matches.created_at.min()}, going further.")
page += 1
next_page = player_matches(player_id, page=page)
if next_page is None:
print(
"Warning: Could only fetch back to {matches.created_at.min()} "
"from stormgateworld but you have unrenamed replays from before this.",
)
return matches.reset_index(drop=True)
matches = pd.concat([matches, next_page])
return matches.reset_index(drop=True)
def player_matches(player_id: str, page: int):
url = f"https://api.stormgateworld.com/v0/players/{player_id}/matches?page={page}"
print(f"Fetching match history page {page}...")
with urlopen(url) as f:
data = json.load(f)["matches"]
if not data:
return None
data = [flatten_match(player_id, x) for x in data]
matches = pd.json_normalize([x for x in data if x is not None])
matches["ended_at"] = pd.to_datetime(matches["ended_at"])
matches["created_at"] = pd.to_datetime(matches["created_at"])
return matches
def flatten_match(player_id: str, match: dict):
try:
match["us"] = next(
p for p in match["players"] if p["player"]["player_id"] == player_id
)
match["them"] = next(
p for p in match["players"] if p["player"]["player_id"] != player_id
)
except Exception:
return None
del match["players"]
return match
class ReplayFile(NamedTuple):
path: Path
time: datetime
@staticmethod
def from_path(path: Path):
m = re.search(r"(\d\d\d\d)\.(\d\d)\.(\d\d)-(\d\d).(\d\d)", path.name)
if not m:
return None
time = (
datetime(*(int(x) for x in m.groups())) # type: ignore
.astimezone()
.astimezone(timezone.utc)
.replace(tzinfo=None)
)
return ReplayFile(path, time)
def find_match(matches: pd.DataFrame, replay: ReplayFile):
delta = (matches.created_at - replay.time).abs()
idxmin = delta.idxmin()
if delta[idxmin] < TOLERANCE:
return matches.iloc[idxmin]
def rename_replay(replay: ReplayFile, match):
us = match["us.player.nickname"]
them = match["them.player.nickname"]
try:
r1 = match["us.race"][0].upper()
except Exception:
r1 = "?"
try:
r2 = match["them.race"][0].upper()
except Exception:
r2 = "?"
try:
result = match["us.result"][0].upper()
except Exception:
result = "?"
try:
minutes, seconds = divmod(int(match["duration"]), 60)
duration = f"{minutes:02d}m{seconds:02d}s"
time = match["created_at"].strftime("%Y-%m-%d %H.%M")
except Exception:
duration = "??m??s"
time = replay.time.strftime("%Y-%m-%d %H.%M")
try:
newname = f"{time} {result} {duration} {us} {r1}v{r2} {them}.SGReplay"
target = replay.path.parent / newname
do_rename(replay.path, target)
except Exception as e:
print(f"Unexpected error renaming {replay.path}: {e}")
def do_rename(source: Path, target: Path):
if target.exists():
print(f"Not renaming {source}! {target.name} already exists!")
return
print(f"Renaming {source} => {target.name}.")
try:
source.rename(target)
except Exception as e:
# In case the error was due to weird characters in a player name:
new_name = sanitize_filename(target.name)
if new_name != target.name:
print(
f"Error renaming {source} => {target.name}, retrying with sanitized filename."
)
do_rename(source, target.parent / new_name)
else:
print(f"Error renaming {source} => {target.name}: {e}")
# These characters are forbidden in filenames on Linux or Windows:
bad_chars = re.compile(r'[<>:"/\\|?*\0]')
def sanitize_filename(filename: str) -> str:
"""Remove bad characters from a filename"""
return bad_chars.sub("", filename)
if __name__ == "__main__":
main()
@WinckelData
Copy link

Map names would be a great addition to the renaming! 👍

@acarapetis
Copy link
Author

I'm now getting map names out of the replays successfully, as well as double-checking the player nicknames. It's grown a bit big to be a gist now, so I've published it as a python package - https://github.com/acarapetis/shroudstone

@WinckelData
Copy link

Awesome Work, Haven't tried it yet only went a bit over the code on my phonr but I would highly appreciate a way to customize the renaming schema If that is not already implemented

@acarapetis
Copy link
Author

Read my mind, working on custom format strings already :)

@rojasreinold
Copy link

Great work here. I tried this out on linux+proton and it just worked by doing the pip install and just running it.

I did get some errors of games not found but I think its because the replays were against ai so they didn't have a game logged on stormgateworld.

output:

                    ERROR    No match found for CL45737-2024.02.18-20.00.SGReplay in stormgateworld match history.                                              renamer.py:86
                    ERROR    No match found for CL45737-2024.02.18-20.03.SGReplay in stormgateworld match history.                                              renamer.py:86

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment