From 7338f51c7d83782687a2c4ce82b454f7d878e37a Mon Sep 17 00:00:00 2001 From: glax Date: Fri, 1 Mar 2024 11:11:57 +0100 Subject: [PATCH] Initial Commit Files --- .dockerignore | 25 ++ .gitignore | 5 + SteamGameTimeTrack.sln | 16 ++ SteamGameTimeTrack.sln.DotSettings | 4 + SteamGameTimeTrack/API.cs | 264 +++++++++++++++++++ SteamGameTimeTrack/Config.cs | 16 ++ SteamGameTimeTrack/Dockerfile | 20 ++ SteamGameTimeTrack/GameTime.cs | 28 ++ SteamGameTimeTrack/Net.cs | 63 +++++ SteamGameTimeTrack/Player.cs | 228 ++++++++++++++++ SteamGameTimeTrack/Program.cs | 65 +++++ SteamGameTimeTrack/Session.cs | 25 ++ SteamGameTimeTrack/SteamGameTimeTrack.csproj | 22 ++ SteamGameTimeTrack/Tracker.cs | 199 ++++++++++++++ 14 files changed, 980 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 SteamGameTimeTrack.sln create mode 100644 SteamGameTimeTrack.sln.DotSettings create mode 100644 SteamGameTimeTrack/API.cs create mode 100644 SteamGameTimeTrack/Config.cs create mode 100644 SteamGameTimeTrack/Dockerfile create mode 100644 SteamGameTimeTrack/GameTime.cs create mode 100644 SteamGameTimeTrack/Net.cs create mode 100644 SteamGameTimeTrack/Player.cs create mode 100644 SteamGameTimeTrack/Program.cs create mode 100644 SteamGameTimeTrack/Session.cs create mode 100644 SteamGameTimeTrack/SteamGameTimeTrack.csproj create mode 100644 SteamGameTimeTrack/Tracker.cs diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..cd967fc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,25 @@ +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/.idea +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..add57be --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +bin/ +obj/ +/packages/ +riderModule.iml +/_ReSharper.Caches/ \ No newline at end of file diff --git a/SteamGameTimeTrack.sln b/SteamGameTimeTrack.sln new file mode 100644 index 0000000..7713aba --- /dev/null +++ b/SteamGameTimeTrack.sln @@ -0,0 +1,16 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SteamGameTimeTrack", "SteamGameTimeTrack\SteamGameTimeTrack.csproj", "{40948C87-098B-48A5-BABA-02395BE797C2}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {40948C87-098B-48A5-BABA-02395BE797C2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {40948C87-098B-48A5-BABA-02395BE797C2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {40948C87-098B-48A5-BABA-02395BE797C2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {40948C87-098B-48A5-BABA-02395BE797C2}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/SteamGameTimeTrack.sln.DotSettings b/SteamGameTimeTrack.sln.DotSettings new file mode 100644 index 0000000..cc13654 --- /dev/null +++ b/SteamGameTimeTrack.sln.DotSettings @@ -0,0 +1,4 @@ + + True + True + True \ No newline at end of file diff --git a/SteamGameTimeTrack/API.cs b/SteamGameTimeTrack/API.cs new file mode 100644 index 0000000..8ac0ae4 --- /dev/null +++ b/SteamGameTimeTrack/API.cs @@ -0,0 +1,264 @@ +using System.Net; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.RegularExpressions; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace SteamGameTimeTrack; + +public class API : IDisposable +{ + private readonly HttpListener _server = new(); + private Thread listenThread; + private CancellationTokenSource cts = new(); + private ILogger logger; + private readonly Tracker parent; + + public API(Tracker parent, int port, ILogger logger) + { + this.parent = parent; + this.logger = logger; + if(RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + this._server.Prefixes.Add($"http://*:{port}/"); + else + this._server.Prefixes.Add($"http://localhost:{port}/"); + this.listenThread = new (Listen); + this.listenThread.Start(); + } + + private void Listen() + { + this._server.Start(); + foreach (string serverPrefix in this._server.Prefixes) + logger.LogInformation($"Listening on {serverPrefix}"); + while (this._server.IsListening) + { + try + { + //cts.CancelAfter(1000); + HttpListenerContext context = this._server.GetContext(); + //Log($"{context.Request.HttpMethod} {context.Request.Url} {context.Request.UserAgent}"); + Task t = new(() => + { + HandleRequest(context); + }); + t.Start(); + } + catch (OperationCanceledException) + { + + } + catch (HttpListenerException e) + { + this.logger.LogError(e, null); + } + } + } + + private void HandleRequest(HttpListenerContext context) + { + HttpListenerRequest request = context.Request; + HttpListenerResponse response = context.Response; + if (request.Url!.LocalPath.Contains("favicon")) + { + SendResponse(HttpStatusCode.NoContent, response); + return; + } + + switch (request.HttpMethod) + { + case "GET": + HandleGet(request, response); + break; + case "OPTIONS": + SendResponse(HttpStatusCode.OK, response); + break; + default: + SendResponse(HttpStatusCode.MethodNotAllowed, response); + break; + } + } + + private Dictionary GetRequestVariables(string query) + { + Dictionary ret = new(); + Regex queryRex = new (@"\?{1}&?([A-z0-9-=]+=[A-z0-9-=]+)+(&[A-z0-9-=]+=[A-z0-9-=]+)*"); + if (!queryRex.IsMatch(query)) + return ret; + query = query.Substring(1); + foreach (string keyValuePair in query.Split('&').Where(str => str.Length >= 3)) + { + string var = keyValuePair.Split('=')[0]; + string val = Regex.Replace(keyValuePair.Substring(var.Length + 1), "%20", " "); + val = Regex.Replace(val, "%[0-9]{2}", ""); + ret.Add(var, val); + } + return ret; + } + + + private void HandleGet(HttpListenerRequest request, HttpListenerResponse response) + { + Dictionary requestVariables = GetRequestVariables(request.Url!.Query); + string path = Regex.Match(request.Url!.LocalPath, @"[A-z0-9]+(\/[A-z0-9]+)*").Value; + Regex playerInfoRex = new(@"Player(?:\/([0-9]+))?"); + Regex sessionInfoRex = new(@"Player\/([0-9]+|[A-z0-9]+)\/Session(?:\/([0-9]+))?"); + Regex gameTimeInfoRex = new(@"Player\/([0-9]+|[A-z0-9]+)\/GameTime(?:\/([0-9]+))?"); + Regex playerGameInfo = new(@"Player\/([0-9]+|[A-z0-9]+)(?:\/([0-9]+))"); + + this.logger.LogInformation($"API GET {path}"); + + if (gameTimeInfoRex.Match(path) is { Success: true } gti && gti.Value.Equals(path)) + { + if (parent._players.Any(p => p.steamid.Equals(gti.Groups[1].Value))) + { + Player player = parent._players.First(p => p.steamid.Equals(gti.Groups[1].Value)); + if (gti.Groups[2].Success && int.TryParse(gti.Groups[2].Value, out int appid)) //Return specific game + { + if (player.GameTimes.Any(g => g.appid == appid)) + SendResponse(HttpStatusCode.OK, response, player.GameTimes.First(g => g.appid == appid)); + else + SendResponse(HttpStatusCode.NotFound, response, $"AppId {appid} not found"); + } + else //Return all games + { + SendResponse(HttpStatusCode.OK, response, player.GameTimes); + } + } + else + SendResponse(HttpStatusCode.NotFound, response, $"Player {gti.Groups[1].Value} not found"); + }else if (sessionInfoRex.Match(path) is { Success: true } si&& si.Value.Equals(path)) + { + if (parent._players.Any(p => p.steamid.Equals(si.Groups[1].Value))) + { + Player player = parent._players.First(p => p.steamid.Equals(si.Groups[1].Value)); + if (si.Groups[2].Success && int.TryParse(si.Groups[2].Value, out int appid)) //Return specific game + { + if (player.Sessions.Any(s => s.AppId == appid)) + SendResponse(HttpStatusCode.OK, response, player.Sessions.Where(s => s.AppId == appid)); + else + SendResponse(HttpStatusCode.NotFound, response, $"No Sessions for AppId {appid} found"); + } + else //Return all games + { + SendResponse(HttpStatusCode.OK, response, player.Sessions); + } + } + else + SendResponse(HttpStatusCode.NotFound, response, $"Player {si.Groups[1].Value} not found"); + }else if(playerGameInfo.Match(path) is { Success: true } gi&& gi.Value.Equals(path)) + { + if (parent._players.Any(p => p.steamid.Equals(gi.Groups[1].Value))) + { + Player player = parent._players.First(p => p.steamid.Equals(gi.Groups[1].Value)); + if (gi.Groups[2].Success && int.TryParse(gi.Groups[2].Value, out int appid)) //Return specific game + { + JObject x = new(); + if (player.Sessions.Any(s => s.AppId == appid)) + x["Sessions"] = new JArray(player.Sessions.Where(s => s.AppId == appid)); + else + x["Sessions"] = null; + if (player.GameTimes.Any(g => g.appid == appid)) + x["GameTime"] = JObject.FromObject(player.GameTimes.First(g => g.appid == appid)); + else + x["GameTime"] = null; + + if(x["Sessions"] != null && x["GameTime"] != null) + SendResponse(HttpStatusCode.OK, response, x); + else + SendResponse(HttpStatusCode.NotFound, response, $"AppId {appid} not found"); + } + else //Return all games + { + SendResponse(HttpStatusCode.BadRequest, response); + } + } + else + SendResponse(HttpStatusCode.NotFound, response, $"Player {gi.Groups[1].Value} not found"); + }else if (playerInfoRex.Match(path) is { Success: true } pi&& pi.Value.Equals(path)) + { + if (pi.Groups[1].Success)//Return specific Player + { + if(parent._players.Any(p => p.steamid.Equals(pi.Groups[1].Value))) + SendResponse(HttpStatusCode.OK, response, parent._players.First(p => p.steamid.Equals(pi.Groups[1].Value))); + else + SendResponse(HttpStatusCode.NotFound, response, $"Player {pi.Groups[1].Value} not found"); + } + else//Return all Players + SendResponse(HttpStatusCode.OK, response, parent._players); + }else + SendResponse(HttpStatusCode.NotFound, response, "Path not found.\n" + + "Valid Paths:\n" + + "\t/Player/\n" + + "\t/Player//\n" + + "\t/Player//GameTime/\n" + + "\t/Player//Session/\n"); + } + + private void SendResponse(HttpStatusCode statusCode, HttpListenerResponse response, object? content = null) + { + logger.LogInformation($"Response: {statusCode.ToString()}"); + response.StatusCode = (int)statusCode; + response.AddHeader("Access-Control-Allow-Headers", "Content-Type, Accept, X-Requested-With"); + response.AddHeader("Access-Control-Allow-Methods", "GET"); + response.AddHeader("Access-Control-Max-Age", "5"); + response.AppendHeader("Access-Control-Allow-Origin", "*"); + + byte[] bytes; + if (content is not null) + { + if (content is string str) + bytes = Encoding.UTF8.GetBytes(str); + else if (content is JObject jo) + bytes = Encoding.UTF8.GetBytes(jo.ToString()); + else + bytes = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(content)); + } + else + bytes = Array.Empty(); + + if (content is not Stream) + { + response.ContentType = content?.GetType() == typeof(string) ? "text/plain" : "application/json"; + try + { + response.OutputStream.Write(bytes); + response.OutputStream.Close(); + } + catch (HttpListenerException e) + { + logger.LogError(e, null); + } + } + else if(content is FileStream stream) + { + string contentType = stream.Name.Split('.')[^1]; + switch (contentType.ToLower()) + { + case "gif": + response.ContentType = "image/gif"; + break; + case "png": + response.ContentType = "image/png"; + break; + case "jpg": + case "jpeg": + response.ContentType = "image/jpeg"; + break; + case "log": + response.ContentType = "text/plain"; + break; + } + stream.CopyTo(response.OutputStream); + response.OutputStream.Close(); + stream.Close(); + } + } + + public void Dispose() + { + ((IDisposable)_server).Dispose(); + } +} \ No newline at end of file diff --git a/SteamGameTimeTrack/Config.cs b/SteamGameTimeTrack/Config.cs new file mode 100644 index 0000000..8c5a3a1 --- /dev/null +++ b/SteamGameTimeTrack/Config.cs @@ -0,0 +1,16 @@ +using Microsoft.Extensions.Logging; + +namespace SteamGameTimeTrack; + +public struct Config +{ + public string SteamId, ApiToken; + public bool TrackFriends; + public LogLevel LogLevel; + public int ApiPort; + + public override string ToString() + { + return $"SteamId: {SteamId}\nTrack Friends: {(TrackFriends ? "yes" : "no")}\nApi-Port: {ApiPort}\nLogLevel: {Enum.GetName(LogLevel)}\nApiToken: ****{ApiToken.Substring(4, ApiToken.Length-4)}****"; + } +} \ No newline at end of file diff --git a/SteamGameTimeTrack/Dockerfile b/SteamGameTimeTrack/Dockerfile new file mode 100644 index 0000000..5519b5f --- /dev/null +++ b/SteamGameTimeTrack/Dockerfile @@ -0,0 +1,20 @@ +FROM mcr.microsoft.com/dotnet/runtime:7.0 AS base +WORKDIR /app + +FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src +COPY ["SteamGameTimeTrack/SteamGameTimeTrack.csproj", "SteamGameTimeTrack/"] +RUN dotnet restore "SteamGameTimeTrack/SteamGameTimeTrack.csproj" +COPY . . +WORKDIR "/src/SteamGameTimeTrack" +RUN dotnet build "SteamGameTimeTrack.csproj" -c $BUILD_CONFIGURATION -o /app/build + +FROM build AS publish +ARG BUILD_CONFIGURATION=Release +RUN dotnet publish "SteamGameTimeTrack.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "SteamGameTimeTrack.dll"] diff --git a/SteamGameTimeTrack/GameTime.cs b/SteamGameTimeTrack/GameTime.cs new file mode 100644 index 0000000..8f86705 --- /dev/null +++ b/SteamGameTimeTrack/GameTime.cs @@ -0,0 +1,28 @@ +// ReSharper disable InconsistentNaming +// ReSharper disable MemberCanBePrivate.Global + +using Newtonsoft.Json; + +namespace SteamGameTimeTrack; + +public struct GameTime +{ + public int appid, playtime_forever; + public DateTime? GameStarted; + public string name; + [JsonIgnore] + public TimeSpan PlayTime => TimeSpan.FromMinutes(playtime_forever); + [JsonIgnore] + public TimeSpan? Session => GameStarted is not null ? DateTime.UtcNow.Subtract(GameStarted.Value) : null; + + public GameTime Clone() + { + return new GameTime() + { + appid = this.appid, + GameStarted = this.GameStarted, + name = this.name, + playtime_forever = this.playtime_forever + }; + } +} \ No newline at end of file diff --git a/SteamGameTimeTrack/Net.cs b/SteamGameTimeTrack/Net.cs new file mode 100644 index 0000000..7a885df --- /dev/null +++ b/SteamGameTimeTrack/Net.cs @@ -0,0 +1,63 @@ +using Microsoft.Extensions.Logging; +using Newtonsoft.Json.Linq; + +namespace SteamGameTimeTrack; + +public class Net +{ + private readonly ILogger _logger; + private readonly string _apiToken; + private DateTime lastRequest = DateTime.UtcNow; + private readonly TimeSpan _minTimeBetweenRequests = TimeSpan.FromMilliseconds(10); + + public Net(ILogger logger, string apiToken) + { + _logger = logger; + _apiToken = apiToken; + } + + internal HttpResponseMessage MakeRequest(string uri, string? requestId = null) + { + if (requestId is null) + { + requestId = RandomString(4); + this._logger.LogDebug($"RequestId: {requestId} for URI {uri}"); + } + HttpClient client = new(); + uri = uri.Contains("format=json") ? uri : $"{uri}&format=json"; + uri = uri.Contains("key=") ? uri : $"{uri}&key={_apiToken}"; + HttpRequestMessage request = new (HttpMethod.Get, uri); + TimeSpan wait = this.lastRequest.Add(_minTimeBetweenRequests).Subtract(DateTime.UtcNow); + if (wait > TimeSpan.Zero) + { + this._logger.LogDebug($"Waiting {wait:s\\.fffff's'} before requesting {requestId}..."); + Thread.Sleep(wait); + }else + this._logger.LogDebug($"Requesting {requestId}..."); + HttpResponseMessage response = client.Send(request, HttpCompletionOption.ResponseContentRead); + this.lastRequest = DateTime.UtcNow; + this._logger.LogDebug($"Request {requestId} -> {response.StatusCode}"); + return response; + } + + internal JObject? MakeRequestGetJObject(string uri, string? requestId = null) + { + if (requestId is null) + { + requestId = RandomString(4); + this._logger.LogDebug($"RequestId: {requestId} for URI {uri}"); + } + HttpResponseMessage response = MakeRequest(uri, requestId); + if (!response.IsSuccessStatusCode) + return null; + this._logger.LogDebug($"Parsing JObject for {requestId}..."); + return JObject.Parse(response.Content.ReadAsStringAsync().Result); + } + + private static string RandomString(int length) + { + const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + return new string(Enumerable.Repeat(chars, length) + .Select(s => s[Random.Shared.Next(s.Length)]).ToArray()); + } +} \ No newline at end of file diff --git a/SteamGameTimeTrack/Player.cs b/SteamGameTimeTrack/Player.cs new file mode 100644 index 0000000..22a558c --- /dev/null +++ b/SteamGameTimeTrack/Player.cs @@ -0,0 +1,228 @@ +// ReSharper disable IdentifierTypo +// ReSharper disable InconsistentNaming +// ReSharper disable MemberCanBePrivate.Global +// ReSharper disable UnassignedField.Global +// ReSharper disable PropertyCanBeMadeInitOnly.Global + +using System.Reflection; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace SteamGameTimeTrack; + +public struct Player +{ + private readonly Net net; + + public string steamid { get; init; } + public bool IsMe { get; init; } + + public PersonaStateEnum personastate { get; set; } = PersonaStateEnum.Offline; + public byte communityvisibilitystate { get; set; } = 0; + public bool profilestate { get; set; } = false; + public long lastlogoff { get; set; } = 0; + public bool? commentpermission { get; set; } = null; + + public string? realname { get; set; } = null; + public string? primaryclanid { get; set; } = null; + public string? gameserverip { get; set; } = null; + public string? gameextrainfo { get; set; } = null; + public string? loccountrycode { get; set; } = null; + public string? locstatecode { get; set; } = null; + public string? loccityid { get; set; } = null; + public string? personaname { get; set; } = null; + public string? profileurl { get; set; } = null; + public string? avatar { get; set; } = null; + public string? avatarmedium { get; set; } = null; + public string? avatarfull { get; set; } = null; + public string? gameid { get; set; } = null; + public long timecreated { get; set; } = 0; + public long friend_since { get; set; } = 0; + public HashSet GameTimes = new(); + // ReSharper disable once CollectionNeverQueried.Global + public List Sessions = new(); + + [JsonIgnore] + public DateTime? TimeCreated => DateTime.UnixEpoch.AddSeconds(timecreated); + [JsonIgnore] + public DateTime? FriendSince => DateTime.UnixEpoch.AddSeconds(friend_since); + [JsonIgnore] + public DateTime? LastLogoff => DateTime.UnixEpoch.AddSeconds(lastlogoff); + + [JsonConstructor] + public Player(string steamid, Net net, bool IsMe = false) + { + this.IsMe = IsMe; + this.net = net; + this.steamid = steamid; + } + + public override int GetHashCode() + { + return steamid.GetHashCode(); + } + + public void Export() + { + string path = Path.Join("states", steamid); + if (!Directory.Exists(path)) + Directory.CreateDirectory(path); + File.WriteAllText(Path.Join(path, $"{DateTime.UtcNow:dd-mm-yy hh-mm-ss}.json"), JsonConvert.SerializeObject(this, Formatting.Indented)); + } + + public bool Diffs(Player other, out string diffStr) + { + List diffs = new(); + foreach (FieldInfo field in GetType().GetFields()) + { + object? thisValue = field.GetValue(this); + object? otherValue = field.GetValue(other); + if(thisValue is not null && otherValue is not null && !thisValue.Equals(otherValue)) + diffs.Add($"{field.Name} {thisValue} -> {otherValue}"); + } + + foreach (PropertyInfo property in GetType().GetProperties()) + { + object? thisValue = property.GetValue(this); + object? otherValue = property.GetValue(other); + if(thisValue is not null && otherValue is not null && !thisValue.Equals(otherValue)) + diffs.Add($"{property.Name} {thisValue} -> {otherValue}"); + } + + GameTime[] newGameTimes = other.GameTimes.Except(GameTimes).ToArray(); + foreach (GameTime newGameTime in newGameTimes) + diffs.Add($"New game {newGameTime.name} {newGameTime.appid} {newGameTime.PlayTime}"); + foreach (GameTime otherGameTime in other.GameTimes) + // ReSharper disable once PatternAlwaysMatches + if(this.GameTimes.First(tgt => tgt.appid == otherGameTime.appid) is GameTime thisGameTime && !thisGameTime.GameStarted.Equals(otherGameTime.GameStarted)) + diffs.Add($"Started {otherGameTime.name} {otherGameTime.appid} at {otherGameTime.GameStarted}"); + + diffStr = !diffs.Any() ? "No diffs" : $"\n\t{string.Join("\n\t", diffs)}"; + return diffs.Any(); + } + + public void UpdateGameInfo(GameTime gameTime) + { + if (this.GameTimes.Any(gt => gt.appid == gameTime.appid)) + { + GameTime thisGameTime = this.GameTimes.First(gt => gt.appid == gameTime.appid); + this.GameTimes.Remove(thisGameTime); + } + + this.GameTimes.Add(gameTime); + } + + internal void GetGames() + { + JObject? gamesResult = net.MakeRequestGetJObject($"https://api.steampowered.com/IPlayerService/GetOwnedGames/v0001/?&steamid={steamid}&include_appinfo=true&include_played_free_games=true"); + if (gamesResult is not null && gamesResult.TryGetValue("response", out JToken? value) && value.SelectToken("games") is not null) + { + foreach (JToken game in value["games"]!) + { + GameTime gameTime = game.ToObject(); + this.UpdateGameInfo(gameTime); + } + } + } + + public Player WithInfo(Player other) + { + if (other.gameid is not null && this.gameid is null) //Game started + { + if(!this.GameTimes.Any(game => game.appid.ToString().Equals(other.gameid))) + GetGames(); + GameTime gameTime = this.GameTimes.First(game => game.appid.ToString().Equals(other.gameid)); + this.GameTimes.Remove(gameTime); + gameTime.GameStarted = DateTime.UtcNow; + this.GameTimes.Add(gameTime); + }else if (other.gameid is null && this.gameid is not null) //Game stopped + { + if(!this.GameTimes.Any(game => game.appid.ToString().Equals(other.gameid))) + GetGames(); + GameTime gameTime = this.GameTimes.First(game => game.appid.ToString().Equals(other.gameid)); + this.Sessions.Add(Session.FromGameTime(gameTime)); + this.GameTimes.Remove(gameTime); + gameTime.GameStarted = null; + this.GameTimes.Add(gameTime); + } + + return this with + { + personastate = other.personastate, + communityvisibilitystate = other.communityvisibilitystate, + profilestate = other.profilestate, + lastlogoff = other.lastlogoff, + commentpermission = other.commentpermission, + realname = other.realname, + primaryclanid = other.primaryclanid, + gameserverip = other.gameserverip, + gameextrainfo = other.gameextrainfo, + loccountrycode = other.loccountrycode, + locstatecode = other.locstatecode, + loccityid = other.loccityid, + personaname = other.personaname, + profileurl = other.profileurl, + avatar = other.avatar, + avatarmedium = other.avatarmedium, + avatarfull = other.avatarfull, + gameid = other.gameid + }; + } + + internal void ShuttingDownSaveSessions() + { + if (this.gameid is null) + return; + string gid = this.gameid; + GameTime? gameTime = this.GameTimes.FirstOrDefault(game => game.appid.ToString().Equals(gid)); + if (gameTime is null || gameTime.Value.GameStarted is null) + return; + this.Sessions.Add(Session.FromGameTime(gameTime.Value)); + this.gameid = null; + this.Export(); + } + + internal class PlayerJsonConverter : JsonConverter + { + private Net net; + + public PlayerJsonConverter(Net net) + { + this.net = net; + } + + public override bool CanConvert(Type objectType) + { + return objectType == typeof(Player); + } + + public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) + { + JObject jo = JObject.Load(reader); + Player tmp = new (jo.Value("steamid")!, net, jo.Value("IsMe")) + { + GameTimes = jo["GameTimes"]!.ToObject>()!, + Sessions = jo["Sessions"]!.ToObject>()! + }; + return tmp.WithInfo(jo.ToObject()); + } + + public override bool CanWrite => false; + + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) + { + throw new NotImplementedException(); + } + } +} + +public enum PersonaStateEnum : byte +{ + Offline = 0, + Online = 1, + Busy = 2, + Away = 3, + Snooze = 4, + LookingToTrade = 5, + LookingToPlay = 6 +} \ No newline at end of file diff --git a/SteamGameTimeTrack/Program.cs b/SteamGameTimeTrack/Program.cs new file mode 100644 index 0000000..d48a900 --- /dev/null +++ b/SteamGameTimeTrack/Program.cs @@ -0,0 +1,65 @@ +// See https://aka.ms/new-console-template for more information + +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using SteamGameTimeTrack; + +Config config; + +if (File.Exists("config.json")) + config = JsonConvert.DeserializeObject(File.ReadAllText("config.json")); +else +{ + string? steamid; + do + { + Console.Clear(); + Console.WriteLine("SteamId:"); + steamid = Console.ReadLine(); + } while (steamid is null || steamid.Length < 1); + + string? apiKey; + do + { + Console.Clear(); + Console.WriteLine("API-Key:"); + apiKey = Console.ReadLine(); + } while (apiKey is null || apiKey.Length < 1); + + LogLevel logLevel; + string[] levels = Enum.GetNames(); + do + { + Console.Clear(); + for (int i = 0; i < levels.Length; i++) + Console.WriteLine($"{i}) {levels[i]}"); + } while (!int.TryParse(Console.ReadKey().KeyChar.ToString(), out int selectedLevel) || selectedLevel < 0 || + selectedLevel >= levels.Length || !Enum.TryParse(levels[selectedLevel], out logLevel)); + + Console.Clear(); + Console.WriteLine("Track friends? (y/n)"); + bool trackFriends = Console.ReadKey(true).Key == ConsoleKey.Y; + + int port; + do + { + Console.Clear(); + Console.WriteLine("API-Port:"); + } while (!int.TryParse(Console.ReadLine(), out port) || port < 1 || port > 65535); + + config = new() + { + SteamId = steamid, + ApiToken = apiKey, + LogLevel = logLevel, + TrackFriends = trackFriends, + ApiPort = port + }; +} + + + +Tracker t = new (config); +while(Console.ReadKey(true).Key != ConsoleKey.Q) + Console.WriteLine("Press q to quit."); +t.Dispose(); \ No newline at end of file diff --git a/SteamGameTimeTrack/Session.cs b/SteamGameTimeTrack/Session.cs new file mode 100644 index 0000000..d9094b2 --- /dev/null +++ b/SteamGameTimeTrack/Session.cs @@ -0,0 +1,25 @@ +using Newtonsoft.Json; + +namespace SteamGameTimeTrack; + +public struct Session +{ + public int AppId; + public string Name; + public DateTime SessionStarted; + public TimeSpan SessionDuration; + [JsonIgnore] public DateTime SessionEnded => SessionStarted.Add(SessionDuration); + + public static Session FromGameTime(GameTime gameTime) + { + if (gameTime.GameStarted is null) + throw new ArgumentNullException(nameof(GameTime.GameStarted)); + return new Session() + { + AppId = gameTime.appid, + Name = gameTime.name, + SessionStarted = gameTime.GameStarted.Value, + SessionDuration = gameTime.Session!.Value + }; + } +} \ No newline at end of file diff --git a/SteamGameTimeTrack/SteamGameTimeTrack.csproj b/SteamGameTimeTrack/SteamGameTimeTrack.csproj new file mode 100644 index 0000000..44ad999 --- /dev/null +++ b/SteamGameTimeTrack/SteamGameTimeTrack.csproj @@ -0,0 +1,22 @@ + + + + Exe + net7.0 + enable + enable + Linux + + + + + .dockerignore + + + + + + + + + diff --git a/SteamGameTimeTrack/Tracker.cs b/SteamGameTimeTrack/Tracker.cs new file mode 100644 index 0000000..62f73cb --- /dev/null +++ b/SteamGameTimeTrack/Tracker.cs @@ -0,0 +1,199 @@ +using GlaxLogger; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace SteamGameTimeTrack; + +public class Tracker : IDisposable +{ + private bool _running = true; + // ReSharper disable once MemberInitializerValueIgnored + private readonly ILogger _logger = null!; + internal readonly HashSet _players = new(); + private readonly Net net; + private readonly Config config; + private readonly API api; + + private readonly TimeSpan _updateFriendsInterval = TimeSpan.FromMinutes(30); + private readonly TimeSpan _updateGamesInterval = TimeSpan.FromMinutes(60); + private readonly TimeSpan _updateInfoInterval = TimeSpan.FromSeconds(60); + private readonly Thread _updateFriendsThread, _updateGamesThread, _updateInfoThread; + + public Tracker(Config config) + { + this.config = config; + this._logger = new Logger(config.LogLevel, consoleOut: Console.Out); + this.net = new(this._logger, config.ApiToken); + this._logger.LogInformation(config.ToString()); + + LoadKnownInformation(); + if(!_players.Any(player => player.IsMe)) + _players.Add(new Player(config.SteamId, net, true)); + + if (config.TrackFriends) + UpdateFriends(); + + UpdatePlayerGames(); + UpdatePlayerInfo(); + + this._updateFriendsThread = new (() => + { + lock(_updateFriendsThread!) + { + while (_running) + { + _logger.LogInformation($"Waiting {_updateFriendsInterval:g} for next Friends-List-Update. {DateTime.Now.Add(_updateFriendsInterval):HH:mm:ss zz}"); + Monitor.Wait(_updateFriendsThread, _updateFriendsInterval); + if (!_running) + break; + UpdateFriends(); + } + } + }); + this._updateFriendsThread.Start(); + + this._updateGamesThread = new(() => + { + lock(_updateGamesThread!) + { + while (_running) + { + _logger.LogInformation($"Waiting {_updateGamesInterval:g} for next Player-Games-Update. {DateTime.Now.Add(_updateGamesInterval):HH:mm:ss zz}"); + Monitor.Wait(_updateGamesThread, _updateGamesInterval); + if (!_running) + break; + UpdatePlayerGames(); + } + } + }); + this._updateGamesThread.Start(); + + this._updateInfoThread = new(() => + { + lock(_updateInfoThread!) + { + while (_running) + { + _logger.LogInformation($"Waiting {_updateInfoInterval:g} for next Player-Info-Update. {DateTime.Now.Add(_updateInfoInterval):HH:mm:ss zz}"); + Monitor.Wait(_updateInfoThread, _updateInfoInterval); + if (!_running) + break; + UpdatePlayerInfo(); + } + } + }); + this._updateInfoThread.Start(); + + this.api = new(this, config.ApiPort, this._logger); + } + + public Tracker(string steamId, string apiToken, bool trackFriends = true, int apiPort = 8888, LogLevel logLevel = LogLevel.Information) : this(new Config() + { + ApiToken = apiToken, + LogLevel = logLevel, + SteamId = steamId, + TrackFriends = trackFriends, + ApiPort = apiPort + }) + { + File.WriteAllText("config.json", JsonConvert.SerializeObject(config, Formatting.Indented)); + } + + private void UpdateFriends() + { + this._logger.LogInformation("Getting Friends-List..."); + JObject? friendsResult = net.MakeRequestGetJObject($"https://api.steampowered.com/ISteamUser/GetFriendList/v0001/?steamid={config.SteamId}&relationship=friend"); + if (friendsResult is not null && friendsResult.TryGetValue("friendslist", out JToken? value) && value.SelectToken("friends") is not null) + { + this._logger.LogDebug("Got Friends-List."); + foreach (JToken friend in value["friends"]!) + { + string steamid = friend["steamid"]!.Value()!; + if(!_players.Any(player => player.steamid.Equals(steamid))) + this._players.Add(new(steamid, net) + { + friend_since = friend["friend_since"]!.Value() + }); + } + this._logger.LogInformation($"Got {this._players.Count - 1} friends."); + } + } + + private void UpdatePlayerGames() + { + int i = 1; + foreach (Player player in this._players) + { + _logger.LogInformation($"Getting GameInfo {i++}/{_players.Count} {player.personaname} {player.steamid}"); + player.GetGames(); + } + } + + private void UpdatePlayerInfo() + { + this._logger.LogInformation("Getting Player-info..."); + string steamIds = string.Join(',', _players.Select(player => player.steamid)); + JObject? result = net.MakeRequestGetJObject($"https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/?steamids={steamIds}"); + if (result is not null && result.TryGetValue("response", out JToken? response)) + { + foreach (JToken player in response["players"]!) + { + Player p = player.ToObject(); + UpdatePlayer(p); + } + } + this._logger.LogInformation("Done getting Player-info..."); + } + + private void LoadKnownInformation() + { + if (!Directory.Exists("states")) + return; + foreach (DirectoryInfo subDir in new DirectoryInfo("states").GetDirectories()) + { + FileInfo? latest = subDir.GetFiles().MaxBy(file => file.CreationTimeUtc); + if (latest is not null) + { + Player player = JsonConvert.DeserializeObject(File.ReadAllText(latest.FullName), new Player.PlayerJsonConverter(net)); + this._players.Add(player); + } + } + } + + private void UpdatePlayer(Player player) + { + this._logger.LogDebug($"Updating Player {player.personaname} {player.steamid}"); + Player p = this._players.First(pp => pp.steamid.Equals(player.steamid)); + this._players.Remove(p); + Player updated = p.WithInfo(player); + + this._players.Add(updated); + if (p.Diffs(updated, out string diffStr)) + { + updated.Export(); + this._logger.LogDebug($"Diffs: {diffStr}"); + } + } + + public void Dispose() + { + this._logger.LogInformation("Stopping..."); + _running = false; + lock (_updateInfoThread) + { + Monitor.Pulse(_updateInfoThread); + } + lock (_updateGamesThread) + { + Monitor.Pulse(_updateGamesThread); + } + lock (_updateFriendsThread) + { + Monitor.Pulse(_updateFriendsThread); + } + this._logger.LogInformation("Exporting Player-States..."); + foreach (Player player in this._players) + player.ShuttingDownSaveSessions(); + } +} \ No newline at end of file