PlaytimeHistory 0.1.0: sessions and team/alive segments in MySQL, DB-IP locations

This commit is contained in:
Astra 2026-10-04 17:45:48 +01:00
commit aad86dd1c7
11 changed files with 1989 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
bin/
obj/
release-out/

81
Config.cs Normal file
View file

@ -0,0 +1,81 @@
using System.Text.Json.Serialization;
using CounterStrikeSharp.API.Core;
namespace PlaytimeHistory;
// configs/plugins/PlaytimeHistory/PlaytimeHistory.json, which CSS writes with these defaults on first
// load. It holds the database password, so the plugin keeps it at mode 600. Changes need the plugin
// reloaded: css_plugins reload PlaytimeHistory.
public class PlaytimeHistoryConfig : BasePluginConfig
{
[JsonPropertyName("Database")]
public DatabaseConfig Database { get; set; } = new();
// pt_servers.id. Must differ between servers writing to the same database.
[JsonPropertyName("ServerId")]
public int ServerId { get; set; } = 1;
// "host:port" for pt_servers.address. The server can't work out its own public address, so
// nothing is stored when this is empty.
[JsonPropertyName("PublicAddress")]
public string PublicAddress { get; set; } = "";
// Whether time spent spectating or unassigned (team picker) counts toward pt_players.total_seconds.
// It's always recorded in pt_segments and pt_players.spec_seconds either way.
[JsonPropertyName("CountSpectatorTime")]
public bool CountSpectatorTime { get; set; } = false;
// DB-IP's City Lite database, relative to the plugin folder. pre.sh installs it. Without it,
// sessions have no country or city.
[JsonPropertyName("GeoIpDatabase")]
public string GeoIpDatabase { get; set; } = "dbip-city-lite.mmdb";
[JsonPropertyName("PingSampleIntervalSeconds")]
public int PingSampleIntervalSeconds { get; set; } = 30;
// How often players online are counted into pt_online_snapshots. 0 turns snapshots off.
[JsonPropertyName("OnlineSnapshotIntervalSeconds")]
public int OnlineSnapshotIntervalSeconds { get; set; } = 300;
// How often open sessions and segments get heartbeat_at, which is where they're closed after a
// crash. A crash loses at most this much time.
[JsonPropertyName("HeartbeatSeconds")]
public int HeartbeatSeconds { get; set; } = 60;
// Writes kept on disk while the database is unreachable, sent once it's back. Beyond this, new
// writes are dropped (and logged).
[JsonPropertyName("SpoolMaxEntries")]
public int SpoolMaxEntries { get; set; } = 20000;
// css_playtime/css_lastseen about someone else need this; about yourself, nothing.
[JsonPropertyName("OtherPlayersPermission")]
public string OtherPlayersPermission { get; set; } = "@css/generic";
[JsonPropertyName("CommandCooldownSeconds")]
public int CommandCooldownSeconds { get; set; } = 5;
[JsonPropertyName("LastSeenLimit")]
public int LastSeenLimit { get; set; } = 5;
}
public class DatabaseConfig
{
[JsonPropertyName("Host")]
public string Host { get; set; } = "127.0.0.1";
[JsonPropertyName("Port")]
public uint Port { get; set; } = 3306;
[JsonPropertyName("Database")]
public string Database { get; set; } = "playtime_history";
[JsonPropertyName("User")]
public string User { get; set; } = "pt_plugin";
[JsonPropertyName("Password")]
public string Password { get; set; } = "";
// MySqlConnector's SslMode: None, Preferred, Required, VerifyCA or VerifyFull.
[JsonPropertyName("SslMode")]
public string SslMode { get; set; } = "None";
}

357
Database.cs Normal file
View file

