# 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 ''; 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": "", "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 DB-IP`. `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 ` (with `FORGEJO_TOKEN`) rebuilds from the committed source and publishes `PlaytimeHistory-.tar.gz`, laid out like `game/csgo/`. The tag must be `v`. 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/`.