Playtime from PlaytimeHistory's database (SAW_PT_DSN)

A public Playtime tab and player-drawer playtime, and staff-only figures
(who's on, staff activity, disconnect reasons, countries, names, sessions)
for @css/generic on the staff panel and on the public site when signed in.
Hidden unless SAW_PT_DSN is set and its tables are there.
This commit is contained in:
Astra 2026-10-04 21:05:57 +01:00
parent daa7ec2dbc
commit 6d0fc6e463
18 changed files with 2238 additions and 12 deletions

View file

@ -0,0 +1,771 @@
// Package playtime reads PlaytimeHistory's database (plugins/PlaytimeHistory in the cs2 repo): who
// played when, on which team and map. It's a separate database from SimpleAdmin's, read-only from
// here. All its times are UTC.
package playtime
import (
"context"
"database/sql"
"fmt"
"strconv"
"strings"
"time"
"github.com/go-sql-driver/mysql"
)
// Teams as PlaytimeHistory records them.
const (
TeamUnassigned = 0
TeamSpectator = 1
TeamT = 2
TeamCT = 3
)
// End kinds of a session (pt_sessions.end_kind).
const (
EndOpen = 0
EndDisconnect = 1
EndMapChange = 2
EndUnload = 3
EndStale = 4 // closed at its last heartbeat after the server died
)
// tables are the ones the panel reads. Without all of them, playtime is off.
var tables = []string{"pt_players", "pt_sessions", "pt_segments", "pt_nicknames", "pt_online_snapshots"}
type Store struct {
db *sql.DB
server int // pt_servers.id to read, or 0 for every server
}
// Open connects lazily: a database that's down only turns playtime off, never stops the panel.
func Open(dsn string, server int) (*Store, error) {
cfg, err := mysql.ParseDSN(dsn)
if err != nil {
return nil, err
}
cfg.ParseTime = true
cfg.Loc = time.UTC
db, err := sql.Open("mysql", cfg.FormatDSN())
if err != nil {
return nil, err
}
db.SetMaxOpenConns(5)
db.SetConnMaxLifetime(5 * time.Minute)
return &Store{db: db, server: server}, nil
}
// New wraps an open database, for tests.
func New(db *sql.DB, server int) *Store { return &Store{db: db, server: server} }
func (s *Store) Close() error { return s.db.Close() }
// and adds the server filter to a WHERE clause on a table with server_id.
func (s *Store) and(col string, args []any) (string, []any) {
if s.server == 0 {
return "", args
}
return " AND " + col + " = ?", append(args, s.server)
}
// State reports whether PlaytimeHistory's tables are there, and whether it has recorded anything.
func (s *Store) State(ctx context.Context) (hasTables, hasData bool, err error) {
var n int
err = s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name IN (?, ?, ?, ?, ?)`,
tables[0], tables[1], tables[2], tables[3], tables[4]).Scan(&n)
if err != nil || n < len(tables) {
return false, false, err
}
where, args := s.and("server_id", nil)
err = s.db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM pt_sessions WHERE 1=1`+where+`)`, args...).Scan(&hasData)
return true, hasData, err
}
// ---------------------------------------------------------------- Hourly buckets
// Bucket is the seconds played on T or CT in one UTC hour.
type Bucket struct {
Start time.Time
Seconds int64
}
// hours splits closed T/CT segments since a time into UTC hours, for one player or ("") everyone.
// Segments end at the latest on a map change, so none is longer than a day.
func (s *Store) hours(ctx context.Context, since time.Time, steamid uint64) ([]Bucket, error) {
args := []any{since, since, since.Add(-24 * time.Hour)}
filter := ""
if steamid != 0 {
filter = " AND steamid64 = ?"
args = append(args, steamid)
}
where, args := s.and("server_id", args)
rows, err := s.db.QueryContext(ctx, `WITH RECURSIVE seg AS (
SELECT GREATEST(started_at, ?) AS t0, ended_at AS t1 FROM pt_segments
WHERE ended_at IS NOT NULL AND ended_at > started_at AND team IN (2, 3)
AND ended_at > ? AND started_at >= ?`+filter+where+`
), h AS (
SELECT t1, CAST(DATE_FORMAT(t0, '%Y-%m-%d %H:00:00') AS DATETIME) AS b, t0 AS p FROM seg
UNION ALL
SELECT t1, b + INTERVAL 1 HOUR, b + INTERVAL 1 HOUR FROM h WHERE b + INTERVAL 1 HOUR < t1
)
SELECT b, SUM(TIMESTAMPDIFF(SECOND, p, LEAST(t1, b + INTERVAL 1 HOUR))) FROM h GROUP BY b ORDER BY b`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Bucket
for rows.Next() {
var b Bucket
if err := rows.Scan(&b.Start, &b.Seconds); err != nil {
return nil, err
}
out = append(out, b)
}
return out, rows.Err()
}
// dayStart is local midnight on t's day in loc.
func dayStart(t time.Time, loc *time.Location) time.Time {
y, m, d := t.In(loc).Date()
return time.Date(y, m, d, 0, 0, 0, 0, loc)
}
// Day is one local day: hours played on T/CT, or different players who joined.
type Day struct {
Date string `json:"date"` // 2006-01-02, in the viewer's time zone
Hours float64 `json:"hours,omitempty"`
Players int `json:"players,omitempty"`
}
// lastDays is the n local days ending today, oldest first, and the start of the first.
func lastDays(now time.Time, loc *time.Location, n int) ([]Day, time.Time) {
today := dayStart(now, loc)
days := make([]Day, n)
for i := range days {
days[i].Date = today.AddDate(0, 0, i-n+1).Format(time.DateOnly)
}
return days, today.AddDate(0, 0, 1-n)
}
// dayIndex finds t's local day in days, or -1.
func dayIndex(days []Day, t time.Time, loc *time.Location) int {
key := t.In(loc).Format(time.DateOnly)
for i := len(days) - 1; i >= 0; i-- {
if days[i].Date == key {
return i
}
}
return -1
}
// hoursPerDay adds the buckets up into days. An hour bucket goes to the local day it starts on,
// which is exact for whole-hour time zones.
func hoursPerDay(days []Day, buckets []Bucket, loc *time.Location) {
for _, b := range buckets {
if i := dayIndex(days, b.Start, loc); i >= 0 {
days[i].Hours += float64(b.Seconds) / 3600
}
}
for i := range days {
days[i].Hours = round1(days[i].Hours)
}
}
func round1(f float64) float64 { return float64(int64(f*10+0.5)) / 10 }
func round2(f float64) float64 { return float64(int64(f*100+0.5)) / 100 }
// ---------------------------------------------------------------- Overview (public)
type Summary struct {
Players int `json:"players"`
PlayersPrev int `json:"playersPrev"`
Hours float64 `json:"hours"`
HoursPrev float64 `json:"hoursPrev"`
Peak int `json:"peak"`
PeakAt *time.Time `json:"peakAt,omitempty"`
NewPlayers int `json:"newPlayers"`
}
type Leader struct {
SteamID string `json:"steamid"`
Name string `json:"name"`
T float64 `json:"t"` // hours
CT float64 `json:"ct"`
Kills int `json:"kills"`
Deaths int `json:"deaths"`
}
type MapTime struct {
Map string `json:"map"`
Hours float64 `json:"hours"`
Players int `json:"players,omitempty"`
}
// Overview is the public Playtime page. Week figures are the last 7 days; charts the last 30
// (the heatmap the last 28, four of each weekday), in the viewer's time zone.
type Overview struct {
Summary Summary `json:"summary"`
ByHour [24]float64 `json:"byHour"` // average players on T/CT in each local hour of the day
Heat [7][24]float64 `json:"heat"` // the same per weekday, Monday first
Days []Day `json:"days"`
Maps []MapTime `json:"maps"`
}
func (s *Store) Overview(ctx context.Context, now time.Time, loc *time.Location) (Overview, error) {
var o Overview
days, start := lastDays(now, loc, 30)
buckets, err := s.hours(ctx, start, 0)
if err != nil {
return o, err
}
week, fortnight := now.Add(-7*24*time.Hour), now.Add(-14*24*time.Hour)
heatFrom := dayStart(now, loc).AddDate(0, 0, -27)
for _, b := range buckets {
h := float64(b.Seconds) / 3600
local := b.Start.In(loc)
o.ByHour[local.Hour()] += h / 30
if !local.Before(heatFrom) {
o.Heat[(int(local.Weekday())+6)%7][local.Hour()] += h / 4
}
switch {
case !b.Start.Before(week):
o.Summary.Hours += h
case !b.Start.Before(fortnight):
o.Summary.HoursPrev += h
}
}
// Two decimals: a quiet hour averages well under one player.
for i := range o.ByHour {
o.ByHour[i] = round2(o.ByHour[i])
}
for d := range o.Heat {
for h := range o.Heat[d] {
o.Heat[d][h] = round2(o.Heat[d][h])
}
}
o.Summary.Hours, o.Summary.HoursPrev = round1(o.Summary.Hours), round1(o.Summary.HoursPrev)
where, args := s.and("server_id", []any{week, fortnight, week, fortnight})
if err := s.db.QueryRowContext(ctx, `SELECT
COUNT(DISTINCT IF(started_at >= ?, steamid64, NULL)),
COUNT(DISTINCT IF(started_at >= ? AND started_at < ?, steamid64, NULL))
FROM pt_sessions WHERE started_at >= ?`+where, args...).Scan(&o.Summary.Players, &o.Summary.PlayersPrev); err != nil {
return o, err
}
where, args = s.and("server_id", []any{week})
var peakAt sql.NullTime
err = s.db.QueryRowContext(ctx, `SELECT players, taken_at FROM pt_online_snapshots
WHERE taken_at >= ?`+where+` ORDER BY players DESC, taken_at DESC LIMIT 1`, args...).Scan(&o.Summary.Peak, &peakAt)
if err != nil && err != sql.ErrNoRows {
return o, err
}
if peakAt.Valid {
o.Summary.PeakAt = &peakAt.Time
}
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM pt_players WHERE first_seen >= ?`, week).Scan(&o.Summary.NewPlayers); err != nil {
return o, err
}
// Different players per local day: by the hour each session started in.
where, args = s.and("server_id", []any{start})
rows, err := s.db.QueryContext(ctx, `SELECT DISTINCT steamid64, CAST(DATE_FORMAT(started_at, '%Y-%m-%d %H:00:00') AS DATETIME)
FROM pt_sessions WHERE started_at >= ?`+where, args...)
if err != nil {
return o, err
}
seen := make([]map[uint64]bool, len(days))
for rows.Next() {
var id uint64
var at time.Time
if err := rows.Scan(&id, &at); err != nil {
rows.Close()
return o, err
}
if i := dayIndex(days, at, loc); i >= 0 {
if seen[i] == nil {
seen[i] = map[uint64]bool{}
}
seen[i][id] = true
}
}
rows.Close()
if err := rows.Err(); err != nil {
return o, err
}
for i := range days {
days[i].Players = len(seen[i])
}
o.Days = days
o.Maps, err = s.maps(ctx, 0, &start, 10)
return o, err
}
// maps is time on T/CT per map, for one player or everyone, since a time or (nil) ever.
func (s *Store) maps(ctx context.Context, steamid uint64, since *time.Time, limit int) ([]MapTime, error) {
q := `SELECT map, SUM(seconds), COUNT(DISTINCT steamid64) FROM pt_segments
WHERE ended_at IS NOT NULL AND team IN (2, 3)`
var args []any
if since != nil {
q += " AND started_at >= ?"
args = append(args, *since)
}
if steamid != 0 {
q += " AND steamid64 = ?"
args = append(args, steamid)
}
where, args := s.and("server_id", args)
rows, err := s.db.QueryContext(ctx, q+where+` GROUP BY map ORDER BY SUM(seconds) DESC LIMIT ?`, append(args, limit)...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []MapTime{}
for rows.Next() {
var m MapTime
var secs int64
if err := rows.Scan(&m.Map, &secs, &m.Players); err != nil {
return nil, err
}
m.Hours = round1(float64(secs) / 3600)
if steamid != 0 {
m.Players = 0
}
out = append(out, m)
}
return out, rows.Err()
}
// Leaders is the most time on T/CT since a time, or (nil) ever, with kills and deaths over the
// same stretch.
func (s *Store) Leaders(ctx context.Context, since *time.Time, limit int) ([]Leader, error) {
var rows *sql.Rows
var err error
if since == nil {
// pt_players' totals cover every server sharing the database.
rows, err = s.db.QueryContext(ctx, `SELECT steamid64, last_nickname, t_seconds, ct_seconds FROM pt_players
WHERE t_seconds + ct_seconds > 0 ORDER BY t_seconds + ct_seconds DESC LIMIT ?`, limit)
} else {
where, args := s.and("g.server_id", []any{*since})
rows, err = s.db.QueryContext(ctx, `SELECT g.steamid64, COALESCE(MAX(p.last_nickname), ''),
SUM(IF(g.team = 2, g.seconds, 0)), SUM(IF(g.team = 3, g.seconds, 0))
FROM pt_segments g LEFT JOIN pt_players p ON p.steamid64 = g.steamid64
WHERE g.ended_at IS NOT NULL AND g.team IN (2, 3) AND g.started_at >= ?`+where+`
GROUP BY g.steamid64 ORDER BY SUM(g.seconds) DESC LIMIT ?`, append(args, limit)...)
}
if err != nil {
return nil, err
}
out := []Leader{}
var ids []uint64
for rows.Next() {
var id uint64
var l Leader
var t, ct int64
if err := rows.Scan(&id, &l.Name, &t, &ct); err != nil {
rows.Close()
return nil, err
}
l.SteamID, l.T, l.CT = strconv.FormatUint(id, 10), round1(float64(t)/3600), round1(float64(ct)/3600)
out = append(out, l)
ids = append(ids, id)
}
rows.Close()
if err := rows.Err(); err != nil || len(ids) == 0 {
return out, err
}
in, args := inList(ids)
q := `SELECT steamid64, COALESCE(SUM(kills), 0), COALESCE(SUM(deaths), 0) FROM pt_sessions WHERE steamid64 IN (` + in + `)`
if since != nil {
q += " AND started_at >= ?"
args = append(args, *since)
}
where, args := s.and("server_id", args)
krows, err := s.db.QueryContext(ctx, q+where+" GROUP BY steamid64", args...)
if err != nil {
return nil, err
}
defer krows.Close()
kd := map[string][2]int{}
for krows.Next() {
var id uint64
var k, d int
if err := krows.Scan(&id, &k, &d); err != nil {
return nil, err
}
kd[strconv.FormatUint(id, 10)] = [2]int{k, d}
}
for i := range out {
out[i].Kills, out[i].Deaths = kd[out[i].SteamID][0], kd[out[i].SteamID][1]
}
return out, krows.Err()
}
func inList(ids []uint64) (string, []any) {
args := make([]any, len(ids))
for i, id := range ids {
args[i] = id
}
return strings.TrimSuffix(strings.Repeat("?,", len(ids)), ","), args
}
// ParseID turns a SteamID64 string into the number PlaytimeHistory stores.
func ParseID(steamid string) (uint64, error) {
n, err := strconv.ParseUint(steamid, 10, 64)
if err != nil || n == 0 {
return 0, fmt.Errorf("bad SteamID64 %q", steamid)
}
return n, nil
}
// ---------------------------------------------------------------- One player
type Stats struct {
Kills int `json:"kills"`
Deaths int `json:"deaths"`
Assists int `json:"assists"`
Headshots int `json:"headshots"`
Damage int `json:"damage"`
MVPs int `json:"mvps"`
Rounds int `json:"rounds"`
}
// Player is what anyone can see about one player's playtime. Hours are on T or CT unless named.
type Player struct {
FirstSeen time.Time `json:"firstSeen"`
LastSeen time.Time `json:"lastSeen"`
Sessions int `json:"sessions"`
T float64 `json:"t"`
CT float64 `json:"ct"`
Spec float64 `json:"spec"` // spectating or unassigned
Alive float64 `json:"alive"`
Dead float64 `json:"dead"`
Stats Stats `json:"stats"`
Days []Day `json:"days"` // last 30 days
Maps []MapTime `json:"maps"`
}
// Player returns nil when PlaytimeHistory has never seen them.
func (s *Store) Player(ctx context.Context, steamid uint64, now time.Time, loc *time.Location) (*Player, error) {
var p Player
var t, ct, spec, alive, dead int64
err := s.db.QueryRowContext(ctx, `SELECT first_seen, last_seen, sessions_count, t_seconds, ct_seconds, spec_seconds,
alive_seconds, dead_seconds FROM pt_players WHERE steamid64 = ?`, steamid).
Scan(&p.FirstSeen, &p.LastSeen, &p.Sessions, &t, &ct, &spec, &alive, &dead)
if err == sql.ErrNoRows {
return nil, nil
} else if err != nil {
return nil, err
}
h := func(secs int64) float64 { return round1(float64(secs) / 3600) }
p.T, p.CT, p.Spec, p.Alive, p.Dead = h(t), h(ct), h(spec), h(alive), h(dead)
if err := s.db.QueryRowContext(ctx, `SELECT COALESCE(SUM(kills), 0), COALESCE(SUM(deaths), 0), COALESCE(SUM(assists), 0),
COALESCE(SUM(headshots), 0), COALESCE(SUM(damage), 0), COALESCE(SUM(mvp), 0), COALESCE(SUM(rounds_played), 0)
FROM pt_sessions WHERE steamid64 = ?`, steamid).
Scan(&p.Stats.Kills, &p.Stats.Deaths, &p.Stats.Assists, &p.Stats.Headshots, &p.Stats.Damage, &p.Stats.MVPs, &p.Stats.Rounds); err != nil {
return nil, err
}
days, start := lastDays(now, loc, 30)
buckets, err := s.hours(ctx, start, steamid)
if err != nil {
return nil, err
}
hoursPerDay(days, buckets, loc)
p.Days = days
if p.Maps, err = s.maps(ctx, steamid, nil, 5); err != nil {
return nil, err
}
return &p, nil
}
type Nickname struct {
Name string `json:"name"`
FirstSeen time.Time `json:"firstSeen"`
LastSeen time.Time `json:"lastSeen"`
Times int `json:"times"`
}
type Session struct {
Started time.Time `json:"started"`
Ended *time.Time `json:"ended,omitempty"`
Seconds int64 `json:"seconds"`
EndKind int `json:"endKind"`
Map string `json:"map"`
EndMap string `json:"endMap,omitempty"`
Reason string `json:"reason,omitempty"`
Kills *int `json:"kills,omitempty"`
Deaths *int `json:"deaths,omitempty"`
Ping *int `json:"ping,omitempty"`
Country string `json:"country,omitempty"`
City string `json:"city,omitempty"`
}
// PlayerStaff is what only staff see: names, sessions, location and ping.
type PlayerStaff struct {
Names []Nickname `json:"names"`
Recent []Session `json:"recent"`
Country string `json:"country,omitempty"`
CountryName string `json:"countryName,omitempty"`
City string `json:"city,omitempty"`
Ping *int `json:"ping,omitempty"` // average over the last 30 days
}
func (s *Store) PlayerStaff(ctx context.Context, steamid uint64, now time.Time) (PlayerStaff, error) {
out := PlayerStaff{Names: []Nickname{}, Recent: []Session{}}
rows, err := s.db.QueryContext(ctx, `SELECT nickname, first_seen, last_seen, times_seen FROM pt_nicknames
WHERE steamid64 = ? ORDER BY last_seen DESC LIMIT 20`, steamid)
if err != nil {
return out, err
}
for rows.Next() {
var n Nickname
if err := rows.Scan(&n.Name, &n.FirstSeen, &n.LastSeen, &n.Times); err != nil {
rows.Close()
return out, err
}
out.Names = append(out.Names, n)
}
rows.Close()
if err := rows.Err(); err != nil {
return out, err
}
where, args := s.and("server_id", []any{steamid})
rows, err = s.db.QueryContext(ctx, `SELECT started_at, ended_at, COALESCE(duration_seconds, TIMESTAMPDIFF(SECOND, started_at, heartbeat_at)),
end_kind, connect_map, COALESCE(disconnect_map, ''), COALESCE(disconnect_reason_name, ''), kills, deaths, ping_avg,
COALESCE(country_iso, ''), COALESCE(city, '')
FROM pt_sessions WHERE steamid64 = ?`+where+` ORDER BY started_at DESC LIMIT 10`, args...)
if err != nil {
return out, err
}
for rows.Next() {
var x Session
var ended sql.NullTime
var kills, deaths, ping sql.NullInt64
if err := rows.Scan(&x.Started, &ended, &x.Seconds, &x.EndKind, &x.Map, &x.EndMap, &x.Reason, &kills, &deaths, &ping,
&x.Country, &x.City); err != nil {
rows.Close()
return out, err
}
if ended.Valid {
x.Ended = &ended.Time
}
x.Kills, x.Deaths, x.Ping = intPtr(kills), intPtr(deaths), intPtr(ping)
out.Recent = append(out.Recent, x)
}
rows.Close()
if err := rows.Err(); err != nil {
return out, err
}
err = s.db.QueryRowContext(ctx, `SELECT country_iso, COALESCE(country_name, ''), COALESCE(city, '') FROM pt_sessions
WHERE steamid64 = ? AND country_iso IS NOT NULL ORDER BY started_at DESC LIMIT 1`, steamid).
Scan(&out.Country, &out.CountryName, &out.City)
if err != nil && err != sql.ErrNoRows {
return out, err
}
var ping sql.NullFloat64
if err := s.db.QueryRowContext(ctx, `SELECT AVG(ping_avg) FROM pt_sessions WHERE steamid64 = ? AND started_at >= ?`,
steamid, now.Add(-30*24*time.Hour)).Scan(&ping); err != nil {
return out, err
}
if ping.Valid {
n := int(ping.Float64 + 0.5)
out.Ping = &n
}
return out, nil
}
func intPtr(n sql.NullInt64) *int {
if !n.Valid {
return nil
}
v := int(n.Int64)
return &v
}
// ---------------------------------------------------------------- Staff page
// Online is an open session: when it started, where from, and the player's team right now.
type Online struct {
Since *time.Time `json:"since,omitempty"`
Team *int `json:"team,omitempty"`
Alive *bool `json:"alive,omitempty"`
Country string `json:"country,omitempty"`
City string `json:"city,omitempty"`
}
// Online looks up the open sessions of these players (the ones the live status lists, so admins
// hidden with css_hide stay hidden).
func (s *Store) Online(ctx context.Context, ids []uint64) (map[string]Online, error) {
out := map[string]Online{}
if len(ids) == 0 {
return out, nil
}
in, args := inList(ids)
where, args := s.and("s.server_id", args)
rows, err := s.db.QueryContext(ctx, `SELECT s.steamid64, s.started_at, g.team, g.alive, COALESCE(s.country_iso, ''), COALESCE(s.city, '')
FROM pt_sessions s LEFT JOIN pt_segments g ON g.session_key = s.session_key AND g.ended_at IS NULL
WHERE s.ended_at IS NULL AND s.steamid64 IN (`+in+`)`+where+` ORDER BY s.started_at`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var id uint64
var o Online
var since time.Time
var team, alive sql.NullInt64
if err := rows.Scan(&id, &since, &team, &alive, &o.Country, &o.City); err != nil {
return nil, err
}
o.Since = &since
if team.Valid {
o.Team = intPtr(team)
}
if alive.Valid {
a := alive.Int64 == 1
o.Alive = &a
}
// The latest session wins if a crash left an older one open.
out[strconv.FormatUint(id, 10)] = o
}
return out, rows.Err()
}
// Activity is a staff member's time on the server since a time.
type Activity struct {
Hours float64 `json:"hours"` // T or CT
Spec float64 `json:"spec"`
LastSeen *time.Time `json:"lastSeen,omitempty"`
}
func (s *Store) Activity(ctx context.Context, ids []uint64, since time.Time) (map[string]Activity, error) {
out := map[string]Activity{}
if len(ids) == 0 {
return out, nil
}
in, args := inList(ids)
where, args := s.and("server_id", append(args, since))
rows, err := s.db.QueryContext(ctx, `SELECT steamid64, SUM(IF(team IN (2, 3), seconds, 0)), SUM(IF(team IN (0, 1), seconds, 0))
FROM pt_segments WHERE steamid64 IN (`+in+`) AND ended_at IS NOT NULL AND started_at >= ?`+where+` GROUP BY steamid64`, args...)
if err != nil {
return nil, err
}
for rows.Next() {
var id uint64
var play, spec int64
if err := rows.Scan(&id, &play, &spec); err != nil {
rows.Close()
return nil, err
}
out[strconv.FormatUint(id, 10)] = Activity{Hours: round1(float64(play) / 3600), Spec: round1(float64(spec) / 3600)}
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
in, args = inList(ids)
rows, err = s.db.QueryContext(ctx, `SELECT steamid64, last_seen FROM pt_players WHERE steamid64 IN (`+in+`)`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var id uint64
var seen time.Time
if err := rows.Scan(&id, &seen); err != nil {
return nil, err
}
key := strconv.FormatUint(id, 10)
a := out[key]
a.LastSeen = &seen
out[key] = a
}
return out, rows.Err()
}
type Reason struct {
Name string `json:"name"`
Count int `json:"count"`
AvgSeconds int64 `json:"avgSeconds"`
}
// Reasons counts how players disconnected since a time (map changes and crashes aren't reasons).
func (s *Store) Reasons(ctx context.Context, since time.Time) ([]Reason, error) {
where, args := s.and("server_id", []any{EndDisconnect, since})
rows, err := s.db.QueryContext(ctx, `SELECT COALESCE(disconnect_reason_name, ''), COUNT(*), COALESCE(AVG(duration_seconds), 0)
FROM pt_sessions WHERE end_kind = ? AND started_at >= ?`+where+`
GROUP BY disconnect_reason_name ORDER BY COUNT(*) DESC LIMIT 15`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Reason{}
for rows.Next() {
var r Reason
var avg float64
if err := rows.Scan(&r.Name, &r.Count, &avg); err != nil {
return nil, err
}
r.AvgSeconds = int64(avg)
out = append(out, r)
}
return out, rows.Err()
}
type Country struct {
ISO string `json:"iso"`
Name string `json:"name"`
Players int `json:"players"`
Hours float64 `json:"hours"` // connected, any team
}
// Countries is where players connect from, ever.
func (s *Store) Countries(ctx context.Context) ([]Country, error) {
where, args := s.and("server_id", nil)
rows, err := s.db.QueryContext(ctx, `SELECT country_iso, COALESCE(MAX(country_name), ''), COUNT(DISTINCT steamid64),
COALESCE(SUM(duration_seconds), 0)
FROM pt_sessions WHERE country_iso IS NOT NULL`+where+`
GROUP BY country_iso ORDER BY COUNT(DISTINCT steamid64) DESC LIMIT 20`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Country{}
for rows.Next() {
var c Country
var secs int64
if err := rows.Scan(&c.ISO, &c.Name, &c.Players, &secs); err != nil {
return nil, err
}
c.Hours = round1(float64(secs) / 3600)
out = append(out, c)
}
return out, rows.Err()
}
// EndKinds counts how sessions since a time ended, by end_kind.
func (s *Store) EndKinds(ctx context.Context, since time.Time) (map[int]int, error) {
where, args := s.and("server_id", []any{since})
rows, err := s.db.QueryContext(ctx, `SELECT end_kind, COUNT(*) FROM pt_sessions WHERE started_at >= ?`+where+` GROUP BY end_kind`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[int]int{}
for rows.Next() {
var k, n int
if err := rows.Scan(&k, &n); err != nil {
return nil, err
}
out[k] = n
}
return out, rows.Err()
}