@ -0,0 +1,357 @@
using MySqlConnector;
namespace PlaytimeHistory;
// The plugin's own MySQL database (pt_* tables). Every method opens its own connection from the
// pool and runs off the game thread. Writes come only from the Writer, one at a time, in order.
public sealed class Database
{
private const string MigrationPrefix = "PlaytimeHistory.Migrations.";
private readonly string _connectionString;
public Database(DatabaseConfig config)
{
if (!Enum.TryParse<MySqlSslMode>(config.SslMode, ignoreCase: true, out var sslMode))
throw new ArgumentException($"Database.SslMode \"{config.SslMode}\" isn't one of None, Preferred, Required, VerifyCA, VerifyFull");
_connectionString = new MySqlConnectionStringBuilder
{
Server = config.Host,
Port = config.Port,
Database = config.Database,
UserID = config.User,
Password = config.Password,
SslMode = sslMode,
ConnectionTimeout = 10,
DefaultCommandTimeout = 30,
MaximumPoolSize = 5,
}.ConnectionString;
}
// What css_playtime shows. Totals come from pt_players; the week from closed segments.
public sealed record Summary(
DateTime FirstSeen, DateTime LastSeen, int Sessions, long Total, long T, long CT, long Spec,
long Alive, long Dead, string LastNickname, long WeekPlaying, long WeekSpec);
public sealed record RecentSession(DateTime StartedAt, DateTime? EndedAt, int? Seconds, string Map, EndKind EndKind);
private async Task<MySqlConnection> OpenAsync(CancellationToken token = default)
{
var connection = new MySqlConnection(_connectionString);
await connection.OpenAsync(token);
return connection;
}
private static MySqlCommand Command(MySqlConnection connection, MySqlTransaction? transaction, string sql,
params (string Name, object? Value)[] parameters)
{
var command = new MySqlCommand(sql, connection, transaction);
foreach (var (name, value) in parameters)
command.Parameters.AddWithValue(name, value ?? DBNull.Value);
return command;
}
// Applies the embedded Migrations/*.sql in name order, each once, like GroupVerify's gv_migrations.
// Returns the versions applied.
public async Task<List<string>> MigrateAsync(CancellationToken token)
{
await using var connection = await OpenAsync(token);
await using (var create = Command(connection, null, """
CREATE TABLE IF NOT EXISTS `pt_migrations` (
`id` int NOT NULL AUTO_INCREMENT,
`version` varchar(128) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
"""))
await create.ExecuteNonQueryAsync(token);
string last;
await using (var query = Command(connection, null, "SELECT version FROM pt_migrations ORDER BY id DESC LIMIT 1"))
last = (await query.ExecuteScalarAsync(token))?.ToString() ?? "";
var assembly = typeof(Database).Assembly;
var applied = new List<string>();
foreach (var resource in assembly.GetManifestResourceNames()
.Where(n => n.StartsWith(MigrationPrefix) && n.EndsWith(".sql"))
.Order(StringComparer.Ordinal))
{
var version = resource[MigrationPrefix.Length..^".sql".Length];
if (string.CompareOrdinal(version, last) <= 0)
continue;
using var reader = new StreamReader(assembly.GetManifestResourceStream(resource)!);
await using (var migrate = Command(connection, null, await reader.ReadToEndAsync(token)))
await migrate.ExecuteNonQueryAsync(token);
await using (var record = Command(connection, null, "INSERT INTO pt_migrations (version) VALUES (@version)", ("@version", version)))
await record.ExecuteNonQueryAsync(token);
applied.Add(version);
}
return applied;
}
public async Task ApplyAsync(Op op, CancellationToken token)
{
await using var connection = await OpenAsync(token);
await using var transaction = await connection.BeginTransactionAsync(token);
switch (op)
{
case ServerSeen o:
await Exec(connection, transaction, token, """
INSERT INTO pt_servers (id, address, hostname, first_seen, last_seen)
VALUES (@id, @address, @hostname, @at, @at)
ON DUPLICATE KEY UPDATE
address = IF(VALUES(address) = '', address, VALUES(address)),
hostname = IF(VALUES(hostname) = '', hostname, VALUES(hostname)),
last_seen = GREATEST(last_seen, VALUES(last_seen))
""", ("@id", o.ServerId), ("@address", o.Address), ("@hostname", Clip(o.Hostname, 128)), ("@at", o.At));
break;
case RecoverStale o:
await RecoverStaleAsync(connection, transaction, o, token);
break;
case SessionOpen o:
await SessionOpenAsync(connection, transaction, o, token);
break;
case SessionClose o:
await SessionCloseAsync(connection, transaction, o, token);
break;
case SegmentOpen o:
await Exec(connection, transaction, token, """
INSERT IGNORE INTO pt_segments
(segment_key, session_key, steamid64, server_id, map, team, alive, started_at, heartbeat_at)
VALUES (@key, @session, @steamid, @server, @map, @team, @alive, @at, @at)
""", ("@key", o.SegmentKey), ("@session", o.SessionKey), ("@steamid", o.SteamId),
("@server", o.ServerId), ("@map", Clip(o.Map, 64)), ("@team", o.Team), ("@alive", o.Alive), ("@at", o.StartedAt));
break;
case SegmentRestate o:
await Exec(connection, transaction, token,
"UPDATE pt_segments SET team = @team, alive = @alive WHERE segment_key = @key AND ended_at IS NULL",
("@key", o.SegmentKey), ("@team", o.Team), ("@alive", o.Alive));
break;
case SegmentClose o:
await CloseSegmentAsync(connection, transaction, o.SegmentKey, o.EndedAt, o.CountSpectatorTime, token);
break;
case Heartbeat o:
await Exec(connection, transaction, token,
"UPDATE pt_sessions SET heartbeat_at = @at WHERE server_id = @server AND ended_at IS NULL AND heartbeat_at < @at",
("@server", o.ServerId), ("@at", o.At));
await Exec(connection, transaction, token,
"UPDATE pt_segments SET heartbeat_at = @at WHERE server_id = @server AND ended_at IS NULL AND heartbeat_at < @at",
("@server", o.ServerId), ("@at", o.At));
break;
case OnlineSnapshot o:
await Exec(connection, transaction, token, """
INSERT INTO pt_online_snapshots (server_id, taken_at, players, bots, max_players, map)
VALUES (@server, @at, @players, @bots, @max, @map)
""", ("@server", o.ServerId), ("@at", o.At), ("@players", o.Players), ("@bots", o.Bots),
("@max", o.MaxPlayers), ("@map", Clip(o.Map, 64)));
break;
default:
throw new ArgumentException($"Unknown op {op.GetType().Name}");
}
await transaction.CommitAsync(token);
}
private static async Task SessionOpenAsync(MySqlConnection connection, MySqlTransaction transaction, SessionOpen o, CancellationToken token)
{
var nickname = Clip(o.Nickname, 128);
var inserted = await Exec(connection, transaction, token, """
INSERT IGNORE INTO pt_sessions
(session_key, steamid64, server_id, nickname, started_at, heartbeat_at, connect_map,
players_online, max_players, country_iso, country_name, city, plugin_version)
VALUES (@key, @steamid, @server, @nickname, @at, @at, @map, @online, @max, @iso, @country, @city, @version)
""", ("@key", o.SessionKey), ("@steamid", o.SteamId), ("@server", o.ServerId), ("@nickname", nickname),
("@at", o.StartedAt), ("@map", Clip(o.Map, 64)), ("@online", o.PlayersOnline), ("@max", o.MaxPlayers),
("@iso", o.CountryIso), ("@country", Clip(o.CountryName, 64)), ("@city", Clip(o.City, 128)),
("@version", Clip(o.PluginVersion, 32)));
if (inserted == 0)
return;
await Exec(connection, transaction, token, """
INSERT INTO pt_players (steamid64, first_seen, last_seen, sessions_count, last_nickname, last_country, last_server_id)
VALUES (@steamid, @at, @at, 1, @nickname, @iso, @server)
ON DUPLICATE KEY UPDATE
last_seen = GREATEST(last_seen, VALUES(last_seen)),
sessions_count = sessions_count + 1,
last_nickname = VALUES(last_nickname),
last_country = COALESCE(VALUES(last_country), last_country),
last_server_id = VALUES(last_server_id)
""", ("@steamid", o.SteamId), ("@at", o.StartedAt), ("@nickname", nickname), ("@iso", o.CountryIso), ("@server", o.ServerId));
await Exec(connection, transaction, token, """
INSERT INTO pt_nicknames (steamid64, nickname, first_seen, last_seen, times_seen)
VALUES (@steamid, @nickname, @at, @at, 1)
ON DUPLICATE KEY UPDATE last_seen = GREATEST(last_seen, VALUES(last_seen)), times_seen = times_seen + 1
""", ("@steamid", o.SteamId), ("@nickname", nickname), ("@at", o.StartedAt));
}
private static async Task SessionCloseAsync(MySqlConnection connection, MySqlTransaction transaction, SessionClose o, CancellationToken token)
{
var s = o.Stats;
var p = o.Ping;
var closed = await Exec(connection, transaction, token, """
UPDATE pt_sessions SET
ended_at = @at, heartbeat_at = @at,
duration_seconds = GREATEST(TIMESTAMPDIFF(SECOND, started_at, @at), 0),
end_kind = @kind, disconnect_map = @map, disconnect_reason = @reason, disconnect_reason_name = @reasonName,
kills = @kills, deaths = @deaths, assists = @assists, headshots = @headshots, damage = @damage,
mvp = @mvp, score = @score, rounds_played = @rounds,
ping_avg = @pingAvg, ping_min = @pingMin, ping_max = @pingMax, ping_samples = @pingSamples
WHERE session_key = @key AND ended_at IS NULL
""", ("@key", o.SessionKey), ("@at", o.EndedAt), ("@kind", (int)o.EndKind), ("@map", Clip(o.Map, 64)),
("@reason", o.DisconnectReason), ("@reasonName", Clip(o.DisconnectReasonName, 64)),
("@kills", s?.Kills), ("@deaths", s?.Deaths), ("@assists", s?.Assists), ("@headshots", s?.Headshots),
("@damage", s?.Damage), ("@mvp", s?.Mvp), ("@score", s?.Score), ("@rounds", o.RoundsPlayed),
("@pingAvg", p?.Avg), ("@pingMin", p?.Min), ("@pingMax", p?.Max), ("@pingSamples", p?.Samples));
if (closed == 0)
return;
await Exec(connection, transaction, token,
"UPDATE pt_players SET last_seen = GREATEST(last_seen, @at) WHERE steamid64 = @steamid",
("@steamid", o.SteamId), ("@at", o.EndedAt));
}
// Closes a segment that's still open and adds its time to the player's totals, together.
private static async Task CloseSegmentAsync(MySqlConnection connection, MySqlTransaction transaction,
string segmentKey, DateTime endedAt, bool countSpectatorTime, CancellationToken token)
{
ulong steamId;
int team;
bool? alive;
DateTime startedAt;
await using (var select = Command(connection, transaction,
"SELECT steamid64, team, alive, started_at FROM pt_segments WHERE segment_key = @key AND ended_at IS NULL FOR UPDATE",
("@key", segmentKey)))
await using (var reader = await select.ExecuteReaderAsync(token))
{
if (!await reader.ReadAsync(token))
return;
steamId = reader.GetUInt64(0);
team = reader.GetInt32(1);
alive = reader.IsDBNull(2) ? null : reader.GetInt32(2) != 0;
startedAt = reader.GetDateTime(3);
}
var seconds = Math.Max(0L, (long)(endedAt - startedAt).TotalSeconds);
await Exec(connection, transaction, token,
"UPDATE pt_segments SET ended_at = @at, heartbeat_at = @at, seconds = @seconds WHERE segment_key = @key",
("@key", segmentKey), ("@at", endedAt), ("@seconds", seconds));
var playing = Teams.Playing(team);
await Exec(connection, transaction, token, """
UPDATE pt_players SET
total_seconds = total_seconds + @total,
t_seconds = t_seconds + @t,
ct_seconds = ct_seconds + @ct,
spec_seconds = spec_seconds + @spec,
alive_seconds = alive_seconds + @alive,
dead_seconds = dead_seconds + @dead
WHERE steamid64 = @steamid
""", ("@steamid", steamId),
("@total", playing || countSpectatorTime ? seconds : 0),
("@t", team == Teams.T ? seconds : 0),
("@ct", team == Teams.CT ? seconds : 0),
("@spec", playing ? 0 : seconds),
("@alive", playing && alive == true ? seconds : 0),
("@dead", playing && alive != true ? seconds : 0));
}
private static async Task RecoverStaleAsync(MySqlConnection connection, MySqlTransaction transaction, RecoverStale o, CancellationToken token)
{
var open = new List<(string Key, DateTime Heartbeat)>();
await using (var select = Command(connection, transaction,
"SELECT segment_key, heartbeat_at FROM pt_segments WHERE server_id = @server AND ended_at IS NULL",
("@server", o.ServerId)))
await using (var reader = await select.ExecuteReaderAsync(token))
while (await reader.ReadAsync(token))
open.Add((reader.GetString(0), reader.GetDateTime(1)));
foreach (var (key, heartbeat) in open)
await CloseSegmentAsync(connection, transaction, key, heartbeat, o.CountSpectatorTime, token);
await Exec(connection, transaction, token, """
UPDATE pt_players p JOIN pt_sessions s ON s.steamid64 = p.steamid64
SET p.last_seen = GREATEST(p.last_seen, s.heartbeat_at)
WHERE s.server_id = @server AND s.ended_at IS NULL
""", ("@server", o.ServerId));
await Exec(connection, transaction, token, """
UPDATE pt_sessions SET
ended_at = heartbeat_at,
duration_seconds = GREATEST(TIMESTAMPDIFF(SECOND, started_at, heartbeat_at), 0),
end_kind = @kind
WHERE server_id = @server AND ended_at IS NULL
""", ("@server", o.ServerId), ("@kind", (int)EndKind.Stale));
}
public async Task<Summary?> GetSummaryAsync(ulong steamId, CancellationToken token)
{
await using var connection = await OpenAsync(token);
Summary? summary = null;
await using (var select = Command(connection, null, """
SELECT first_seen, last_seen, sessions_count, total_seconds, t_seconds, ct_seconds,
spec_seconds, alive_seconds, dead_seconds, last_nickname
FROM pt_players WHERE steamid64 = @steamid
""", ("@steamid", steamId)))
await using (var reader = await select.ExecuteReaderAsync(token))
{
if (!await reader.ReadAsync(token))
return null;
summary = new Summary(reader.GetDateTime(0), reader.GetDateTime(1), reader.GetInt32(2), reader.GetInt64(3),
reader.GetInt64(4), reader.GetInt64(5), reader.GetInt64(6), reader.GetInt64(7), reader.GetInt64(8),
reader.GetString(9), 0, 0);
}
await using (var week = Command(connection, null, """
SELECT COALESCE(SUM(CASE WHEN team IN (2, 3) THEN seconds END), 0),
COALESCE(SUM(CASE WHEN team IN (0, 1) THEN seconds END), 0)
FROM pt_segments
WHERE steamid64 = @steamid AND ended_at IS NOT NULL AND started_at >= UTC_TIMESTAMP() - INTERVAL 7 DAY
""", ("@steamid", steamId)))
await using (var reader = await week.ExecuteReaderAsync(token))
{
if (await reader.ReadAsync(token))
summary = summary with { WeekPlaying = reader.GetInt64(0), WeekSpec = reader.GetInt64(1) };
}
return summary;
}
public async Task<List<RecentSession>> GetRecentSessionsAsync(ulong steamId, int limit, CancellationToken token)
{
await using var connection = await OpenAsync(token);
await using var select = Command(connection, null, """
SELECT started_at, ended_at, duration_seconds, connect_map, end_kind
FROM pt_sessions WHERE steamid64 = @steamid
ORDER BY started_at DESC LIMIT @limit
""", ("@steamid", steamId), ("@limit", limit));
await using var reader = await select.ExecuteReaderAsync(token);
var sessions = new List<RecentSession>();
while (await reader.ReadAsync(token))
sessions.Add(new RecentSession(
reader.GetDateTime(0),
reader.IsDBNull(1) ? null : reader.GetDateTime(1),
reader.IsDBNull(2) ? null : reader.GetInt32(2),
reader.GetString(3),
(EndKind)reader.GetInt32(4)));
return sessions;
}
private static async Task<int> Exec(MySqlConnection connection, MySqlTransaction transaction, CancellationToken token,
string sql, params (string Name, object? Value)[] parameters)
{
await using var command = Command(connection, transaction, sql, parameters);
return await command.ExecuteNonQueryAsync(token);
}
[return: System.Diagnostics.CodeAnalysis.NotNullIfNotNull(nameof(value))]
private static string? Clip(string? value, int length) =>
value == null || value.Length <= length ? value : value[..length];
}

