feat: add internal match detail links
Add internal match detail links for recent matches without safe external URLs while preserving external links when available.
This commit is contained in:
@@ -816,6 +816,83 @@ def list_recent_historical_matches(
|
||||
return items
|
||||
|
||||
|
||||
def get_historical_match_detail(
|
||||
*,
|
||||
server_slug: str,
|
||||
match_id: str,
|
||||
db_path: Path | None = None,
|
||||
) -> dict[str, object] | None:
|
||||
"""Return one persisted public-scoreboard match detail for the historical API layer."""
|
||||
normalized_server_slug = _stringify(server_slug)
|
||||
normalized_match_id = _stringify(match_id)
|
||||
if not normalized_server_slug or not normalized_match_id:
|
||||
return None
|
||||
resolved_path = initialize_historical_storage(db_path=db_path)
|
||||
with _connect(resolved_path) as connection:
|
||||
row = connection.execute(
|
||||
"""
|
||||
SELECT
|
||||
historical_servers.slug AS server_slug,
|
||||
historical_servers.display_name AS server_name,
|
||||
historical_matches.external_match_id,
|
||||
historical_matches.started_at,
|
||||
historical_matches.ended_at,
|
||||
historical_matches.map_pretty_name,
|
||||
historical_matches.map_name,
|
||||
historical_matches.allied_score,
|
||||
historical_matches.axis_score,
|
||||
historical_matches.raw_payload_ref,
|
||||
historical_servers.scoreboard_base_url,
|
||||
COUNT(historical_player_match_stats.id) AS player_count,
|
||||
SUM(COALESCE(historical_player_match_stats.time_seconds, 0)) AS total_time_seconds
|
||||
FROM historical_matches
|
||||
INNER JOIN historical_servers
|
||||
ON historical_servers.id = historical_matches.historical_server_id
|
||||
LEFT JOIN historical_player_match_stats
|
||||
ON historical_player_match_stats.historical_match_id = historical_matches.id
|
||||
WHERE historical_servers.slug = ?
|
||||
AND historical_matches.external_match_id = ?
|
||||
GROUP BY historical_matches.id
|
||||
LIMIT 1
|
||||
""",
|
||||
(normalized_server_slug, normalized_match_id),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
started_at = row["started_at"]
|
||||
ended_at = row["ended_at"]
|
||||
return {
|
||||
"server": {
|
||||
"slug": row["server_slug"],
|
||||
"name": row["server_name"],
|
||||
},
|
||||
"match_id": row["external_match_id"],
|
||||
"started_at": started_at,
|
||||
"ended_at": ended_at,
|
||||
"closed_at": ended_at or started_at,
|
||||
"duration_seconds": _calculate_match_duration_seconds(started_at, ended_at),
|
||||
"map": {
|
||||
"name": row["map_name"],
|
||||
"pretty_name": row["map_pretty_name"] or row["map_name"],
|
||||
},
|
||||
"result": {
|
||||
"allied_score": _coerce_int(row["allied_score"]),
|
||||
"axis_score": _coerce_int(row["axis_score"]),
|
||||
"winner": _resolve_match_winner(
|
||||
row["allied_score"],
|
||||
row["axis_score"],
|
||||
),
|
||||
},
|
||||
"player_count": int(row["player_count"] or 0),
|
||||
"total_time_seconds": _coerce_int(row["total_time_seconds"]),
|
||||
"capture_basis": "public-scoreboard-match",
|
||||
"match_url": _resolve_safe_match_url(
|
||||
row["raw_payload_ref"],
|
||||
row["scoreboard_base_url"],
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def list_historical_server_summaries(
|
||||
*,
|
||||
server_slug: str | None = None,
|
||||
@@ -3140,6 +3217,18 @@ def _resolve_safe_match_url(raw_payload_ref: object, scoreboard_base_url: object
|
||||
return candidate
|
||||
|
||||
|
||||
def _calculate_match_duration_seconds(started_at: object, ended_at: object) -> int | None:
|
||||
start_text = _stringify(started_at)
|
||||
end_text = _stringify(ended_at)
|
||||
if not start_text or not end_text:
|
||||
return None
|
||||
try:
|
||||
duration = _parse_timestamp(end_text) - _parse_timestamp(start_text)
|
||||
except ValueError:
|
||||
return None
|
||||
return max(0, int(duration.total_seconds()))
|
||||
|
||||
|
||||
def _start_of_week(value: datetime) -> datetime:
|
||||
normalized = value.astimezone(timezone.utc)
|
||||
midnight = normalized.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
@@ -40,6 +40,7 @@ from .historical_snapshots import (
|
||||
from .historical_storage import (
|
||||
ALL_SERVERS_SLUG,
|
||||
DEFAULT_HISTORICAL_SERVERS,
|
||||
get_historical_match_detail,
|
||||
get_historical_player_profile,
|
||||
list_historical_server_summaries,
|
||||
list_monthly_leaderboard,
|
||||
@@ -47,6 +48,7 @@ from .historical_storage import (
|
||||
list_weekly_leaderboard,
|
||||
list_weekly_top_kills,
|
||||
)
|
||||
from .rcon_historical_read_model import get_rcon_historical_match_detail
|
||||
from .normalizers import normalize_map_name
|
||||
from .storage import list_latest_snapshots, list_server_history, list_snapshot_history
|
||||
|
||||
@@ -473,6 +475,72 @@ def build_recent_historical_matches_payload(
|
||||
}
|
||||
|
||||
|
||||
def build_historical_match_detail_payload(
|
||||
*,
|
||||
server_slug: str,
|
||||
match_id: str,
|
||||
) -> dict[str, object]:
|
||||
"""Return available detail for one historical match without inventing external URLs."""
|
||||
if get_historical_data_source_kind() == SOURCE_KIND_RCON:
|
||||
item = get_rcon_historical_match_detail(
|
||||
server_key=server_slug,
|
||||
match_id=match_id,
|
||||
)
|
||||
if item is not None:
|
||||
return {
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"title": "Detalle de partida historica",
|
||||
"context": "historical-match-detail",
|
||||
"source": "rcon-historical-competitive-read-model",
|
||||
"found": True,
|
||||
**build_source_policy(
|
||||
primary_source=SOURCE_KIND_RCON,
|
||||
selected_source=SOURCE_KIND_RCON,
|
||||
source_attempts=[
|
||||
build_source_attempt(
|
||||
source=SOURCE_KIND_RCON,
|
||||
role="primary",
|
||||
status="success",
|
||||
reason="historical-match-detail-served-by-rcon",
|
||||
)
|
||||
],
|
||||
),
|
||||
"item": item,
|
||||
},
|
||||
}
|
||||
|
||||
item = get_historical_match_detail(server_slug=server_slug, match_id=match_id)
|
||||
return {
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"title": "Detalle de partida historica",
|
||||
"context": "historical-match-detail",
|
||||
"source": "historical-crcon-storage",
|
||||
"found": item is not None,
|
||||
**(
|
||||
_resolve_historical_fallback_policy(
|
||||
fallback_reason="rcon-historical-read-model-has-no-match-detail"
|
||||
)
|
||||
if get_historical_data_source_kind() == SOURCE_KIND_RCON
|
||||
else build_source_policy(
|
||||
primary_source=SOURCE_KIND_PUBLIC_SCOREBOARD,
|
||||
selected_source=SOURCE_KIND_PUBLIC_SCOREBOARD,
|
||||
source_attempts=[
|
||||
build_source_attempt(
|
||||
source=SOURCE_KIND_PUBLIC_SCOREBOARD,
|
||||
role="primary",
|
||||
status="success" if item is not None else "empty",
|
||||
reason="historical-match-detail-served-by-public-scoreboard",
|
||||
)
|
||||
],
|
||||
)
|
||||
),
|
||||
"item": item,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def build_monthly_mvp_payload(
|
||||
*,
|
||||
limit: int = 10,
|
||||
|
||||
@@ -8,6 +8,7 @@ from .historical_storage import ALL_SERVERS_SLUG
|
||||
from .normalizers import normalize_map_name
|
||||
from .rcon_historical_storage import (
|
||||
find_rcon_historical_competitive_window,
|
||||
get_rcon_historical_competitive_window_by_session,
|
||||
list_rcon_historical_competitive_summary_rows,
|
||||
list_rcon_historical_competitive_windows,
|
||||
)
|
||||
@@ -123,6 +124,48 @@ def get_rcon_historical_competitive_match_context(
|
||||
)
|
||||
|
||||
|
||||
def get_rcon_historical_match_detail(
|
||||
*,
|
||||
server_key: str,
|
||||
match_id: str,
|
||||
) -> dict[str, object] | None:
|
||||
"""Return one RCON competitive window as a match-detail compatible payload."""
|
||||
item = get_rcon_historical_competitive_window_by_session(
|
||||
server_key=server_key,
|
||||
session_key=match_id,
|
||||
)
|
||||
if item is None:
|
||||
return None
|
||||
return {
|
||||
"server": {
|
||||
"slug": item["target_key"],
|
||||
"name": item["display_name"],
|
||||
"external_server_id": item["external_server_id"],
|
||||
"region": item["region"],
|
||||
},
|
||||
"match_id": item["session_key"],
|
||||
"started_at": item["first_seen_at"],
|
||||
"ended_at": item["last_seen_at"],
|
||||
"closed_at": item["last_seen_at"],
|
||||
"duration_seconds": item.get("duration_seconds"),
|
||||
"map": {
|
||||
"name": item.get("map_name"),
|
||||
"pretty_name": normalize_map_name(item.get("map_pretty_name") or item.get("map_name")),
|
||||
},
|
||||
"result": {
|
||||
"allied_score": None,
|
||||
"axis_score": None,
|
||||
"winner": None,
|
||||
},
|
||||
"player_count": int(round(float(item.get("average_players") or 0))),
|
||||
"peak_players": item.get("peak_players"),
|
||||
"sample_count": item.get("sample_count"),
|
||||
"capture_basis": "rcon-competitive-window",
|
||||
"capabilities": item.get("capabilities"),
|
||||
"match_url": None,
|
||||
}
|
||||
|
||||
|
||||
def _build_server_summary(item: dict[str, object]) -> dict[str, object]:
|
||||
sample_count = int(item.get("sample_count") or 0)
|
||||
first_last_points = list_rcon_historical_recent_activity(
|
||||
|
||||
@@ -662,6 +662,79 @@ def find_rcon_historical_competitive_window(
|
||||
}
|
||||
|
||||
|
||||
def get_rcon_historical_competitive_window_by_session(
|
||||
*,
|
||||
server_key: str,
|
||||
session_key: str,
|
||||
db_path: Path | None = None,
|
||||
) -> dict[str, object] | None:
|
||||
"""Return one persisted competitive RCON window by its synthetic session key."""
|
||||
normalized_session_key = str(session_key or "").strip()
|
||||
if not normalized_session_key:
|
||||
return None
|
||||
resolved_path = _resolve_db_path(db_path)
|
||||
aliases = _expand_target_key_aliases(server_key)
|
||||
alias_placeholders = ", ".join("?" for _ in aliases)
|
||||
try:
|
||||
with _connect_readonly(resolved_path) as connection:
|
||||
row = connection.execute(
|
||||
f"""
|
||||
SELECT
|
||||
targets.target_key,
|
||||
targets.external_server_id,
|
||||
targets.display_name,
|
||||
targets.region,
|
||||
windows.session_key,
|
||||
windows.map_name,
|
||||
windows.map_pretty_name,
|
||||
windows.first_seen_at,
|
||||
windows.last_seen_at,
|
||||
windows.sample_count,
|
||||
windows.total_players,
|
||||
windows.peak_players,
|
||||
windows.confidence_mode,
|
||||
windows.capabilities_json
|
||||
FROM rcon_historical_competitive_windows AS windows
|
||||
INNER JOIN rcon_historical_targets AS targets
|
||||
ON targets.id = windows.target_id
|
||||
WHERE windows.session_key = ?
|
||||
AND (
|
||||
targets.target_key IN ({alias_placeholders})
|
||||
OR targets.external_server_id IN ({alias_placeholders})
|
||||
)
|
||||
LIMIT 1
|
||||
""",
|
||||
[normalized_session_key, *aliases, *aliases],
|
||||
).fetchone()
|
||||
except sqlite3.OperationalError:
|
||||
return None
|
||||
if row is None:
|
||||
return None
|
||||
sample_count = int(row["sample_count"] or 0)
|
||||
return {
|
||||
"target_key": row["target_key"],
|
||||
"external_server_id": row["external_server_id"],
|
||||
"display_name": row["display_name"],
|
||||
"region": row["region"],
|
||||
"session_key": row["session_key"],
|
||||
"first_seen_at": row["first_seen_at"],
|
||||
"last_seen_at": row["last_seen_at"],
|
||||
"duration_seconds": _calculate_duration_seconds(
|
||||
row["first_seen_at"],
|
||||
row["last_seen_at"],
|
||||
),
|
||||
"map_name": row["map_name"],
|
||||
"map_pretty_name": row["map_pretty_name"] or row["map_name"],
|
||||
"sample_count": sample_count,
|
||||
"average_players": round((int(row["total_players"] or 0) / sample_count), 2)
|
||||
if sample_count > 0
|
||||
else 0.0,
|
||||
"peak_players": int(row["peak_players"] or 0),
|
||||
"confidence_mode": row["confidence_mode"],
|
||||
"capabilities": _deserialize_json_object(row["capabilities_json"]),
|
||||
}
|
||||
|
||||
|
||||
def _connect(db_path: Path) -> sqlite3.Connection:
|
||||
return connect_sqlite_writer(db_path)
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from .payloads import (
|
||||
build_error_payload,
|
||||
build_health_payload,
|
||||
build_historical_leaderboard_payload,
|
||||
build_historical_match_detail_payload,
|
||||
build_monthly_mvp_payload,
|
||||
build_monthly_mvp_v2_payload,
|
||||
build_monthly_leaderboard_payload,
|
||||
@@ -254,6 +255,19 @@ def resolve_get_payload(path: str) -> tuple[HTTPStatus | None, dict[str, object]
|
||||
server_slug=server_slug,
|
||||
)
|
||||
|
||||
if parsed.path == "/api/historical/matches/detail":
|
||||
params = parse_qs(parsed.query)
|
||||
server_slug = params.get("server", [None])[0]
|
||||
match_id = params.get("match", [None])[0]
|
||||
if not server_slug:
|
||||
return HTTPStatus.BAD_REQUEST, build_error_payload("Server parameter is required")
|
||||
if not match_id:
|
||||
return HTTPStatus.BAD_REQUEST, build_error_payload("Match parameter is required")
|
||||
return HTTPStatus.OK, build_historical_match_detail_payload(
|
||||
server_slug=server_slug,
|
||||
match_id=match_id,
|
||||
)
|
||||
|
||||
if parsed.path == "/api/historical/server-summary":
|
||||
server_slug = parse_qs(parsed.query).get("server", [None])[0]
|
||||
return HTTPStatus.OK, build_historical_server_summary_payload(server_slug=server_slug)
|
||||
|
||||
Reference in New Issue
Block a user