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