From ad7726d978fd4ac61b7761c305da933937d4750f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Gremaud?= Date: Fri, 16 Oct 2026 05:28:15 +0200 Subject: [PATCH] try to match to steam game --- .../management/commands/import_planning.py | 25 +++- polylan_submitter/games/services/planning.py | 86 +++++++++++- .../games/services/steam_store.py | 12 ++ polylan_submitter/src/Planning.vue | 51 ++++--- .../src/components/PlanningMultiSlot.vue | 127 ++++++++++++++++++ 5 files changed, 271 insertions(+), 30 deletions(-) create mode 100644 polylan_submitter/src/components/PlanningMultiSlot.vue diff --git a/polylan_submitter/games/management/commands/import_planning.py b/polylan_submitter/games/management/commands/import_planning.py index 1e5af5d..9a5a190 100644 --- a/polylan_submitter/games/management/commands/import_planning.py +++ b/polylan_submitter/games/management/commands/import_planning.py @@ -5,7 +5,12 @@ from django.core.management.base import BaseCommand, CommandError from django.db import transaction from events.models import Event -from games.services.planning import import_planning +from games.services.planning import ( + create_missing_steam_games, + import_planning, + slot_game_names, +) +from games.tasks import refresh_steam_games_task class Command(BaseCommand): @@ -17,6 +22,11 @@ class Command(BaseCommand): def add_arguments(self, parser): parser.add_argument("event_path", help="Path of the event, e.g. polylan-43") parser.add_argument("json_file", help="Planning JSON file, '-' for stdin") + parser.add_argument( + "--no-steam", + action="store_true", + help="Don't look up unknown games on the Steam store", + ) def handle(self, *args, **options): try: @@ -39,3 +49,16 @@ class Command(BaseCommand): f"Planning of {event}: {created} created, {updated} updated, {deleted} deleted" ) ) + + if options["no_steam"]: + return + + games = create_missing_steam_games(event, slot_game_names(entries)) + for game in games: + self.stdout.write(f"+ {game.name} (Steam app {game.steam_app_id})") + if games: + # Prices, blurb, ... for the games page + refresh_steam_games_task.enqueue(game_ids=[game.id for game in games]) + self.stdout.write( + self.style.SUCCESS(f"{len(games)} game(s) found on Steam and added to {event}") + ) diff --git a/polylan_submitter/games/services/planning.py b/polylan_submitter/games/services/planning.py index 2fbe83a..f388bbc 100644 --- a/polylan_submitter/games/services/planning.py +++ b/polylan_submitter/games/services/planning.py @@ -1,17 +1,101 @@ +import logging import re import unicodedata -from ..models import Game, PlanningSlot +import httpx +from django.utils.text import slugify + +from ..models import EventGame, Game, PlanningSlot +from .steam_store import search_store + +logger = logging.getLogger(__name__) + +# "Cosmoteer: Starship Architect & Commander" is still "Cosmoteer" +SUBTITLE_SEPARATOR = re.compile(r"\s*(?::|\s-\s|\s–\s|\s—\s)") def normalize_name(name: str) -> str: """'Towerfall: Ascension' and 'TowerFall Ascension' are the same game: no accents, no case, letters and digits only.""" + # Before NFKD, which turns ™ into "TM" + name = re.sub(r"[™®©]", "", name) name = unicodedata.normalize("NFKD", name) name = "".join(c for c in name if not unicodedata.combining(c)) return re.sub(r"[^0-9a-z]", "", name.casefold()) +def _steam_match(name: str) -> dict | None: + """The Steam store app named name (or name + a subtitle), if there's one. + Strict on purpose: "Jass" or "Crochet" must not end up as some random game.""" + wanted = normalize_name(name) + if not wanted: + return None + # Steam's search chokes on punctuation ("Puyo Puyo — Tetris" finds nothing) + term = " ".join(re.sub(r"[^\w\s]", " ", name).split()) + for item in search_store(term): + steam_name = item.get("name") or "" + if wanted in ( + normalize_name(steam_name), + normalize_name(SUBTITLE_SEPARATOR.split(steam_name, maxsplit=1)[0]), + ): + return item + return None + + +def _unique_path(name: str) -> str: + base = slugify(name)[:90] or "game" + path, n = base, 2 + while Game.objects.filter(path=path).exists(): + path, n = f"{base}-{n}", n + 1 + return path + + +def create_missing_steam_games(event, names: list[str]) -> list[Game]: + """For each name without a Game, look it up on the Steam store and create + the Game + its EventGame when found. Returns the created games.""" + known = {normalize_name(game.name) for game in Game.objects.all()} + created = [] + for name in dict.fromkeys(names): # unique, keeps order + if normalize_name(name) in known: + continue + try: + item = _steam_match(name) + except (httpx.HTTPError, ValueError) as exc: + logger.warning("Steam store search failed for %r: %s", name, exc) + continue + if item is None: + continue + + if Game.objects.filter(steam_app_id=item["id"]).exists(): + # Already there under another name, the planning won't match it + logger.warning( + "%r is Steam app %s, already a Game under another name", + name, + item["id"], + ) + continue + + # Named like in the planning, so the slot matches it + game = Game.objects.create( + name=name, steam_app_id=item["id"], path=_unique_path(name) + ) + EventGame.objects.get_or_create(event=event, game=game) + known.add(normalize_name(name)) + created.append(game) + return created + + +def slot_game_names(entries: list[dict]) -> list[str]: + """Names to look for in a planning: titles of single slots, game names of + multi-anims (their title, like "Fils rouges", isn't a game).""" + names = [] + for entry in entries: + if not entry.get("is_multi_anims"): + names.append(entry["title"]) + names += [a["name"] for a in entry.get("aandts") or []] + return names + + def _game_out(name: str, game: Game | None) -> dict: if game is None: return {"name": name} diff --git a/polylan_submitter/games/services/steam_store.py b/polylan_submitter/games/services/steam_store.py index dc26be4..ab5dcaf 100644 --- a/polylan_submitter/games/services/steam_store.py +++ b/polylan_submitter/games/services/steam_store.py @@ -11,6 +11,7 @@ logger = logging.getLogger(__name__) # Public store endpoint, no API key needed. Rate limited to ~200 calls / 5 min, # which is plenty for a handful of games every few hours. APP_DETAILS_URL = "https://store.steampowered.com/api/appdetails" +STORE_SEARCH_URL = "https://store.steampowered.com/api/storesearch/" class SteamStoreError(Exception): @@ -37,6 +38,17 @@ def fetch_app_details(steam_app_id: int) -> dict: return entry["data"] +def search_store(term: str) -> list[dict]: + """Store search results for term, as [{"id": app id, "name": ...}, ...].""" + response = httpx.get( + STORE_SEARCH_URL, + params={"term": term, "cc": settings.STEAM_STORE_COUNTRY, "l": "english"}, + timeout=10, + ) + response.raise_for_status() + return (response.json() or {}).get("items") or [] + + def refresh_steam_game(game: Game) -> None: """Pull the current store info for a Steam game into its steam_* fields.""" data = fetch_app_details(game.steam_app_id) diff --git a/polylan_submitter/src/Planning.vue b/polylan_submitter/src/Planning.vue index a8f5504..4356a95 100644 --- a/polylan_submitter/src/Planning.vue +++ b/polylan_submitter/src/Planning.vue @@ -6,6 +6,7 @@ import { useQuery } from "@tanstack/vue-query"; import { eventPlanningQuery } from "@/queries"; import type { PlanningSlotOut } from "./api/types.gen"; import PlanningCover from "./components/PlanningCover.vue"; +import PlanningMultiSlot from "./components/PlanningMultiSlot.vue"; interface Props { eventPath: string; @@ -117,28 +118,32 @@ const layout = computed(() => { // Unused columns are dropped const columnIds = [...new Set(list.map(({ slot }) => slot.column))].sort((a, b) => a - b); - const columns = columnIds.map((id) => - list + let left = AXIS_PX; + const columns = columnIds.map((id) => { + const placed = list .filter(({ slot }) => slot.column === id) .map(({ slot, b, e }) => ({ slot, top: yOf(b) + 3, height: yOf(e) - yOf(b) - 6, when: `${fmtTime.format(b)} – ${fmtTime.format(e)}`, - })), - ); + })); + // The multi-anims column is the big one, twice as wide + const width = placed.some(({ slot }) => slot.is_multi_anims) ? 2 * COL_PX + GAP_PX : COL_PX; + const column = { left, width, placed }; + left += width + GAP_PX; + return column; + }); return { height: y, - width: AXIS_PX + columns.length * (COL_PX + GAP_PX) - GAP_PX, + width: left - GAP_PX, days, ticks, breaks, columns, }; }); - -const columnLeft = (index: number) => AXIS_PX + index * (COL_PX + GAP_PX);