53
Geo.cs Normal file
View file

@ -0,0 +1,53 @@
using System.Net;
using MaxMind.Db;
namespace PlaytimeHistory;
// Country and city for an IP, from DB-IP's City Lite database (MaxMind DB format, CC BY 4.0:
// "IP Geolocation by DB-IP", https://db-ip.com). Only the result is kept; the IP never is.
public sealed class Geo : IDisposable
{
public sealed record Location(string? CountryIso, string? CountryName, string? City);
private readonly Reader _reader;
private Geo(Reader reader) => _reader = reader;
public string DatabaseType => _reader.Metadata.DatabaseType;
public DateTime BuildDate => _reader.Metadata.BuildDate;
// Null when the file isn't there.
public static Geo? Open(string path) =>
File.Exists(path) ? new Geo(new Reader(path, FileAccessMode.MemoryMapped)) : null;
// `address` is "ip" or "ip:port", as CCSPlayerController.IpAddress gives it.
public Location? Lookup(string? address)
{
if (string.IsNullOrEmpty(address))
return null;
var colon = address.LastIndexOf(':');
var host = colon > 0 && address.IndexOf(':') == colon ? address[..colon] : address;
if (!IPAddress.TryParse(host, out var ip))
return null;
var data = _reader.Find<Dictionary<string, object>>(ip);
if (data == null)
return null;
string? iso = null, country = null, city = null;
if (data.TryGetValue("country", out var c) && c is Dictionary<string, object> countryData)
{
iso = countryData.TryGetValue("iso_code", out var code) ? code as string : null;
country = EnglishName(countryData);
}
if (data.TryGetValue("city", out var ct) && ct is Dictionary<string, object> cityData)
city = EnglishName(cityData);
return iso == null && city == null ? null : new Location(iso, country, city);
}
private static string? EnglishName(Dictionary<string, object> data) =>
data.TryGetValue("names", out var n) && n is Dictionary<string, object> names &&
names.TryGetValue("en", out var en) ? en as string : null;
public void Dispose() => _reader.Dispose();
}

View file

