diff --git a/server/src/plugins/lolstats/index.ts b/server/src/plugins/lolstats/index.ts index 89d40f7..0f1f05c 100644 --- a/server/src/plugins/lolstats/index.ts +++ b/server/src/plugins/lolstats/index.ts @@ -2,7 +2,7 @@ import type express from 'express'; import type { Plugin, PluginContext } from '../../core/plugin.js'; import { sseBroadcast } from '../../core/sse.js'; import { getState, setState } from '../../core/persistence.js'; -import { getProfile, getMatches, getMatchDetail, REGIONS } from './opgg-api.js'; +import { getProfile, getMatches, getMatchDetail, renewSummoner, REGIONS } from './opgg-api.js'; import type { RecentSearch } from './types.js'; // ── Recent searches ── @@ -105,6 +105,21 @@ const lolstatsPlugin: Plugin = { } }); + // ── Summoner Renewal (refresh op.gg data) ── + app.post('/api/lolstats/renew', async (req, res) => { + const { gameName, tagLine, region } = req.query as Record; + if (!gameName || !tagLine || !region) { + return res.status(400).json({ error: 'gameName, tagLine, region required' }); + } + try { + const result = await renewSummoner(gameName, tagLine, region); + res.json(result); + } catch (e: any) { + console.error('[LoLStats] Renewal error:', e.message); + res.status(502).json({ error: e.message }); + } + }); + // ── Recent Searches ── app.get('/api/lolstats/recent', (_req, res) => { res.json(getRecent()); diff --git a/server/src/plugins/lolstats/opgg-api.ts b/server/src/plugins/lolstats/opgg-api.ts index 4fde86c..0a06d2c 100644 --- a/server/src/plugins/lolstats/opgg-api.ts +++ b/server/src/plugins/lolstats/opgg-api.ts @@ -1,6 +1,7 @@ /** * op.gg MCP Client — calls the public op.gg MCP server at https://mcp-api.op.gg/mcp * Uses JSON-RPC 2.0 over StreamableHTTP (MCP protocol). + * Also uses op.gg REST API for summoner renewal (data refresh). * No API key needed. */ @@ -10,6 +11,7 @@ import type { } from './types.js'; const MCP_URL = 'https://mcp-api.op.gg/mcp'; +const OPGG_API = 'https://lol-api-summoner.op.gg/api'; let rpcId = 1; // ── Regions ── @@ -334,3 +336,108 @@ export async function getMatchDetail( const parsed = parseClassNotation(raw); return parsed?.game_detail ?? parsed?.data?.game_detail ?? null; } + +// ── Summoner Renewal (op.gg REST API) ── +// Triggers a data refresh on op.gg's servers so the MCP returns fresh data. + +/** + * Look up the op.gg-internal summoner_id for a Riot ID. + * Required for the renewal endpoint. + */ +async function getSummonerId( + gameName: string, tagLine: string, region: string, +): Promise { + const regionLower = region.toLowerCase(); + const riotId = `${gameName}-${tagLine}`; + const url = `${OPGG_API}/v3/${regionLower}/summoners?riot_id=${encodeURIComponent(riotId)}&hl=en_US`; + + console.log(`[LoLStats] Looking up summoner_id: ${url}`); + + const res = await fetch(url, { + headers: { 'Accept': 'application/json' }, + }); + + if (!res.ok) { + throw new Error(`op.gg summoner lookup HTTP ${res.status}`); + } + + const json = await res.json() as any; + const summonerId = json?.data?.summoner_id ?? json?.data?.id ?? json?.summoner_id; + + if (!summonerId) { + // Try to find it in a different structure + const alt = json?.data?.[0]?.summoner_id ?? json?.[0]?.summoner_id; + if (alt) return String(alt); + console.log('[LoLStats] Summoner lookup response:', JSON.stringify(json).slice(0, 500)); + throw new Error('Could not find summoner_id in op.gg response'); + } + + return String(summonerId); +} + +/** + * Trigger a data renewal on op.gg and poll until complete. + * Returns the last_updated_at timestamp on success. + */ +export async function renewSummoner( + gameName: string, tagLine: string, region: string, +): Promise<{ renewed: boolean; last_updated_at: string; message?: string }> { + const regionLower = region.toLowerCase(); + + // Step 1: Get summoner_id + let summonerId: string; + try { + summonerId = await getSummonerId(gameName, tagLine, region); + } catch (e: any) { + console.error('[LoLStats] Summoner lookup failed:', e.message); + throw new Error(`Summoner not found on op.gg: ${e.message}`); + } + + console.log(`[LoLStats] Triggering renewal for summoner_id=${summonerId} region=${regionLower}`); + + // Step 2: POST renewal + const renewUrl = `${OPGG_API}/${regionLower}/summoners/${encodeURIComponent(summonerId)}/renewal`; + const maxPolls = 10; + + for (let attempt = 0; attempt < maxPolls; attempt++) { + const res = await fetch(renewUrl, { + method: 'POST', + headers: { 'Accept': 'application/json' }, + }); + + const json = await res.json() as any; + + // Cooldown (already renewed recently) — status 200 but with message + if (json.message?.includes('Already renewed') || json.message?.includes('already')) { + console.log(`[LoLStats] Already renewed recently, last_updated_at=${json.last_updated_at}`); + return { + renewed: true, + last_updated_at: json.last_updated_at ?? '', + message: json.message, + }; + } + + // Check if finish + const data = json.data ?? json; + if (data.finish === true) { + console.log(`[LoLStats] Renewal complete, last_updated_at=${data.last_updated_at}`); + return { + renewed: true, + last_updated_at: data.last_updated_at ?? '', + }; + } + + // Not finished — wait and poll again + const delay = Math.min(data.delay ?? 1000, 3000); + console.log(`[LoLStats] Renewal in progress, waiting ${delay}ms (attempt ${attempt + 1}/${maxPolls})`); + await new Promise(r => setTimeout(r, delay)); + } + + // Timeout — renewal took too long + console.warn('[LoLStats] Renewal polling timeout'); + return { + renewed: false, + last_updated_at: '', + message: 'Renewal timed out — data may still be updating', + }; +} diff --git a/web/src/plugins/lolstats/LolstatsTab.tsx b/web/src/plugins/lolstats/LolstatsTab.tsx index 8409abe..fc7fd05 100644 --- a/web/src/plugins/lolstats/LolstatsTab.tsx +++ b/web/src/plugins/lolstats/LolstatsTab.tsx @@ -111,8 +111,11 @@ export default function LolstatsTab({ data }: { data: any }) { const [expandedMatch, setExpandedMatch] = useState(null); const [matchDetails, setMatchDetails] = useState>({}); const [loadingMore, setLoadingMore] = useState(false); + const [renewing, setRenewing] = useState(false); + const [lastUpdated, setLastUpdated] = useState(null); const searchRef = useRef(null); + const currentSearchRef = useRef<{ gameName: string; tagLine: string; region: string } | null>(null); // Load regions useEffect(() => { @@ -127,8 +130,24 @@ export default function LolstatsTab({ data }: { data: any }) { if (data.regions && !regions.length) setRegions(data.regions); }, [data]); + // ── Renewal ── + const doRenew = useCallback(async (gameName: string, tagLine: string, searchRegion: string) => { + setRenewing(true); + try { + const qs = `gameName=${encodeURIComponent(gameName)}&tagLine=${encodeURIComponent(tagLine)}®ion=${searchRegion}`; + const res = await fetch(`/api/lolstats/renew?${qs}`, { method: 'POST' }); + if (res.ok) { + const data = await res.json(); + if (data.last_updated_at) setLastUpdated(data.last_updated_at); + return data.renewed ?? false; + } + } catch {} + setRenewing(false); + return false; + }, []); + // ── Search ── - const doSearch = useCallback(async (gn?: string, tl?: string, rg?: string) => { + const doSearch = useCallback(async (gn?: string, tl?: string, rg?: string, skipRenew = false) => { let gameName = gn ?? ''; let tagLine = tl ?? ''; const searchRegion = rg ?? region; @@ -152,6 +171,14 @@ export default function LolstatsTab({ data }: { data: any }) { setExpandedMatch(null); setMatchDetails({}); + // Track current search for update button + currentSearchRef.current = { gameName, tagLine, region: searchRegion }; + + // Auto-renew on first search (trigger op.gg refresh in background) + if (!skipRenew) { + doRenew(gameName, tagLine, searchRegion).finally(() => setRenewing(false)); + } + try { const qs = `gameName=${encodeURIComponent(gameName)}&tagLine=${encodeURIComponent(tagLine)}®ion=${searchRegion}`; @@ -168,6 +195,7 @@ export default function LolstatsTab({ data }: { data: any }) { const profileData = await profileRes.json(); setProfile(profileData); + if (profileData.updated_at) setLastUpdated(profileData.updated_at); if (matchesRes.ok) { const matchesData = await matchesRes.json(); @@ -177,7 +205,23 @@ export default function LolstatsTab({ data }: { data: any }) { setError(e.message); } setLoading(false); - }, [searchText, region]); + }, [searchText, region, doRenew]); + + // ── Manual Update (renew + re-fetch) ── + const doUpdate = useCallback(async () => { + const cur = currentSearchRef.current; + if (!cur || renewing) return; + setRenewing(true); + try { + await doRenew(cur.gameName, cur.tagLine, cur.region); + // Wait a moment for op.gg MCP to pick up the renewed data + await new Promise(r => setTimeout(r, 1500)); + // Re-fetch + await doSearch(cur.gameName, cur.tagLine, cur.region, true); + } finally { + setRenewing(false); + } + }, [doRenew, doSearch, renewing]); // ── Load more matches ── const loadMore = useCallback(async () => { @@ -384,7 +428,19 @@ export default function LolstatsTab({ data }: { data: any }) { Ladder Rank #{profile.ladder_rank.rank.toLocaleString()} / {profile.ladder_rank.total?.toLocaleString()} )} + {lastUpdated && ( +
Updated {timeAgo(lastUpdated)} ago
+ )} + {/* Ranked Cards */} diff --git a/web/src/plugins/lolstats/lolstats.css b/web/src/plugins/lolstats/lolstats.css index 84d397f..337f28e 100644 --- a/web/src/plugins/lolstats/lolstats.css +++ b/web/src/plugins/lolstats/lolstats.css @@ -122,6 +122,43 @@ font-size: 11px; color: var(--text-faint); } +.lol-profile-updated { + font-size: 10px; + color: var(--text-faint); + margin-top: 2px; +} +.lol-profile-info { + flex: 1; + min-width: 0; +} + +/* ── Update Button ── */ +.lol-update-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + border: 1px solid var(--bg-tertiary); + border-radius: 8px; + background: var(--bg-primary); + color: var(--text-muted); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: border-color 0.2s, color 0.2s, background 0.2s; + white-space: nowrap; + flex-shrink: 0; +} +.lol-update-btn:hover { border-color: var(--accent); color: var(--text-normal); } +.lol-update-btn:disabled { opacity: 0.6; cursor: not-allowed; } +.lol-update-btn.renewing { border-color: var(--accent); color: var(--accent); } +.lol-update-icon { + font-size: 16px; + display: inline-block; +} +.lol-update-btn.renewing .lol-update-icon { + animation: lol-spin 1s linear infinite; +} /* ── Ranked Cards ── */ .lol-ranked-row {