try to match to steam game
This commit is contained in:
@@ -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}")
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<Placed>(({ 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);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -159,10 +164,13 @@ const columnLeft = (index: number) => AXIS_PX + index * (COL_PX + GAP_PX);
|
||||
<div v-else class="overflow-x-auto pb-4">
|
||||
<div class="relative mx-auto" :style="{ width: `${layout.width}px`, height: `${layout.height}px` }">
|
||||
<!-- Time axis: a header row per day, hour ticks on the side -->
|
||||
<!-- Above the cards: slots running past midnight go over the header row -->
|
||||
<div v-for="day in layout.days" :key="`day-${day.y}`"
|
||||
class="absolute left-0 right-0 border-b border-base-content/20 flex items-end pb-1 text-sm font-semibold uppercase tracking-wide"
|
||||
class="absolute left-0 right-0 z-20 pointer-events-none border-b border-base-content/20 flex items-end text-sm font-semibold uppercase tracking-wide"
|
||||
:style="{ top: `${day.y}px`, height: `${DAY_PX}px` }">
|
||||
{{ day.label }}
|
||||
<!-- Ends at the time axis and grows to the left, never over the cards -->
|
||||
<span class="absolute bottom-0 whitespace-nowrap bg-base-300 rounded-t px-2 py-0.5"
|
||||
:style="{ right: `calc(100% - ${AXIS_PX}px)` }">{{ day.label }}</span>
|
||||
</div>
|
||||
<template v-for="tick in layout.ticks" :key="tick.y">
|
||||
<span class="absolute -translate-y-1/2 text-xs text-base-content/40 text-right pr-2"
|
||||
@@ -181,19 +189,12 @@ const columnLeft = (index: number) => AXIS_PX + index * (COL_PX + GAP_PX);
|
||||
</div>
|
||||
|
||||
<!-- Slots -->
|
||||
<div v-for="(column, index) in layout.columns" :key="index" class="absolute top-0 h-full"
|
||||
:style="{ left: `${columnLeft(index)}px`, width: `${COL_PX}px` }">
|
||||
<template v-for="{ slot, top, height, when } in column" :key="slot.id">
|
||||
<!-- Multi-anims: one long card, its games stay in view while scrolling -->
|
||||
<div v-if="slot.is_multi_anims" class="planning-multi absolute inset-x-0 rounded-md p-2 shadow-xl"
|
||||
:style="{ top: `${top}px`, height: `${height}px`, '--slot-color': slot.color }">
|
||||
<div class="font-bold uppercase text-sm tracking-wide mb-2">{{ slot.title }}</div>
|
||||
<component :is="game.store_url ? 'a' : 'div'" v-for="game in slot.games" :key="game.name"
|
||||
:href="game.store_url || undefined" target="_blank" rel="noopener noreferrer"
|
||||
class="sticky top-2 block aspect-[2/3] rounded overflow-hidden mb-3 shadow-lg bg-base-200">
|
||||
<PlanningCover :game="game" :color="slot.color" />
|
||||
</component>
|
||||
</div>
|
||||
<div v-for="column in layout.columns" :key="column.left" class="absolute top-0 h-full"
|
||||
:style="{ left: `${column.left}px`, width: `${column.width}px` }">
|
||||
<template v-for="{ slot, top, height, when } in column.placed" :key="slot.id">
|
||||
<!-- Multi-anims: one long shiny card, its games follow the scroll -->
|
||||
<PlanningMultiSlot v-if="slot.is_multi_anims" :entry="slot" class="absolute inset-x-0"
|
||||
:style="{ top: `${top}px`, height: `${height}px` }" />
|
||||
|
||||
<component :is="slot.games[0]?.store_url ? 'a' : 'div'" v-else
|
||||
:href="slot.games[0]?.store_url || undefined" target="_blank" rel="noopener noreferrer"
|
||||
@@ -218,10 +219,4 @@ const columnLeft = (index: number) => AXIS_PX + index * (COL_PX + GAP_PX);
|
||||
.planning-break {
|
||||
background: repeating-linear-gradient(45deg, color-mix(in srgb, currentColor 6%, transparent) 0 6px, transparent 6px 12px);
|
||||
}
|
||||
|
||||
.planning-multi {
|
||||
background: linear-gradient(180deg,
|
||||
color-mix(in srgb, var(--slot-color) 30%, var(--color-base-200)),
|
||||
var(--color-base-200));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, useTemplateRef } from "vue";
|
||||
import { useElementBounding, useElementSize, useWindowSize } from "@vueuse/core";
|
||||
import type { PlanningSlotOut } from "../api/types.gen";
|
||||
import PlanningCover from "./PlanningCover.vue";
|
||||
|
||||
defineProps<{ entry: PlanningSlotOut }>();
|
||||
|
||||
const track = useTemplateRef<HTMLElement>("track");
|
||||
const stack = useTemplateRef<HTMLElement>("stack");
|
||||
|
||||
const { top: trackTop, height: trackHeight } = useElementBounding(track);
|
||||
const { height: stackHeight } = useElementSize(stack);
|
||||
const { height: windowHeight } = useWindowSize();
|
||||
|
||||
// The games follow the scroll: kept in the middle of the screen, but never
|
||||
// leaving the slot (stuck to its top/bottom when it's only partly visible)
|
||||
const offset = computed(() => {
|
||||
const wanted = windowHeight.value / 2 - stackHeight.value / 2 - trackTop.value;
|
||||
const max = Math.max(0, trackHeight.value - stackHeight.value);
|
||||
return Math.min(max, Math.max(0, wanted));
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="planning-multi h-full rounded-md p-[2px]" :style="{ '--slot-color': entry.color }">
|
||||
<div class="planning-multi-inner relative h-full rounded-[5px] p-2 flex flex-col overflow-hidden">
|
||||
<div class="planning-multi-title font-black uppercase text-sm tracking-widest mb-2">
|
||||
<i class="mdi mdi-star-four-points mr-1"></i>{{ entry.title }}
|
||||
</div>
|
||||
|
||||
<div ref="track" class="relative flex-1">
|
||||
<div ref="stack" class="absolute inset-x-0 grid grid-cols-2 gap-3"
|
||||
:style="{ transform: `translateY(${offset}px)` }">
|
||||
<component :is="game.store_url ? 'a' : 'div'" v-for="game in entry.games" :key="game.name"
|
||||
:href="game.store_url || undefined" target="_blank" rel="noopener noreferrer"
|
||||
class="block aspect-[2/3] rounded overflow-hidden shadow-lg bg-base-200
|
||||
transition-transform hover:scale-[1.05]">
|
||||
<PlanningCover :game="game" :color="entry.color" />
|
||||
</component>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Light sweeping down the whole slot -->
|
||||
<div class="planning-multi-sheen pointer-events-none absolute inset-0"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@property --planning-angle {
|
||||
syntax: "<angle>";
|
||||
initial-value: 0deg;
|
||||
inherits: false;
|
||||
}
|
||||
|
||||
/* Rotating gradient border + glow in the slot color */
|
||||
.planning-multi {
|
||||
background: conic-gradient(from var(--planning-angle),
|
||||
var(--slot-color), #ffd76a, var(--slot-color), #ffffff, var(--slot-color));
|
||||
box-shadow: 0 0 24px -4px var(--slot-color);
|
||||
animation: planning-spin 6s linear infinite, planning-glow 3s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.planning-multi-inner {
|
||||
background: linear-gradient(180deg,
|
||||
color-mix(in srgb, var(--slot-color) 35%, var(--color-base-200)),
|
||||
var(--color-base-200) 40%,
|
||||
color-mix(in srgb, var(--slot-color) 20%, var(--color-base-200)));
|
||||
}
|
||||
|
||||
.planning-multi-title {
|
||||
background: linear-gradient(90deg, #fff 20%, #ffd76a 40%, #fff 60%);
|
||||
background-size: 200% 100%;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
animation: planning-shimmer 3s linear infinite;
|
||||
}
|
||||
|
||||
.planning-multi-sheen {
|
||||
background: linear-gradient(180deg, transparent 0%, rgb(255 255 255 / 0.12) 50%, transparent 100%);
|
||||
background-size: 100% 600px;
|
||||
background-repeat: no-repeat;
|
||||
animation: planning-sheen 7s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes planning-spin {
|
||||
to {
|
||||
--planning-angle: 360deg;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes planning-glow {
|
||||
to {
|
||||
box-shadow: 0 0 40px 0 var(--slot-color);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes planning-shimmer {
|
||||
from {
|
||||
background-position: 100% 0;
|
||||
}
|
||||
|
||||
to {
|
||||
background-position: -100% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes planning-sheen {
|
||||
from {
|
||||
background-position: 0 -600px;
|
||||
}
|
||||
|
||||
to {
|
||||
background-position: 0 calc(100% + 600px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
.planning-multi,
|
||||
.planning-multi-title,
|
||||
.planning-multi-sheen {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user