From 6cf79882308bb41deb5df8b73d86aa308bbc4d17 Mon Sep 17 00:00:00 2001 From: Andreas Aronsson Date: Sun, 2 Nov 2025 17:48:09 +0100 Subject: [PATCH] Add options to write appinfo and manifest as JSON This to enable integration with automation suites that reads JSON files. --- DepotDownloader/ContentDownloader.cs | 163 +++++++++++++++++++++++++ DepotDownloader/DepotDownloader.csproj | 1 + DepotDownloader/DownloadConfig.cs | 2 + DepotDownloader/Program.cs | 4 + DepotDownloader/Steam3Session.cs | 5 + README.md | 2 + 6 files changed, 177 insertions(+) diff --git a/DepotDownloader/ContentDownloader.cs b/DepotDownloader/ContentDownloader.cs index a59a69fb..181f077a 100644 --- a/DepotDownloader/ContentDownloader.cs +++ b/DepotDownloader/ContentDownloader.cs @@ -9,6 +9,9 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Net; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Nodes; using System.Threading; using System.Threading.Tasks; using SteamKit2; @@ -912,6 +915,11 @@ namespace DepotDownloader return null; } + if (Config.WriteManifestJson) + { + WriteManifestToJsonFile(depot, newManifest); + } + var stagingDir = Path.Combine(depot.InstallDir, STAGING_DIR); var filesAfterExclusions = newManifest.Files.AsParallel().Where(f => TestIsFileIncluded(f.FileName)).ToList(); @@ -1429,5 +1437,160 @@ namespace DepotDownloader sw.WriteLine($"{file.TotalSize,14:d} {file.Chunks.Count,6:d} {sha1Hash} {(int)file.Flags,5:x} {file.FileName}"); } } + + private static void WriteManifestToJsonFile(DepotDownloadInfo depot, DepotManifest manifest) + { + var fileName = $"manifest_{depot.DepotId}_{depot.ManifestId}.json"; + var filePath = Path.Combine(depot.InstallDir, CONFIG_DIR, fileName); + using var sw = new StreamWriter(filePath); + + var manifestFiles = manifest.Files ?? []; + var uniqueChunks = new HashSet(new ChunkIdComparer()); + foreach (var file in manifestFiles) + { + foreach (var chunk in file.Chunks) + { + uniqueChunks.Add(chunk.ChunkID); + } + } + + var filesArray = new JsonArray(); + foreach (var file in manifestFiles) + { + var sha1Hash = Convert.ToHexString(file.FileHash).ToLower(); + var fileObject = new JsonObject + { + { "size", file.TotalSize }, + { "chunks", file.Chunks.Count }, + { "sha", sha1Hash }, + { "flags", Convert.ToString((int)file.Flags, 16) }, + { "name", file.FileName } + }; + filesArray.Add(fileObject); + } + + var jsonObject = new JsonObject + { + { "description", $"Content Manifest for Depot {depot.DepotId}" }, + { "depotId", depot.DepotId }, + { "manifestId", depot.ManifestId }, + { "manifestCreationTime", manifest.CreationTime }, + { "totalFiles", manifestFiles.Count }, + { "totalChunks", uniqueChunks.Count }, + { "totalBytesOnDisk", manifest.TotalUncompressedSize }, + { "totalBytesCompressed", manifest.TotalCompressedSize }, + { "files", filesArray } + }; + + var options = new JsonSerializerOptions + { + WriteIndented = true // Pretty print + }; + sw.WriteLine(jsonObject.ToJsonString(options)); + Console.WriteLine($"Wrote {fileName}"); + } + + public static async Task DumpAppInfoJsonFile(uint appId) + { + if (steam3 != null && appId != INVALID_APP_ID) + { + await steam3.RequestAppInfo(appId); + } + + if (steam3?.AppInfo == null) + { + return; + } + + if (!steam3.AppInfo.TryGetValue(appId, out var appInfo) || appInfo == null) + { + return; + } + + WriteAppInfoToJsonFile(appInfo, Config.InstallDirectory); + } + + private static void WriteAppInfoToJsonFile(SteamApps.PICSProductInfoCallback.PICSProductInfo appInfo, string installDir) + { + var fileName = $"appinfo_{appInfo.ID}.json"; + var filePath = Path.Combine(installDir, CONFIG_DIR, fileName); + using var sw = new StreamWriter(filePath); + + // Will traverse the KeyValue chain and fill the parent JSON object. + void Traverse(KeyValue kv, JsonObject parentObj) + { + if (kv.Name != null) + { + if (kv.Value == null) + { + var childObj = new JsonObject(); + foreach (var child in kv.Children) + { + Traverse(child, childObj); + } + + parentObj[kv.Name] = childObj; + } + else + { + parentObj[kv.Name] = kv.Value; + } + } + else + { + Console.WriteLine("KeyValue.Name was null"); + } + } + + var jsonObject = new JsonObject(); + + // Dynamically walk appInfo properties in case it is extended in the future. + foreach (var prop in appInfo.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + var name = prop.Name; + var value = prop.GetValue(appInfo); + switch (value) + { + case int or short or long or uint or ushort or ulong: // ID, ChangeNumber + jsonObject[name] = Convert.ToInt64(value); + break; + case float or double: // Future proofing + jsonObject[name] = Convert.ToDouble(value); + break; + case bool boolean: // MissingToken, OnlyPublic, UseHttp + jsonObject[name] = boolean; + break; + case string str: // Future proofing + jsonObject[name] = str; + break; + case null: // SHAHash, HttpUri + jsonObject[name] = null; + break; + case byte[] bytes: // SHAHash + jsonObject[name] = Convert.ToHexString(bytes); + break; + case KeyValue keyValue: // KeyValues + { + var values = new JsonObject(); + Traverse(keyValue, values); + jsonObject[name] = values; + break; + } + case Uri uri: // HttpUri + jsonObject[name] = uri.ToString(); + break; + default: + Console.WriteLine($"Warning: Unhandled property in AppInfo JSON output: {name} ({value})"); + break; + } + } + + var options = new JsonSerializerOptions + { + WriteIndented = true // Pretty print + }; + sw.WriteLine(jsonObject.ToJsonString(options)); + Console.WriteLine($"Wrote {fileName}"); + } } } diff --git a/DepotDownloader/DepotDownloader.csproj b/DepotDownloader/DepotDownloader.csproj index 2484263e..4c9970d0 100644 --- a/DepotDownloader/DepotDownloader.csproj +++ b/DepotDownloader/DepotDownloader.csproj @@ -28,5 +28,6 @@ + diff --git a/DepotDownloader/DownloadConfig.cs b/DepotDownloader/DownloadConfig.cs index 144853bf..bcbe3aa1 100644 --- a/DepotDownloader/DownloadConfig.cs +++ b/DepotDownloader/DownloadConfig.cs @@ -32,5 +32,7 @@ namespace DepotDownloader public bool UseQrCode { get; set; } public bool SkipAppConfirmation { get; set; } + public bool WriteAppInfoJson { get; set; } + public bool WriteManifestJson { get; set; } } } diff --git a/DepotDownloader/Program.cs b/DepotDownloader/Program.cs index 26b7f16f..f24fcb23 100644 --- a/DepotDownloader/Program.cs +++ b/DepotDownloader/Program.cs @@ -156,6 +156,8 @@ namespace DepotDownloader ContentDownloader.Config.MaxDownloads = GetParameter(args, "-max-downloads", 8); ContentDownloader.Config.LoginID = HasParameter(args, "-loginid") ? GetParameter(args, "-loginid") : null; + ContentDownloader.Config.WriteAppInfoJson = HasParameter(args, "-appinfo-json"); + ContentDownloader.Config.WriteManifestJson = HasParameter(args, "-manifest-json"); #endregion @@ -529,6 +531,8 @@ namespace DepotDownloader Console.WriteLine(" -max-downloads <#> - maximum number of chunks to download concurrently. (default: 8)."); Console.WriteLine(" -loginid <#> - a unique 32-bit integer Steam LogonID in decimal, required if running multiple instances of DepotDownloader concurrently."); Console.WriteLine(" -use-lancache - forces downloads over the local network via a Lancache instance."); + Console.WriteLine(" -appinfo-json - writes appinfo as JSON to the config directory."); + Console.WriteLine(" -manifest-json - writes human readable manifest as JSON to the config directory."); Console.WriteLine(); Console.WriteLine(" -debug - enable verbose debug logging."); Console.WriteLine(" -V or --version - print version and runtime."); diff --git a/DepotDownloader/Steam3Session.cs b/DepotDownloader/Steam3Session.cs index bff4f3ca..30f0e826 100644 --- a/DepotDownloader/Steam3Session.cs +++ b/DepotDownloader/Steam3Session.cs @@ -174,6 +174,11 @@ namespace DepotDownloader Console.WriteLine("Got AppInfo for {0}", app.ID); AppInfo[app.ID] = app; + + if (ContentDownloader.Config.WriteAppInfoJson) + { + await ContentDownloader.DumpAppInfoJsonFile(appId); + } } foreach (var app in appInfo.UnknownApps) diff --git a/README.md b/README.md index 810c9466..b824faf4 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,8 @@ Parameter | Description `-cellid <#>` | the overridden CellID of the content server to download from. `-max-downloads <#>` | maximum number of chunks to download concurrently. (default: 8). `-use-lancache` | forces downloads over the local network via a Lancache instance. +`-appinfo-json` | writes appinfo as JSON to the config directory. +`-manifest-json` | writes human readable manifest as JSON to the config directory. #### Other