// 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() }