@ -0,0 +1,138 @@
-- All times are UTC. Tables are prefixed pt_ so they can share a database if ever needed.
CREATE TABLE IF NOT EXISTS `pt_servers` (
`id` int NOT NULL,
`address` varchar(64) NOT NULL DEFAULT '',
`hostname` varchar(128) NOT NULL DEFAULT '',
`first_seen` datetime NOT NULL,
`last_seen` datetime NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- One row per connection, opened on join and closed on leave or map change.
-- end_kind: 0 open, 1 disconnect, 2 map change, 3 plugin unload, 4 stale (closed at its last
-- heartbeat after the server died).
CREATE TABLE IF NOT EXISTS `pt_sessions` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`session_key` char(32) NOT NULL,
`steamid64` bigint unsigned NOT NULL,
`server_id` int NOT NULL,
`nickname` varchar(128) NOT NULL,
`started_at` datetime NOT NULL,
`ended_at` datetime NULL,
`heartbeat_at` datetime NOT NULL,
`duration_seconds` int unsigned NULL,
`end_kind` tinyint NOT NULL DEFAULT 0,
`connect_map` varchar(64) NOT NULL,
`disconnect_map` varchar(64) NULL,
`disconnect_reason` smallint NULL,
`disconnect_reason_name` varchar(64) NULL,
`players_online` smallint unsigned NOT NULL,
`max_players` smallint unsigned NOT NULL,
`country_iso` char(2) NULL,
`country_name` varchar(64) NULL,
`city` varchar(128) NULL,
`kills` int NULL,
`deaths` int NULL,
`assists` int NULL,
`headshots` int NULL,
`damage` int NULL,
`mvp` int NULL,
`score` int NULL,
`rounds_played` smallint unsigned NULL,
`ping_avg` smallint unsigned NULL,
`ping_min` smallint unsigned NULL,
`ping_max` smallint unsigned NULL,
`ping_samples` int unsigned NULL,
`plugin_version` varchar(32) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_session_key` (`session_key`),
KEY `ix_steamid_started` (`steamid64`, `started_at`),
KEY `ix_server_started` (`server_id`, `started_at`),
KEY `ix_started` (`started_at`),
KEY `ix_server_open` (`server_id`, `ended_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- A stretch of a session where the player's team and alive/dead state didn't change.
-- team: 0 unassigned, 1 spectator, 2 T, 3 CT. alive: NULL when not on T/CT.
CREATE TABLE IF NOT EXISTS `pt_segments` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`segment_key` char(32) NOT NULL,
`session_key` char(32) NOT NULL,
`steamid64` bigint unsigned NOT NULL,
`server_id` int NOT NULL,
`map` varchar(64) NOT NULL,
`team` tinyint NOT NULL,
`alive` tinyint NULL,
`started_at` datetime NOT NULL,
`ended_at` datetime NULL,
`heartbeat_at` datetime NOT NULL,
`seconds` int unsigned NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_segment_key` (`segment_key`),
KEY `ix_steamid_started` (`steamid64`, `started_at`),
KEY `ix_started` (`started_at`),
KEY `ix_session` (`session_key`),
KEY `ix_server_open` (`server_id`, `ended_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Running totals, added to in the same transaction that closes a segment or session.
-- spec_seconds covers both spectator and unassigned. total_seconds includes it only when the
-- plugin's CountSpectatorTime is on.
CREATE TABLE IF NOT EXISTS `pt_players` (
`steamid64` bigint unsigned NOT NULL,
`first_seen` datetime NOT NULL,
`last_seen` datetime NOT NULL,
`sessions_count` int unsigned NOT NULL DEFAULT 0,
`total_seconds` bigint unsigned NOT NULL DEFAULT 0,
`t_seconds` bigint unsigned NOT NULL DEFAULT 0,
`ct_seconds` bigint unsigned NOT NULL DEFAULT 0,
`spec_seconds` bigint unsigned NOT NULL DEFAULT 0,
`alive_seconds` bigint unsigned NOT NULL DEFAULT 0,
`dead_seconds` bigint unsigned NOT NULL DEFAULT 0,
`last_nickname` varchar(128) NOT NULL,
`last_country` char(2) NULL,
`last_server_id` int NOT NULL,
PRIMARY KEY (`steamid64`),
KEY `ix_total` (`total_seconds`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `pt_nicknames` (
`steamid64` bigint unsigned NOT NULL,
`nickname` varchar(128) NOT NULL,
`first_seen` datetime NOT NULL,
`last_seen` datetime NOT NULL,
`times_seen` int unsigned NOT NULL DEFAULT 1,
PRIMARY KEY (`steamid64`, `nickname`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `pt_online_snapshots` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`server_id` int NOT NULL,
`taken_at` datetime NOT NULL,
`players` smallint unsigned NOT NULL,
`bots` smallint unsigned NOT NULL,
`max_players` smallint unsigned NOT NULL,
`map` varchar(64) NOT NULL,
PRIMARY KEY (`id`),
KEY `ix_server_taken` (`server_id`, `taken_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Segments split at the hour boundaries: one row per segment per UTC hour it touches. Closed
-- segments only; filter on hour_start (and steamid64) when reading.
CREATE VIEW IF NOT EXISTS `pt_segments_hourly` AS
WITH RECURSIVE h AS (
SELECT s.steamid64, s.server_id, s.map, s.team, s.alive, s.ended_at,
CAST(DATE_FORMAT(s.started_at, '%Y-%m-%d %H:00:00') AS DATETIME) AS hour_start,
s.started_at AS piece_start
FROM pt_segments s
WHERE s.ended_at IS NOT NULL AND s.ended_at > s.started_at
UNION ALL
SELECT steamid64, server_id, map, team, alive, ended_at,
hour_start + INTERVAL 1 HOUR, hour_start + INTERVAL 1 HOUR
FROM h
WHERE hour_start + INTERVAL 1 HOUR < ended_at
)
SELECT steamid64, server_id, map, team, alive, hour_start,
TIMESTAMPDIFF(SECOND, piece_start, LEAST(ended_at, hour_start + INTERVAL 1 HOUR)) AS seconds
FROM h;

71
Ops.cs Normal file
View file

@ -0,0 +1,71 @@
using System.Text.Json.Serialization;
namespace PlaytimeHistory;
// Every database write, as data, so the writer can run them in order off the game thread and keep
// them in the spool file while the database is down. Each one is safe to run twice: inserts are
// keyed (session_key, segment_key) and closes only touch rows still open, so a write that's sent
// again after a lost reply changes nothing.
[JsonPolymorphic(TypeDiscriminatorPropertyName = "op")]
[JsonDerivedType(typeof(ServerSeen), "server_seen")]
[JsonDerivedType(typeof(RecoverStale), "recover_stale")]
[JsonDerivedType(typeof(SessionOpen), "session_open")]
[JsonDerivedType(typeof(SessionClose), "session_close")]
[JsonDerivedType(typeof(SegmentOpen), "segment_open")]
[JsonDerivedType(typeof(SegmentRestate), "segment_restate")]
[JsonDerivedType(typeof(SegmentClose), "segment_close")]
[JsonDerivedType(typeof(Heartbeat), "heartbeat")]
[JsonDerivedType(typeof(OnlineSnapshot), "online_snapshot")]
public abstract record Op;
public sealed record ServerSeen(int ServerId, string Address, string Hostname, DateTime At) : Op;
// Closes whatever this server left open (a crash, or a kill without an unload) at its last heartbeat.
public sealed record RecoverStale(int ServerId, bool CountSpectatorTime) : Op;
public sealed record SessionOpen(
string SessionKey, ulong SteamId, int ServerId, string Nickname, DateTime StartedAt, string Map,
int PlayersOnline, int MaxPlayers, string? CountryIso, string? CountryName, string? City,
string PluginVersion) : Op;
public sealed record SessionClose(
string SessionKey, ulong SteamId, DateTime EndedAt, EndKind EndKind, string Map,
int? DisconnectReason, string? DisconnectReasonName, MatchStats? Stats, int RoundsPlayed,
PingStats? Ping) : Op;
public sealed record SegmentOpen(
string SegmentKey, string SessionKey, ulong SteamId, int ServerId, string Map, int Team, bool? Alive,
DateTime StartedAt) : Op;
// The state changed within the second the segment started: change it instead of leaving a
// zero-length segment behind.
public sealed record SegmentRestate(string SegmentKey, int Team, bool? Alive) : Op;
public sealed record SegmentClose(string SegmentKey, DateTime EndedAt, bool CountSpectatorTime) : Op;
public sealed record Heartbeat(int ServerId, DateTime At) : Op;
public sealed record OnlineSnapshot(int ServerId, DateTime At, int Players, int Bots, int MaxPlayers, string Map) : Op;
public sealed record MatchStats(int Kills, int Deaths, int Assists, int Headshots, int Damage, int Mvp, int Score);
public sealed record PingStats(int Avg, int Min, int Max, int Samples);
public enum EndKind
{
Open = 0,
Disconnect = 1,
MapChange = 2,
Unload = 3,
Stale = 4,
}
public static class Teams
{
public const int None = 0;
public const int Spectator = 1;
public const int T = 2;
public const int CT = 3;
public static bool Playing(int team) => team is T or CT;
}

655
PlaytimeHistory.cs Normal file
View file

@ -0,0 +1,655 @@
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Core.Attributes.Registration;
using CounterStrikeSharp.API.Modules.Admin;
using CounterStrikeSharp.API.Modules.Commands;
using CounterStrikeSharp.API.Modules.Cvars;
using CounterStrikeSharp.API.Modules.Timers;
using CounterStrikeSharp.API.Modules.Utils;
using CounterStrikeSharp.API.ValveConstants.Protobuf;
using Microsoft.Extensions.Logging;
namespace PlaytimeHistory;
// Records every player's time on the server in its own MySQL database: sessions (join to leave, or
// to a map change) and, inside them, segments, each a stretch where the player's team and
// alive/dead state stayed the same. Per-hour, per-team and alive/dead playtime all come from the
// segments with SQL (see the README). Only country and city are kept from a player's IP.
//
// Threads: every field below is only touched on the game thread. Database writes are queued to the
// Writer; command lookups run in Task.Run and hand their answer back with Server.NextFrame.
public class PlaytimeHistory : BasePlugin, IPluginConfig<PlaytimeHistoryConfig>
{
public override string ModuleName => "PlaytimeHistory";
public override string ModuleVersion => "0.1.0";
public override string ModuleAuthor => "astra";
public override string ModuleDescription => "Records playtime per session, team and alive/dead state";
private const string StatusPermission = "@css/root";
public PlaytimeHistoryConfig Config { get; set; } = new();
private Database? _db;
private Writer? _writer;
private Geo? _geo;
private bool _geoBroken;
private string _map = "";
// False between a map's end and the next one's start, when nothing is opened.
private bool _mapRunning;
// Slot -> the session open there.
private readonly Dictionary<int, Tracked> _tracked = [];
// Caller SteamID64 -> when they may use a lookup command again (UTC).
private readonly Dictionary<ulong, DateTime> _cooldowns = [];
private sealed class Tracked
{
public required ulong SteamId;
public required string SessionKey;
public required MatchStats? Baseline;
public string? SegmentKey;
public DateTime SegmentStart;
public int Team;
public bool? Alive;
public int Rounds;
public long PingSum;
public int PingSamples;
public int PingMin = int.MaxValue;
public int PingMax;
}
public void OnConfigParsed(PlaytimeHistoryConfig config)
{
config.PingSampleIntervalSeconds = Math.Max(5, config.PingSampleIntervalSeconds);
config.OnlineSnapshotIntervalSeconds = Math.Max(0, config.OnlineSnapshotIntervalSeconds);
config.HeartbeatSeconds = Math.Max(10, config.HeartbeatSeconds);
config.SpoolMaxEntries = Math.Max(0, config.SpoolMaxEntries);
config.LastSeenLimit = Math.Clamp(config.LastSeenLimit, 1, 20);
config.PublicAddress = config.PublicAddress.Trim();
Config = config;
}
public override void Load(bool hotReload)
{
ProtectConfigFile();
RegisterListener<Listeners.OnMapStart>(OnMapStart);
RegisterListener<Listeners.OnMapEnd>(OnMapEnd);
RegisterListener<Listeners.OnClientDisconnect>(OnClientDisconnect);
RegisterEventHandler<EventPlayerConnectFull>(OnPlayerConnectFull);
RegisterEventHandler<EventPlayerTeam>(OnPlayerTeam);
RegisterEventHandler<EventPlayerSpawn>(OnPlayerSpawn);
RegisterEventHandler<EventPlayerDeath>(OnPlayerDeath);
RegisterEventHandler<EventPlayerDisconnect>(OnPlayerDisconnect, HookMode.Pre);
RegisterEventHandler<EventRoundEnd>(OnRoundEnd);
OpenGeo();
if (Config.Database.Password.Length == 0)
{
Logger.LogError("Database.Password is empty in configs/plugins/PlaytimeHistory/PlaytimeHistory.json: " +
"nothing is recorded. Fill in the Database section, then css_plugins reload PlaytimeHistory");
return;
}
try
{
_db = new Database(Config.Database);
}
catch (ArgumentException ex)
{
Logger.LogError("{Error}: nothing is recorded", ex.Message);
return;
}
_writer = new Writer(_db, Path.Combine(ModuleDirectory, "spool.jsonl"), Config.SpoolMaxEntries, Logger);
_writer.Start();
// First, so sessions left open by a crash are closed before this run opens any.
_writer.Enqueue(new RecoverStale(Config.ServerId, Config.CountSpectatorTime));
Logger.LogInformation("Recording to {Host}:{Port}/{Database} as {User}, server id {Id}",
Config.Database.Host, Config.Database.Port, Config.Database.Database, Config.Database.User, Config.ServerId);
try
{
_map = Server.MapName;
}
catch (Exception)
{
_map = "";
}
_mapRunning = _map.Length > 0;
if (_mapRunning)
ServerSeenNow();
AddTimer(1.0f, Reconcile, TimerFlags.REPEAT);
AddTimer(Config.HeartbeatSeconds, () => _writer.Enqueue(new Heartbeat(Config.ServerId, Now())), TimerFlags.REPEAT);
AddTimer(Config.PingSampleIntervalSeconds, SamplePing, TimerFlags.REPEAT);
if (Config.OnlineSnapshotIntervalSeconds > 0)
AddTimer(Config.OnlineSnapshotIntervalSeconds, TakeSnapshot, TimerFlags.REPEAT);
}
public override void Unload(bool hotReload)
{
if (_writer != null)
{
CloseAll(EndKind.Unload);
_writer.Drain(TimeSpan.FromSeconds(5));
}
_geo?.Dispose();
}
// The config holds the database password.
private void ProtectConfigFile()
{
if (OperatingSystem.IsWindows())
return;
var path = Path.GetFullPath(Path.Combine(ModuleDirectory, "..", "..", "configs", "plugins", ModuleName, $"{ModuleName}.json"));
try
{
if (File.Exists(path))
File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite);
}
catch (Exception ex)
{
Logger.LogWarning("Couldn't make {Path} readable by its owner only: {Error}", path, ex.Message);
}
}
private void OpenGeo()
{
var path = Path.Combine(ModuleDirectory, Config.GeoIpDatabase);
try
{
_geo = Geo.Open(path);
}
catch (Exception ex)
{
Logger.LogError("Couldn't open the location database {Path}: {Error}", path, ex.Message);
return;
}
if (_geo == null)
Logger.LogWarning("No location database at {Path}: sessions get no country or city", path);
else
Logger.LogInformation("Location database {Type}, built {Date:yyyy-MM-dd}", _geo.DatabaseType, _geo.BuildDate);
}
private static DateTime Now()
{
var now = DateTime.UtcNow;
return new DateTime(now.Ticks - now.Ticks % TimeSpan.TicksPerSecond, DateTimeKind.Utc);
}
private static string NewKey() => Guid.NewGuid().ToString("N");
private static bool Trackable(CCSPlayerController? player) =>
player is { IsValid: true, IsBot: false, IsHLTV: false } &&
player.Connected == PlayerConnectedState.Connected && player.SteamID != 0;
private void ServerSeenNow()
{
var hostname = ConVar.Find("hostname")?.StringValue ?? "";
_writer?.Enqueue(new ServerSeen(Config.ServerId, Config.PublicAddress, hostname, Now()));
}
// --- Tracking ---------------------------------------------------------------------
// Once a second, and after the events that change state: opens sessions for players who've
// arrived, closes them for players who've gone, and moves everyone's segments along. The events
// make the times exact; this catches anything they miss.
private void Reconcile()
{
if (_writer == null || !_mapRunning)
return;
var now = Now();
var seen = new HashSet<int>();
for (var slot = 0; slot < Server.MaxPlayers; slot++)
{
var player = Utilities.GetPlayerFromSlot(slot);
if (!Trackable(player))
continue;
seen.Add(slot);
if (_tracked.TryGetValue(slot, out var tracked) && tracked.SteamId != player!.SteamID)
{
CloseSession(slot, tracked, EndKind.Disconnect, now, null, null);
tracked = null;
}
if (tracked == null)
OpenSession(slot, player!, now);
else
UpdateState(tracked, player!, now);
}
foreach (var slot in _tracked.Keys.Where(s => !seen.Contains(s)).ToList())
CloseSession(slot, _tracked[slot], EndKind.Disconnect, now, null, null);
}
private void UpdateSlot(int slot)
{
if (_writer == null || !_mapRunning || !_tracked.TryGetValue(slot, out var tracked))
return;
var player = Utilities.GetPlayerFromSlot(slot);
if (Trackable(player) && player!.SteamID == tracked.SteamId)
UpdateState(tracked, player, Now());
}
private static (int Team, bool? Alive) ReadState(CCSPlayerController player)
{
var team = Math.Clamp((int)player.Team, Teams.None, Teams.CT);
if (!Teams.Playing(team))
return (team, null);
var pawn = player.PlayerPawn.Value;
return (team, pawn is { IsValid: true } && pawn.LifeState == (byte)LifeState_t.LIFE_ALIVE);
}
private void OpenSession(int slot, CCSPlayerController player, DateTime now)
{
var location = LookupLocation(player.IpAddress);
var tracked = new Tracked { SteamId = player.SteamID, SessionKey = NewKey(), Baseline = ReadStats(player) };
_tracked[slot] = tracked;
_writer!.Enqueue(new SessionOpen(tracked.SessionKey, tracked.SteamId, Config.ServerId, player.PlayerName, now, _map,
_tracked.Count, Server.MaxPlayers, location?.CountryIso, location?.CountryName, location?.City, ModuleVersion));
UpdateState(tracked, player, now);
}
private void UpdateState(Tracked tracked, CCSPlayerController player, DateTime now)
{
var (team, alive) = ReadState(player);
if (tracked.SegmentKey != null && team == tracked.Team && alive == tracked.Alive)
return;
if (tracked.SegmentKey != null && now <= tracked.SegmentStart)
{
_writer!.Enqueue(new SegmentRestate(tracked.SegmentKey, team, alive));
}
else
{
if (tracked.SegmentKey != null)
_writer!.Enqueue(new SegmentClose(tracked.SegmentKey, now, Config.CountSpectatorTime));
tracked.SegmentKey = NewKey();
tracked.SegmentStart = now;
_writer!.Enqueue(new SegmentOpen(tracked.SegmentKey, tracked.SessionKey, tracked.SteamId, Config.ServerId, _map,
team, alive, now));
}
tracked.Team = team;
tracked.Alive = alive;
}
// `player` is null when they're already gone; then the session has no match stats.
private void CloseSession(int slot, Tracked tracked, EndKind kind, DateTime now, CCSPlayerController? player, int? reason)
{
_tracked.Remove(slot);
if (tracked.SegmentKey != null)
_writer!.Enqueue(new SegmentClose(tracked.SegmentKey, now, Config.CountSpectatorTime));
var stats = player != null ? Delta(ReadStats(player), tracked.Baseline) : null;
var ping = tracked.PingSamples > 0
? new PingStats((int)(tracked.PingSum / tracked.PingSamples), tracked.PingMin, tracked.PingMax, tracked.PingSamples)
: null;
string? reasonName = reason is { } r
? (Enum.IsDefined(typeof(NetworkDisconnectionReason), r)
? ((NetworkDisconnectionReason)r).ToString().Replace("NETWORK_DISCONNECT_", "")
: $"REASON_{r}")
: null;
_writer!.Enqueue(new SessionClose(tracked.SessionKey, tracked.SteamId, now, kind, _map, reason, reasonName,
stats, tracked.Rounds, ping));
}
private void CloseAll(EndKind kind)
{
var now = Now();
foreach (var (slot, tracked) in _tracked.ToList())
{
var player = Utilities.GetPlayerFromSlot(slot);
var same = player is { IsValid: true } && player.SteamID == tracked.SteamId;
CloseSession(slot, tracked, kind, now, same ? player : null, null);
}
}
private static MatchStats? ReadStats(CCSPlayerController player)
{
var stats = player.ActionTrackingServices?.MatchStats;
if (stats == null)
return null;
return new MatchStats(stats.Kills, stats.Deaths, stats.Assists, stats.HeadShotKills, stats.Damage, player.MVPs, player.Score);
}
// This session's share of the map's running totals. Totals that went down were reset, so they
// count from zero.
private static MatchStats? Delta(MatchStats? now, MatchStats? baseline)
{
if (now == null)
return null;
if (baseline == null)
return now;
static int D(int n, int b) => n >= b ? n - b : n;
return new MatchStats(D(now.Kills, baseline.Kills), D(now.Deaths, baseline.Deaths), D(now.Assists, baseline.Assists),
D(now.Headshots, baseline.Headshots), D(now.Damage, baseline.Damage), D(now.Mvp, baseline.Mvp),
D(now.Score, baseline.Score));
}
private Geo.Location? LookupLocation(string? address)
{
if (_geo == null || _geoBroken)
return null;
try
{
return _geo.Lookup(address);
}
catch (Exception ex)
{
// A broken file would fail for everyone; say so once.
_geoBroken = true;
Logger.LogError("Location lookups are off until the next reload: {Error}", ex.Message);
return null;
}
}
private void SamplePing()
{
foreach (var (slot, tracked) in _tracked)
{
var player = Utilities.GetPlayerFromSlot(slot);
if (player is not { IsValid: true } || player.SteamID != tracked.SteamId)
continue;
var ping = (int)player.Ping;
if (ping <= 0)
continue;
tracked.PingSum += ping;
tracked.PingSamples++;
tracked.PingMin = Math.Min(tracked.PingMin, ping);
tracked.PingMax = Math.Max(tracked.PingMax, ping);
}
}
private void TakeSnapshot()
{
if (_writer == null || !_mapRunning)
return;
var bots = Utilities.GetPlayers().Count(p => p.IsBot);
_writer.Enqueue(new OnlineSnapshot(Config.ServerId, Now(), _tracked.Count, bots, Server.MaxPlayers, _map));
}
// --- Events -----------------------------------------------------------------------
private void OnMapStart(string map)
{
_map = map;
_mapRunning = true;
ServerSeenNow();
}
private void OnMapEnd()
{
if (_writer != null)
CloseAll(EndKind.MapChange);
_mapRunning = false;
}
private void OnClientDisconnect(int slot)
{
// Normally player_disconnect has closed it already, with the reason.
if (_writer != null && _tracked.TryGetValue(slot, out var tracked))
CloseSession(slot, tracked, EndKind.Disconnect, Now(), null, null);
}
private HookResult OnPlayerDisconnect(EventPlayerDisconnect @event, GameEventInfo info)
{
var player = @event.Userid;
if (_writer != null && player is { IsValid: true } && _tracked.TryGetValue(player.Slot, out var tracked) &&
tracked.SteamId == player.SteamID)
CloseSession(player.Slot, tracked, EndKind.Disconnect, Now(), player, @event.Reason);
return HookResult.Continue;
}
private HookResult OnPlayerConnectFull(EventPlayerConnectFull @event, GameEventInfo info)
{
Server.NextFrame(Reconcile);
return HookResult.Continue;
}
private HookResult OnPlayerTeam(EventPlayerTeam @event, GameEventInfo info)
{
if (!@event.Disconnect)
UpdateNextFrame(@event.Userid);
return HookResult.Continue;
}
private HookResult OnPlayerSpawn(EventPlayerSpawn @event, GameEventInfo info)
{
UpdateNextFrame(@event.Userid);
return HookResult.Continue;
}
private HookResult OnPlayerDeath(EventPlayerDeath @event, GameEventInfo info)
{
UpdateNextFrame(@event.Userid);
return HookResult.Continue;
}
// The controller and pawn have caught up with the event by the next frame.
private void UpdateNextFrame(CCSPlayerController? player)
{
if (player is not { IsValid: true })
return;
var slot = player.Slot;
Server.NextFrame(() => UpdateSlot(slot));
}
private HookResult OnRoundEnd(EventRoundEnd @event, GameEventInfo info)
{
foreach (var tracked in _tracked.Values)
if (Teams.Playing(tracked.Team))
tracked.Rounds++;
return HookResult.Continue;
}
// --- Commands ---------------------------------------------------------------------
[ConsoleCommand("css_playtime", "Shows playtime: yours, or (staff) someone else's")]
[CommandHelper(usage: "[name|#userid|steamid64]")]
public void OnPlaytimeCommand(CCSPlayerController? caller, CommandInfo command) => Lookup(caller, command, sessions: false);
[ConsoleCommand("css_lastseen", "Shows recent sessions: yours, or (staff) someone else's")]
[CommandHelper(usage: "[name|#userid|steamid64]")]
public void OnLastSeenCommand(CCSPlayerController? caller, CommandInfo command) => Lookup(caller, command, sessions: true);
[ConsoleCommand("css_pt_status", "Shows PlaytimeHistory's database state")]
[RequiresPermissions(StatusPermission)]
public void OnStatusCommand(CCSPlayerController? caller, CommandInfo command)
{
if (_writer == null)
{
command.ReplyToCommand("[Playtime] Not recording: the database isn't configured (see the server log).");
return;
}
var lastOk = _writer.LastOkAt is { } at ? $"{at:yyyy-MM-dd HH:mm:ss} UTC" : "never";
command.ReplyToCommand($"[Playtime] Database: {(_writer.Failing ? "FAILING" : _writer.Migrated ? "ok" : "connecting")}, " +
$"last success {lastOk}" + (_writer.Failing && _writer.LastError != null ? $", error: {_writer.LastError}" : ""));
command.ReplyToCommand($"[Playtime] Queued writes: {_writer.Queued}, spooled: {_writer.Spooled}, open sessions: {_tracked.Count}");
command.ReplyToCommand(_geo != null
? $"[Playtime] Location database: {_geo.DatabaseType}, built {_geo.BuildDate:yyyy-MM-dd}{(_geoBroken ? " (lookups off after an error)" : "")}"
: "[Playtime] Location database: not loaded");
}
private void Lookup(CCSPlayerController? caller, CommandInfo command, bool sessions)
{
if (_db == null)
{
command.ReplyToCommand("[Playtime] Not recording: the database isn't configured.");
return;
}
var arg = command.ArgCount > 1 ? command.ArgString.Trim() : "";
ulong steamId;
string name;
if (arg.Length == 0)
{
if (caller == null)
{
command.ReplyToCommand($"Usage: {command.GetArg(0)} <name|#userid|steamid64>");
return;
}
steamId = caller.SteamID;
name = caller.PlayerName;
}
else
{
if (caller != null && !AdminManager.PlayerHasPermissions(caller, Config.OtherPlayersPermission))
{
command.ReplyToCommand("[Playtime] You can only look yourself up.");
return;
}
var error = ResolveTarget(arg, out steamId, out name);
if (error != null)
{
command.ReplyToCommand($"[Playtime] {error}");
return;
}
}
if (caller != null)
{
var now = DateTime.UtcNow;
if (_cooldowns.TryGetValue(caller.SteamID, out var until) && until > now)
{
command.ReplyToCommand($"[Playtime] Wait {Math.Ceiling((until - now).TotalSeconds)}s.");
return;
}
_cooldowns[caller.SteamID] = now.AddSeconds(Config.CommandCooldownSeconds);
}
var db = _db;
var limit = Config.LastSeenLimit;
int? callerSlot = caller?.Slot;
var callerSteamId = caller?.SteamID ?? 0;
Task.Run(async () =>
{
List<string> lines;
try
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15));
if (sessions)
{
var recent = await db.GetRecentSessionsAsync(steamId, limit, timeout.Token);
await Server.NextFrameAsync(() => Reply(callerSlot, callerSteamId, DescribeSessions(name, recent)));
}
else
{
var summary = await db.GetSummaryAsync(steamId, timeout.Token);
await Server.NextFrameAsync(() => Reply(callerSlot, callerSteamId, DescribeSummary(name, steamId, summary)));
}
return;
}
catch (Exception ex)
{
Logger.LogError("Lookup for {SteamId} failed: {Error}", steamId, ex.Message);
lines = ["Couldn't read the database, try again later."];
}
await Server.NextFrameAsync(() => Reply(callerSlot, callerSteamId, lines));
});
}
// A SteamID64 (online or not), #userid, or part of an online player's name.
private static string? ResolveTarget(string arg, out ulong steamId, out string name)
{
steamId = 0;
name = arg;
if (arg.Length == 17 && ulong.TryParse(arg, out var id) && id > 76561197960265728UL)
{
steamId = id;
name = Utilities.GetPlayers().FirstOrDefault(p => p.SteamID == id)?.PlayerName ?? arg;
return null;
}
var players = Utilities.GetPlayers().Where(p => !p.IsBot && !p.IsHLTV).ToList();
List<CCSPlayerController> matches;
if (arg.StartsWith('#') && int.TryParse(arg[1..], out var userId))
matches = players.Where(p => p.UserId == userId).ToList();
else
{
matches = players.Where(p => string.Equals(p.PlayerName, arg, StringComparison.OrdinalIgnoreCase)).ToList();
if (matches.Count == 0)
matches = players.Where(p => p.PlayerName.Contains(arg, StringComparison.OrdinalIgnoreCase)).ToList();
}
if (matches.Count == 0)
return $"No player online matches \"{arg}\". Offline players: use their SteamID64.";
if (matches.Count > 1)
return $"\"{arg}\" matches {matches.Count} players: {string.Join(", ", matches.Select(p => p.PlayerName))}.";
steamId = matches[0].SteamID;
name = matches[0].PlayerName;
return null;
}
private List<string> DescribeSummary(string name, ulong steamId, Database.Summary? summary)
{
// pt_players only has closed segments; add the one open now.
long t = 0, ct = 0, spec = 0, alive = 0, dead = 0;
var live = _tracked.Values.FirstOrDefault(x => x.SteamId == steamId);
if (live?.SegmentKey != null)
{
var seconds = Math.Max(0L, (long)(Now() - live.SegmentStart).TotalSeconds);
if (live.Team == Teams.T) t = seconds;
else if (live.Team == Teams.CT) ct = seconds;
else spec = seconds;
if (Teams.Playing(live.Team))
{
if (live.Alive == true) alive = seconds;
else dead = seconds;
}
}
if (summary == null && live == null)
return [$"No playtime recorded for {name}."];
var s = summary ?? new Database.Summary(DateTime.UtcNow, DateTime.UtcNow, 1, 0, 0, 0, 0, 0, 0, name, 0, 0);
t += s.T;
ct += s.CT;
spec += s.Spec;
alive += s.Alive;
dead += s.Dead;
var total = t + ct + (Config.CountSpectatorTime ? spec : 0);
return
[
$"{name}: {Duration(total)} played{(Config.CountSpectatorTime ? " (spectating included)" : "")}",
$"T {Duration(t)}, CT {Duration(ct)}, spectating {Duration(spec)}",
$"Alive {Duration(alive)}, dead {Duration(dead)}",
$"Last 7 days: {Duration(s.WeekPlaying)} on a team, {Duration(s.WeekSpec)} spectating. " +
$"{s.Sessions} session{(s.Sessions == 1 ? "" : "s")} since {s.FirstSeen:yyyy-MM-dd}",
];
}
private static List<string> DescribeSessions(string name, List<Database.RecentSession> sessions)
{
if (sessions.Count == 0)
return [$"No sessions recorded for {name}."];
var lines = new List<string> { $"{name}'s last {sessions.Count} session{(sessions.Count == 1 ? "" : "s")} (UTC):" };
foreach (var s in sessions)
{
var length = s.EndedAt == null
? $"{Duration((long)(DateTime.UtcNow - s.StartedAt).TotalSeconds)}, online now"
: Duration(s.Seconds ?? 0) + (s.EndKind == EndKind.Stale ? " (server stopped)" : "");
lines.Add($"{s.StartedAt:yyyy-MM-dd HH:mm} {length} {s.Map}");
}
return lines;
}
private static void Reply(int? slot, ulong steamId, List<string> lines)
{
if (slot == null)
{
foreach (var line in lines)
Server.PrintToConsole($"[Playtime] {line}");
return;
}
var player = Utilities.GetPlayerFromSlot(slot.Value);
if (player is not { IsValid: true } || player.SteamID != steamId)
return;
foreach (var line in lines)
player.PrintToChat($" {ChatColors.Green}[Playtime]{ChatColors.Default} {line}");
}
private static string Duration(long seconds)
{
if (seconds < 60)
return $"{Math.Max(0, seconds)}s";
var minutes = seconds / 60;
return minutes < 60 ? $"{minutes}m" : $"{minutes / 60}h {minutes % 60:00}m";
}
}

25
PlaytimeHistory.csproj Normal file
View file

@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>PlaytimeHistory</AssemblyName>
<RootNamespace>PlaytimeHistory</RootNamespace>
</PropertyGroup>
<ItemGroup>
<!-- Compile-time only: the server already has CounterStrikeSharp.API.dll -->
<PackageReference Include="CounterStrikeSharp.API" Version="1.0.376">
<PrivateAssets>none</PrivateAssets>
<ExcludeAssets>runtime</ExcludeAssets>
<IncludeAssets>compile; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<!-- The same MySQL driver and version as CS2-SimpleAdmin and GroupVerify; ships with the plugin -->
<PackageReference Include="MySqlConnector" Version="2.6.0" />
<!-- Reads DB-IP's location database (MaxMind DB format); the same version jRandomSkills ships -->
<PackageReference Include="MaxMind.Db" Version="5.1.0" />
</ItemGroup>
<ItemGroup>
<!-- Embedded, so the tables are created without any extra files on the server -->
<EmbeddedResource Include="Migrations/*.sql" LogicalName="PlaytimeHistory.Migrations.%(Filename)%(Extension)" />
</ItemGroup>
</Project>

209
README.md Normal file
View file

@ -0,0 +1,209 @@
# PlaytimeHistory
Records every player's time on the server in its own MySQL/MariaDB database:
- **Sessions**: join to leave (or to a map change), with the map, disconnect reason, country and
city, match stats (kills, deaths, assists, headshots, damage, MVPs, score), rounds played and ping.
- **Segments** inside each session: stretches where the player's team (unassigned, spectator, T,
CT) and alive/dead state didn't change. Per-hour, per-team, alive/dead and spectator time all come
from these with SQL; see [Queries](#queries).
- Running totals per player, nickname history, and a count of players online every 5 minutes.
It replaces ConnectHistory. Player IPs are never stored: the IP is only used, on join, to look up a
country and city.
Warmup counts like any other time. Bots and SourceTV are ignored.
## Setup
1. Create the database and a user for the plugin, as root in MariaDB. On fr04 the cs2 container
reaches MariaDB from `10.89.0.1` (the `systemd-static` network's gateway), so that's the host:
```sql
CREATE DATABASE playtime_history CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'pt_plugin'@'10.89.0.1' IDENTIFIED BY '<strong password>';
GRANT SELECT, INSERT, UPDATE, CREATE, INDEX, ALTER, CREATE VIEW ON playtime_history.* TO 'pt_plugin'@'10.89.0.1';
```
It never needs `DELETE` or `DROP`. For something that only reads it (the web panel), a separate
user with `GRANT SELECT ON playtime_history.*` is enough, the hourly view included.
2. Start the server once. The plugin writes `configs/plugins/PlaytimeHistory/PlaytimeHistory.json`
(mode 600) and records nothing until `Database.Password` is filled in:
```json
{
"Database": {
"Host": "10.89.0.25",
"Port": 3306,
"Database": "playtime_history",
"User": "pt_plugin",
"Password": "<the password>",
"SslMode": "None"
},
"ServerId": 1,
"PublicAddress": "162.19.125.4:27015",
"CountSpectatorTime": false,
"GeoIpDatabase": "dbip-city-lite.mmdb",
"PingSampleIntervalSeconds": 30,
"OnlineSnapshotIntervalSeconds": 300,
"HeartbeatSeconds": 60,
"SpoolMaxEntries": 20000,
"OtherPlayersPermission": "@css/generic",
"CommandCooldownSeconds": 5,
"LastSeenLimit": 5,
"ConfigVersion": 1
}
```
`SslMode` `None` is fine on fr04, where the connection never leaves the host's podman networks.
3. `css_plugins reload PlaytimeHistory`, then `css_pt_status` should say `Database: ok`. The tables
are created on first connect.
| Setting | Meaning |
|---|---|
| `ServerId` | `pt_servers.id`. Must differ between servers sharing a database. |
| `PublicAddress` | Stored in `pt_servers.address`; the server can't work out its own public address. |
| `CountSpectatorTime` | Whether spectating and unassigned time counts toward `pt_players.total_seconds` and the `css_playtime` total. It's recorded either way. |
| `GeoIpDatabase` | The location database, relative to the plugin folder. Missing: no country or city. |
| `HeartbeatSeconds` | How often open sessions and segments are marked as still going. After a crash they're closed at their last heartbeat, so at most this much time is lost. |
| `SpoolMaxEntries` | Writes kept on disk while the database is down (see below). |
| `OtherPlayersPermission` | Needed to look up anyone but yourself. |
## Commands
| Command | Who | What |
|---|---|---|
| `css_playtime [player]` | everyone (others: `OtherPlayersPermission`) | Total, T/CT/spectating, alive/dead, last 7 days |
| `css_lastseen [player]` | everyone (others: `OtherPlayersPermission`) | Recent sessions |
| `css_pt_status` | `@css/root` | Database state, queued and spooled writes, location database |
`[player]` is part of an online player's name, `#userid`, or a SteamID64 (online or not). From the
server console the answer to `css_playtime`/`css_lastseen` is printed to the console a moment later,
so it doesn't reach RCON; read the database for that.
## How it records
Every second, and right after `player_team`, `player_spawn`, `player_death` and
`player_connect_full`, the plugin reads each player's team and whether their pawn is alive. A
change closes the current segment and opens the next, so segment times are exact to the second.
Sessions close on `player_disconnect` (with the reason), on map end and on unload.
Database writes run in order on one background task, never on the game thread. When the database is
unreachable (or refuses the login), writes go to `plugins/PlaytimeHistory/spool.jsonl` and are sent,
in order, once it's back; `css_pt_status` shows how many are waiting. Writes are keyed, so sending
one twice changes nothing. A write the database rejects outright (bad data) is logged and dropped.
If the server dies with sessions open, the next start closes them at their last heartbeat with
`end_kind` 4.
## Tables
All times are UTC (`UTC_TIMESTAMP()`, not `NOW()`, when comparing).
| Table | What |
|---|---|
| `pt_sessions` | One row per connection. `end_kind`: 0 open, 1 disconnect, 2 map change, 3 plugin unload, 4 stale (server died). `duration_seconds` is connected time. |
| `pt_segments` | `team` (0 unassigned, 1 spectator, 2 T, 3 CT), `alive` (1/0, NULL off T/CT), `started_at`, `ended_at`, `seconds`, `map`, `session_key`. Open while `ended_at IS NULL`. |
| `pt_segments_hourly` | View: closed segments split at UTC hour boundaries, one row per segment per hour, with `hour_start` and `seconds`. |
| `pt_players` | Totals per player: `total_seconds`, `t_seconds`, `ct_seconds`, `spec_seconds` (spectator + unassigned), `alive_seconds`, `dead_seconds`, `sessions_count`, first/last seen, last nickname and country. Only closed segments are in them. |
| `pt_nicknames` | Every name a SteamID has used, with first/last seen and times seen. |
| `pt_servers` | `id`, `address`, `hostname`, first/last seen. |
| `pt_online_snapshots` | Players (humans), bots and slots online, every `OnlineSnapshotIntervalSeconds`. |
| `pt_migrations` | Applied migrations. |
## Queries
Playtime per hour for one player, by state:
```sql
SELECT hour_start, team, alive, SUM(seconds) AS seconds
FROM pt_segments_hourly
WHERE steamid64 = ? AND hour_start >= UTC_TIMESTAMP() - INTERVAL 7 DAY
GROUP BY hour_start, team, alive
ORDER BY hour_start, team, alive;
```
Time on a team per hour of the day, everyone, last 30 days (`team IN (0, 1)` for spectating):
```sql
SELECT HOUR(hour_start) AS hour_utc, ROUND(SUM(seconds) / 3600, 1) AS hours
FROM pt_segments_hourly
WHERE team IN (2, 3) AND hour_start >= UTC_TIMESTAMP() - INTERVAL 30 DAY
GROUP BY HOUR(hour_start)
ORDER BY hour_utc;
```
The view splits every closed segment before filtering. If it gets slow, split from `pt_segments`
with the date filter inside instead:
```sql
WITH RECURSIVE s AS (
SELECT steamid64, team, alive, started_at AS t0, ended_at AS t1
FROM pt_segments
WHERE ended_at IS NOT NULL AND ended_at > started_at
AND started_at >= UTC_TIMESTAMP() - INTERVAL 7 DAY
),
h AS (
SELECT steamid64, team, alive, t1,
CAST(DATE_FORMAT(t0, '%Y-%m-%d %H:00:00') AS DATETIME) AS hour_start, t0 AS piece_start
FROM s
UNION ALL
SELECT steamid64, team, alive, t1, hour_start + INTERVAL 1 HOUR, hour_start + INTERVAL 1 HOUR
FROM h WHERE hour_start + INTERVAL 1 HOUR < t1
)
SELECT steamid64, hour_start, team, alive,
SUM(TIMESTAMPDIFF(SECOND, piece_start, LEAST(t1, hour_start + INTERVAL 1 HOUR))) AS seconds
FROM h
GROUP BY steamid64, hour_start, team, alive;
```
Leaderboard, time on a team:
```sql
SELECT last_nickname, steamid64, ROUND((t_seconds + ct_seconds) / 3600, 1) AS hours,
ROUND(alive_seconds / GREATEST(t_seconds + ct_seconds, 1) * 100) AS pct_alive
FROM pt_players
ORDER BY t_seconds + ct_seconds DESC
LIMIT 50;
```
T, CT, spectating, alive and dead in a date range (`pt_players` is all-time):
```sql
SELECT SUM(IF(team = 2, seconds, 0)) AS t, SUM(IF(team = 3, seconds, 0)) AS ct,
SUM(IF(team IN (0, 1), seconds, 0)) AS spectating,
SUM(IF(alive = 1, seconds, 0)) AS alive, SUM(IF(alive = 0, seconds, 0)) AS dead
FROM pt_segments
WHERE steamid64 = ? AND ended_at IS NOT NULL
AND started_at >= '2026-10-01' AND started_at < '2026-11-01';
```
Who's online now:
```sql
SELECT s.nickname, s.steamid64, s.started_at, g.team, g.alive
FROM pt_sessions s LEFT JOIN pt_segments g ON g.session_key = s.session_key AND g.ended_at IS NULL
WHERE s.server_id = 1 AND s.ended_at IS NULL;
```
## Location data
Country and city come from DB-IP's free **IP to City Lite** database
(https://db-ip.com/db/download/ip-to-city-lite), licensed CC BY 4.0. Anywhere that shows them (the
web panel, say) must credit it: `IP Geolocation by <a href="https://db-ip.com">DB-IP</a>`.
`server/pre.sh` installs it into the plugin folder as `dbip-city-lite.mmdb` (about 60 MB to
download, 127 MB unpacked), pinned by month in `DBIP_VERSION` (`YYYY-MM`). DB-IP publishes a new one
on the 1st of each month; bump `DBIP_VERSION` by hand to update. The plugin reads it on load, so
reload or restart after a new one.
## Releases
`./release.sh <tag>` (with `FORGEJO_TOKEN`) rebuilds from the committed source and publishes
`PlaytimeHistory-<tag>.tar.gz`, laid out like `game/csgo/`. The tag must be `v<ModuleVersion>`. It
ships the plugin and its libraries (`MySqlConnector`, `MaxMind.Db`), never `configs/` or the
location database. `server/pre.sh` installs it (`PTH_VERSION`).
Build locally with `../../build.sh` from this folder; output goes to `compiled/PlaytimeHistory/`.

275
Writer.cs Normal file
View file

@ -0,0 +1,275 @@
using System.Text.Json;
using System.Threading.Channels;
using Microsoft.Extensions.Logging;
using MySqlConnector;
namespace PlaytimeHistory;
// Runs every database write on one background task, in the order the game thread queued them. When
// the database can't be reached, writes go to a spool file (one JSON op per line) instead, and every
// later write follows them there until the spool has been sent, so the order never changes. Migrations
// run first, retried until they work; until then everything is spooled.
public sealed class Writer
{
private static readonly TimeSpan RetryInterval = TimeSpan.FromSeconds(15);
private readonly Database _db;
private readonly string _spoolPath;
private readonly int _spoolMax;
private readonly ILogger _logger;
private readonly Channel<Op> _queue = Channel.CreateUnbounded<Op>(new UnboundedChannelOptions { SingleReader = true });
private readonly CancellationTokenSource _stop = new();
private Task? _loop;
private bool _migrated;
private bool _failing;
private bool _spoolFullWarned;
// While failing, migrations and the spool are only retried from this time on (UTC).
private DateTime _retryAt;
private volatile int _spooled;
private volatile string? _lastError;
private DateTime? _lastOkAt;
public Writer(Database db, string spoolPath, int spoolMax, ILogger logger)
{
_db = db;
_spoolPath = spoolPath;
_spoolMax = spoolMax;
_logger = logger;
}
public int Queued => _queue.Reader.Count;
public int Spooled => _spooled;
public bool Migrated => Volatile.Read(ref _migrated);
public bool Failing => Volatile.Read(ref _failing);
public string? LastError => _lastError;
public DateTime? LastOkAt { get { lock (_stop) return _lastOkAt; } }
public void Start() => _loop = Task.Run(RunAsync);
// From the game thread. Never blocks.
public void Enqueue(Op op) => _queue.Writer.TryWrite(op);
// On unload: sends what's queued, waiting up to `timeout`, then spools anything left.
public void Drain(TimeSpan timeout)
{
_queue.Writer.TryComplete();
if (_loop == null)
return;
if (!_loop.Wait(timeout))
{
_stop.Cancel();
if (!_loop.Wait(TimeSpan.FromSeconds(2)))
{
_logger.LogWarning("Database writer didn't stop; {Count} queued writes are lost", Queued);
return;
}
}
while (_queue.Reader.TryRead(out var op))
Spool(op);
}
private async Task RunAsync()
{
_spooled = CountSpool();
if (_spooled > 0)
_logger.LogInformation("{Count} writes waiting in the spool from before", _spooled);
var token = _stop.Token;
while (!token.IsCancellationRequested)
{
if (!_failing || DateTime.UtcNow >= _retryAt)
{
if (!_migrated)
await TryMigrateAsync(token);
if (_migrated && _spooled > 0)
await FlushSpoolAsync(token);
}
while (_queue.Reader.TryRead(out var op))
await HandleAsync(op, token);
bool more;
try
{
using var wait = CancellationTokenSource.CreateLinkedTokenSource(token);
wait.CancelAfter(RetryInterval);
more = await _queue.Reader.WaitToReadAsync(wait.Token);
}
catch (OperationCanceledException)
{
continue;
}
if (!more)
{
// Unloading: everything's been read. One last try at the spool.
if (_migrated && _spooled > 0)
await FlushSpoolAsync(token);
return;
}
}
}
private async Task TryMigrateAsync(CancellationToken token)
{
try
{
foreach (var version in await _db.MigrateAsync(token))
_logger.LogInformation("Migration \"{Version}\" applied", version);
Volatile.Write(ref _migrated, true);
Succeeded();
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
Failed(ex, "Can't reach the database to set up its tables; writes are spooled until it works");
}
}
private async Task HandleAsync(Op op, CancellationToken token)
{
if (!_migrated || _spooled > 0)
{
Spool(op);
return;
}
try
{
await _db.ApplyAsync(op, token);
Succeeded();
}
catch (Exception ex) when (Transient(ex))
{
Failed(ex, "Database write failed; spooling writes until it's back");
Spool(op);
}
catch (Exception ex)
{
_lastError = ex.Message;
_logger.LogError(ex, "Dropped a {Op} write the database refused", op.GetType().Name);
}
}
// Sends the spool in order. Stops at the first write that fails for a reason that may pass, and
// keeps it and the rest.
private async Task FlushSpoolAsync(CancellationToken token)
{
string[] lines;
try
{
lines = File.ReadAllLines(_spoolPath);
}
catch (FileNotFoundException)
{
_spooled = 0;
return;
}
var sent = 0;
for (; sent < lines.Length; sent++)
{
Op? op;
try
{
op = JsonSerializer.Deserialize<Op>(lines[sent]);
}
catch (JsonException ex)
{
_logger.LogError(ex, "Skipped an unreadable spool line");
continue;
}
if (op == null)
continue;
try
{
await _db.ApplyAsync(op, token);
Succeeded();
}
catch (Exception ex) when (Transient(ex))
{
Failed(ex, "Database write failed while sending the spool; trying again later");
break;
}
catch (Exception ex)
{
_lastError = ex.Message;
_logger.LogError(ex, "Dropped a spooled {Op} write the database refused", op.GetType().Name);
}
}
if (sent >= lines.Length)
{
File.Delete(_spoolPath);
_spooled = 0;
_logger.LogInformation("Sent {Count} spooled writes", lines.Length);
return;
}
var temp = _spoolPath + ".tmp";
File.WriteAllLines(temp, lines[sent..]);
File.Move(temp, _spoolPath, overwrite: true);
_spooled = lines.Length - sent;
}
private void Spool(Op op)
{
if (_spooled >= _spoolMax)
{
if (!_spoolFullWarned)
_logger.LogError("The spool is full ({Max} writes); new writes are dropped until the database is back", _spoolMax);
_spoolFullWarned = true;
return;
}
try
{
File.AppendAllText(_spoolPath, JsonSerializer.Serialize(op) + "\n");
_spooled++;
}
catch (Exception ex)
{
_logger.LogError(ex, "Couldn't write to the spool {Path}; a {Op} write is lost", _spoolPath, op.GetType().Name);
}
}
private int CountSpool()
{
try
{
return File.ReadLines(_spoolPath).Count(l => l.Length > 0);
}
catch (FileNotFoundException)
{
return 0;
}
}
// Worth retrying: the database is unreachable, overloaded, or refusing our login (the password may
// be fixed). Anything else (bad data, a missing table) would fail again forever.
private static bool Transient(Exception ex) => ex switch
{
OperationCanceledException => true,
MySqlException m => m.IsTransient || m.ErrorCode is MySqlErrorCode.UnableToConnectToHost
or MySqlErrorCode.AccessDenied or MySqlErrorCode.DatabaseAccessDenied,
TimeoutException or IOException or System.Net.Sockets.SocketException => true,
_ => ex.InnerException != null && Transient(ex.InnerException),
};
private void Succeeded()
{
lock (_stop)
_lastOkAt = DateTime.UtcNow;
_spoolFullWarned = false;
if (Volatile.Read(ref _failing))
_logger.LogInformation("Database is reachable again");
Volatile.Write(ref _failing, false);
}
// Logs once per outage, not per write.
private void Failed(Exception ex, string message)
{
_lastError = ex.Message;
_retryAt = DateTime.UtcNow + RetryInterval;
if (!Volatile.Read(ref _failing))
_logger.LogError("{Message}: {Error}", message, ex.Message);
Volatile.Write(ref _failing, true);
}
}

122
release.sh Executable file
View file

@ -0,0 +1,122 @@
#!/usr/bin/env bash
# Build PlaytimeHistory, package it as one tarball and publish it as a Forgejo release.
#
# ./release.sh <tag> e.g. ./release.sh v0.1.0
#
# Commit first: the plugin is rebuilt here from the committed source, so the release always matches
# the tag. The tag is created on HEAD and pushed to origin if it doesn't exist yet. Re-running with
# the same tag replaces that release's attachment.
#
# Asset: PlaytimeHistory-<tag>.tar.gz, laid out like the server's game/csgo/ - extract it there:
# tar -xzf PlaytimeHistory-<tag>.tar.gz -C /srv/cs2/game/csgo
#
# addons/counterstrikesharp/plugins/PlaytimeHistory/ PlaytimeHistory.dll, .pdb, .deps.json,
# MySqlConnector.dll, MaxMind.Db.dll and the
# Microsoft.Extensions.* abstractions
#
# No configs/: the plugin writes configs/plugins/PlaytimeHistory/PlaytimeHistory.json (its database
# settings) itself when it's missing, so an upgrade never touches them. The location database
# (dbip-city-lite.mmdb) isn't in here either: pre.sh installs it from DB-IP (DBIP_VERSION).
set -euo pipefail
FORGEJO_URL="https://git.zio.sh"
OWNER="cs2"
REPO="PlaytimeHistory"
# Placeholder - replace, or set FORGEJO_TOKEN in the environment instead. Needs write:repository.
# Only uploading needs it; downloading from the public repo doesn't. Don't commit a real token.
FORGEJO_TOKEN="${FORGEJO_TOKEN:-CHANGE_ME_FORGEJO_TOKEN}"
# Only these ship from the publish output. CounterStrikeSharp.API.dll is excluded by the csproj
# (ExcludeAssets=runtime); the server already provides it.
PLUGIN_FILES=(
PlaytimeHistory.dll
PlaytimeHistory.pdb
PlaytimeHistory.deps.json
MySqlConnector.dll
MaxMind.Db.dll
Microsoft.Extensions.DependencyInjection.Abstractions.dll
Microsoft.Extensions.Logging.Abstractions.dll
)
TAG="${1:-}"
if [[ -z "$TAG" ]]; then
echo "usage: $0 <tag> (e.g. v0.1.0)" >&2
exit 1
fi
if [[ "$FORGEJO_TOKEN" == "CHANGE_ME_FORGEJO_TOKEN" ]]; then
echo "Set FORGEJO_TOKEN (edit release.sh or export it) first." >&2
exit 1
fi
for tool in podman jq curl git; do
command -v "$tool" >/dev/null || { echo "'$tool' is required." >&2; exit 1; }
done
cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")"
if [[ -n "$(git status --porcelain --untracked-files=no)" ]]; then
echo "Tracked files have uncommitted changes; commit them so the tag matches the release." >&2
exit 1
fi
# The tag should name the version the plugin reports in `css_plugins list`.
version="$(sed -n 's/.*ModuleVersion => "\(.*\)";.*/\1/p' PlaytimeHistory.cs)"
if [[ "${TAG#v}" != "$version" ]]; then
echo "Tag $TAG doesn't match ModuleVersion $version in PlaytimeHistory.cs." >&2
exit 1
fi
# --- Build -----------------------------------------------------------------------
OUT="release-out"
PUBLISH="$OUT/publish"
rm -rf bin obj "$OUT"
podman run --rm -v "$(pwd)":/src:Z -w /src \
mcr.microsoft.com/dotnet/sdk:10.0 dotnet publish -c Release -o "/src/$PUBLISH"
# --- Stage (layout of game/csgo/) and package ----------------------------------------
STAGE="$OUT/stage"
PLUGIN_DIR="$STAGE/addons/counterstrikesharp/plugins/PlaytimeHistory"
mkdir -p "$PLUGIN_DIR"
for file in "${PLUGIN_FILES[@]}"; do
cp "$PUBLISH/$file" "$PLUGIN_DIR/"
done
TARBALL="PlaytimeHistory-${TAG}.tar.gz"
tar -czf "$OUT/$TARBALL" -C "$STAGE" addons
rm -rf "$STAGE" "$PUBLISH"
echo "Packaged:"
tar -tzf "$OUT/$TARBALL"
# --- Tag ---------------------------------------------------------------------------
if ! git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
git tag -a "$TAG" -m "$TAG"
fi
git push origin "refs/tags/$TAG"
# --- Release -----------------------------------------------------------------------
API="$FORGEJO_URL/api/v1/repos/$OWNER/$REPO"
AUTH=(-H "Authorization: token $FORGEJO_TOKEN")
release_json="$(curl -fsS "${AUTH[@]}" "$API/releases/tags/$TAG" 2>/dev/null || true)"
if [[ -z "$release_json" ]]; then
body="$(git log -1 --format=%B "$TAG")"
release_json="$(jq -n --arg tag "$TAG" --arg body "$body" \
'{tag_name: $tag, name: $tag, body: $body, draft: false, prerelease: false}' |
curl -fsS "${AUTH[@]}" -H "Content-Type: application/json" -X POST --data @- "$API/releases")"
echo "Created release $TAG"
else
echo "Release $TAG already exists, replacing its attachment"
fi
release_id="$(jq -r '.id' <<<"$release_json")"
existing_id="$(jq -r --arg n "$TARBALL" '.assets[]? | select(.name == $n) | .id' <<<"$release_json")"
if [[ -n "$existing_id" ]]; then
curl -fsS "${AUTH[@]}" -X DELETE "$API/releases/$release_id/assets/$existing_id" >/dev/null
fi
curl -fsS "${AUTH[@]}" -X POST -F "attachment=@$OUT/$TARBALL" \
"$API/releases/$release_id/assets?name=$TARBALL" >/dev/null
echo "Uploaded $TARBALL"
echo
echo "Download URL:"
echo " $FORGEJO_URL/$OWNER/$REPO/releases/download/$TAG/$TARBALL"