From 5d0ec8aa77aab7316f384f4e46442d0ce4a0874e Mon Sep 17 00:00:00 2001 From: Rostislav Kirillov Date: Tue, 24 Mar 2026 04:04:19 +0400 Subject: [PATCH] feat: steam import rework --- source/Libraries/SteamLibrary/ModInfo.cs | 43 +++- .../SteamLibrary/Models/ApiResponseModels.cs | 178 ++++++++++++++- .../SteamLibrary/Models/BackendModels.cs | 54 +++++ .../Services/Base/SteamApiServiceBase.cs | 2 +- .../Services/ClientCommService.cs | 30 +-- .../Services/FamilyGroupsService.cs | 49 +++-- .../SteamLibrary/Services/PlayerService.cs | 45 ++-- .../SteamLibrary/Services/SourceNames.cs | 27 ++- .../Services/SteamLocalService.cs | 75 ++----- .../Services/SteamServiceAggregator.cs | 205 +++++++++++++----- .../Services/SteamServicesClient.cs | 24 +- .../Services/SteamStoreService.cs | 7 +- .../SteamLibrary/SteamGameController.cs | 29 ++- .../SteamLibrary/SteamLibrary.csproj | 8 +- .../SteamLibrarySettingsView.xaml | 83 ++++--- .../SteamLibrarySettingsView.xaml.cs | 14 +- .../SteamLibrarySettingsViewModel.cs | 77 +++++-- .../SteamLibrary/SteamMetadataProvider.cs | 14 +- source/Libraries/SteamLibrary/packages.config | 2 +- 19 files changed, 676 insertions(+), 290 deletions(-) create mode 100644 source/Libraries/SteamLibrary/Models/BackendModels.cs diff --git a/source/Libraries/SteamLibrary/ModInfo.cs b/source/Libraries/SteamLibrary/ModInfo.cs index d4180b05..a7c78a78 100644 --- a/source/Libraries/SteamLibrary/ModInfo.cs +++ b/source/Libraries/SteamLibrary/ModInfo.cs @@ -3,12 +3,15 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Text; using System.Text.RegularExpressions; +using SteamLibrary.Models; +using SteamLibrary.Services; namespace SteamLibrary { - internal class ModInfo + internal class ModInfo : ISteamApp { public enum ModType { @@ -17,7 +20,7 @@ public enum ModType } public string Name { get; private set; } - public GameID GameId { get; private set; } + public GameID Id { get; private set; } public string InstallFolder { get; private set; } public List Categories { get; private set; } public string Developer { get; private set; } @@ -49,7 +52,7 @@ private ModInfo(ModType type, string installFolder) InstallFolder = installFolder; Name = "Unknown Mod"; Links = new List(); - GameId = new GameID(); + Id = new GameID(); Developer = "Unknown"; Categories = new List(); } @@ -106,7 +109,7 @@ static public ModInfo GetFromFolder(string path, ModType modType) ModInfo modInfo = new ModInfo(modType, path); if (modType == ModType.HL) { - modInfo.GameId.AppID = halfLife; + modInfo.Id.AppID = halfLife; PopulateModInfoFromLibList(ref modInfo, gameInfoPath); } else @@ -114,8 +117,8 @@ static public ModInfo GetFromFolder(string path, ModType modType) PopulateModInfoFromGameInfo(ref modInfo, gameInfoPath); } - modInfo.GameId.AppType = GameID.GameType.GameMod; - modInfo.GameId.ModID = GetModFolderCRC(dirInfo.Name); + modInfo.Id.AppType = GameID.GameType.GameMod; + modInfo.Id.ModID = GetModFolderCRC(dirInfo.Name); return modInfo; } @@ -141,7 +144,7 @@ static private void PopulateModInfoFromGameInfo(ref ModInfo modInfo, string path var gameInfo = new KeyValue(); gameInfo.ReadFileAsText(path); - modInfo.GameId.AppID = gameInfo["FileSystem"]["SteamAppId"].AsUnsignedInteger(); + modInfo.Id.AppID = gameInfo["FileSystem"]["SteamAppId"].AsUnsignedInteger(); modInfo.Name = gameInfo["game"].Value; if (gameInfo["developer"] != KeyValue.Invalid) @@ -279,5 +282,31 @@ static private string FindIcon(string modPath, string rawIconPath) return null; } + + public GameMetadata ToGame() + { + var game = new GameMetadata + { + GameId = Id, + Name = Name.RemoveTrademarks().Trim(), + InstallDirectory = InstallFolder, + IsInstalled = true, + Developers = new HashSet { new MetadataNameProperty(Developer) }, + Links = Links, + Tags = Categories?.Select(a => new MetadataNameProperty(a)).Cast().ToHashSet(), + Platforms = new HashSet { new MetadataSpecProperty("pc_windows") }, + Source = SourceNames.GetSource(IsOwned, BackendAppInfo?.Type), + }; + + if (!IconPath.IsNullOrEmpty() && File.Exists(IconPath)) + { + game.Icon = new MetadataFile(IconPath); + } + + return game; + } + + public bool IsOwned => true; + public BackendAppInfo BackendAppInfo { get; set; } } } diff --git a/source/Libraries/SteamLibrary/Models/ApiResponseModels.cs b/source/Libraries/SteamLibrary/Models/ApiResponseModels.cs index 0927ba89..b81799ee 100644 --- a/source/Libraries/SteamLibrary/Models/ApiResponseModels.cs +++ b/source/Libraries/SteamLibrary/Models/ApiResponseModels.cs @@ -1,4 +1,9 @@ +using System; using System.Collections.Generic; +using Playnite.SDK.Models; +using SteamKit2; +using SteamLibrary.Services; +using SteamLibrary.Services.Base; namespace SteamLibrary.Models { @@ -19,7 +24,7 @@ public class GetSharedLibraryAppsResponse public string owner_steamid { get; set; } } - public class FamilySharedApp + public class FamilySharedApp : ISteamApp { public int appid { get; set; } public string[] owner_steamids { get; set; } @@ -36,6 +41,48 @@ public class FamilySharedApp public uint app_type { get; set; } public uint[] content_descriptors { get; set; } public string sort_as { get; set; } + + public string NiceAppType => + app_type switch + { + 1 => "game", + 2 => "application", + 4 => "tool", + 8 => "demo", + 8192 => "soundtrack", + _ => $"{app_type}" + }; + + public string NiceExcludeReason => + exclude_reason switch + { + 0 => "none", + 1 => "ineligible", + 3 => "free", + 6 => "type_not_shared", // guessed by looking at data + _ => $"{exclude_reason}" + }; + + // we probably don't want free apps coming from family members (they do exist) + public bool IsImportable => (NiceExcludeReason == "none" || NiceExcludeReason == "free") + && (NiceAppType == "game" || NiceAppType == "demo" || NiceAppType == "tool"); + + public GameMetadata ToGame() + { + return new GameMetadata + { + GameId = Id, + Name = name.RemoveTrademarks().Trim(), + LastActivity = SteamApiServiceBase.GetLastPlayedDateTime(rt_last_played), + Playtime = rt_playtime * 60, + Platforms = new HashSet { new MetadataSpecProperty("pc_windows") }, + Source = SourceNames.GetSource(IsOwned, BackendAppInfo?.Type), + }; + } + + public GameID Id => (uint)appid; + public bool IsOwned { get; set; } + public BackendAppInfo BackendAppInfo { get; set; } } public class GetClientAppListResponse @@ -44,7 +91,7 @@ public class GetClientAppListResponse public SteamClientApp[] apps { get; set; } } - public class SteamClientApp + public class SteamClientApp : ISteamApp { public ulong appid { get; set; } public string app { get; set; } @@ -53,6 +100,31 @@ public class SteamClientApp public string bytes_required { get; set; } public bool running { get; set; } public bool installed { get; set; } + + public GameMetadata ToGame() + { + return new GameMetadata + { + GameId = Id, + Name = app.RemoveTrademarks().Trim(), + InstallSize = GetInstallSize(), + Platforms = new HashSet { new MetadataSpecProperty("pc_windows") }, + Source = SourceNames.GetSource(IsOwned, BackendAppInfo?.Type), + }; + } + + public GameID Id => appid; + public bool IsOwned { get; set; } = true; + public BackendAppInfo BackendAppInfo { get; set; } + + + private ulong? GetInstallSize() + { + if (ulong.TryParse(bytes_required, out ulong size)) + return size; + + return null; + } } public class GetOwnedGamesResponse @@ -61,11 +133,111 @@ public class GetOwnedGamesResponse public List games { get; set; } } - public class OwnedGame + public class OwnedGame : ISteamApp { public int appid { get; set; } public string name { get; set; } public uint playtime_forever { get; set; } public uint rtime_last_played { get; set; } + + public bool IncludePlaytime { get; set; } + + public GameMetadata ToGame() + { + var output = new GameMetadata + { + GameId = Id, + Name = name.RemoveTrademarks().Trim(), + Platforms = new HashSet {new MetadataSpecProperty("pc_windows")}, + Source = SourceNames.GetSource(IsOwned, BackendAppInfo?.Type), + }; + + if (IncludePlaytime) + { + output.Playtime = playtime_forever * 60; + output.LastActivity = SteamApiServiceBase.GetLastPlayedDateTime(rtime_last_played); + } + + return output; + } + + public GameID Id => (uint) appid; + public bool IsOwned => true; + public BackendAppInfo BackendAppInfo { get; set; } + + } + + public interface ISteamApp + { + GameID Id { get; } + GameMetadata ToGame(); + public BackendAppInfo BackendAppInfo { get; set; } + bool IsOwned { get; } + } + + public class LocalSteamApp : ISteamApp + { + public GameID Id { get; set; } + public string Name { get; set; } + public string InstallDir { get; set; } + + public GameMetadata ToGame() + { + return new GameMetadata() + { + GameId = Id, + Name = Name, + InstallDirectory = InstallDir, + IsInstalled = true, + Platforms = new HashSet { new MetadataSpecProperty("pc_windows") }, + Source = SourceNames.GetSource(IsOwned, BackendAppInfo?.Type), + }; + } + + public bool IsOwned => true; + public BackendAppInfo BackendAppInfo { get; set; } + + } + + public class ExtraIdSteamApp : ISteamApp + { + public GameID Id { get; set; } + public string Name { get; set; } + + public GameMetadata ToGame() + { + return new GameMetadata + { + GameId = Id, + Name = Name, + Platforms = new HashSet { new MetadataSpecProperty("pc_windows") }, + Source = SourceNames.GetSource(IsOwned, BackendAppInfo?.Type), + }; + } + + public bool IsOwned => true; + public BackendAppInfo BackendAppInfo { get; set; } + + } + + public class BackendOwnSteamDbApp : ISteamApp + { + public BackendOwnSteamDbApp(BackendAppInfo item) + { + BackendAppInfo = item; + } + + public GameMetadata ToGame() => + new GameMetadata + { + Name = BackendAppInfo.Name.RemoveTrademarks(), + GameId = Id, + Platforms = new HashSet {new MetadataSpecProperty("pc_windows")}, + Source = SourceNames.GetSource(IsOwned, BackendAppInfo?.Type), + }; + + public GameID Id => BackendAppInfo.AppId; + public bool IsOwned => true; + public BackendAppInfo BackendAppInfo { get; set; } } } diff --git a/source/Libraries/SteamLibrary/Models/BackendModels.cs b/source/Libraries/SteamLibrary/Models/BackendModels.cs new file mode 100644 index 00000000..586bd9a5 --- /dev/null +++ b/source/Libraries/SteamLibrary/Models/BackendModels.cs @@ -0,0 +1,54 @@ +#nullable enable +using System.Collections.Generic; + +namespace SteamLibrary.Models +{ + // TODO move to playnite backend repo + public class BackendSteamDbItemsRequest + { + public List? AppIds { get; set; } + } + + /// + /// Playnite backend metadata for steam app + /// + public class BackendAppInfo + { + private string? type; + public uint AppId { get; set; } + + public string? Name + { + get => name ?? $"Unknown App {AppId}"; + set => name = value; + } + + /// + /// Normalized to lowercase. Original metadata has "Game" and "game" values + /// + public string? Type + { + get => type?.ToLowerInvariant() ?? "unknown"; + set => type = value; + } + + public bool IsGame => Type == "game"; + public bool IsFree => Type == "demo" || Type == "beta"; + public bool IsApp => Type == "application"; + public bool IsMedia => Type == "music" || Type == "video"; + public bool IsTool => Type == "tool"; + public bool IsUseless => Type == "config" || Type == "dlc" || Type == "unknown"; + + public Dictionary? LocalizedNames { get; set; } + + public void LocalizeName(string lang) + { + if (LocalizedNames?.TryGetValue(lang, out var appName) == true && !string.IsNullOrWhiteSpace(appName)) + { + Name = appName; + } + } + + private string? name; + } +} diff --git a/source/Libraries/SteamLibrary/Services/Base/SteamApiServiceBase.cs b/source/Libraries/SteamLibrary/Services/Base/SteamApiServiceBase.cs index 2c7f8023..54be9d33 100644 --- a/source/Libraries/SteamLibrary/Services/Base/SteamApiServiceBase.cs +++ b/source/Libraries/SteamLibrary/Services/Base/SteamApiServiceBase.cs @@ -57,7 +57,7 @@ public TResponse Get(string baseUrl, Dictionary param } } - protected static DateTime? GetLastPlayedDateTime(uint unixEpochSeconds) + public static DateTime? GetLastPlayedDateTime(uint unixEpochSeconds) { if (unixEpochSeconds == 0) return null; diff --git a/source/Libraries/SteamLibrary/Services/ClientCommService.cs b/source/Libraries/SteamLibrary/Services/ClientCommService.cs index 110b388c..1b256ddd 100644 --- a/source/Libraries/SteamLibrary/Services/ClientCommService.cs +++ b/source/Libraries/SteamLibrary/Services/ClientCommService.cs @@ -1,7 +1,5 @@ -using Playnite.SDK.Models; using SteamLibrary.Models; using SteamLibrary.Services.Base; -using System; using System.Collections.Generic; using System.Linq; @@ -13,38 +11,22 @@ namespace SteamLibrary.Services /// public class ClientCommService : SteamApiServiceBase { - public IEnumerable GetClientAppList(SteamLibrarySettings settings, SteamUserToken userToken) + /// + /// IClientCommService/GetClientAppList + /// + public IEnumerable GetClientAppList(SteamLibrarySettings settings, SteamUserToken userToken) { var response = Get("https://api.steampowered.com/IClientCommService/GetClientAppList/v1/", new Dictionary { - { "fields", "games" }, // could also add tools here like so: games|tools + { "fields", "all" }, { "access_token", userToken.AccessToken }, { "language", settings.LanguageKey }, }); - return response?.apps?.Select(ToGame) - ?? Enumerable.Empty(); + return response?.apps ?? Enumerable.Empty(); } - private static GameMetadata ToGame(SteamClientApp app) - { - return new GameMetadata - { - GameId = app.appid.ToString(), - Name = app.app.RemoveTrademarks().Trim(), - InstallSize = GetInstallSize(app), - Platforms = new HashSet { new MetadataSpecProperty("pc_windows") }, - Source = new MetadataNameProperty(SourceNames.Steam) - }; - } - private static ulong? GetInstallSize(SteamClientApp app) - { - if (ulong.TryParse(app.bytes_required, out ulong size)) - return size; - - return null; - } } } diff --git a/source/Libraries/SteamLibrary/Services/FamilyGroupsService.cs b/source/Libraries/SteamLibrary/Services/FamilyGroupsService.cs index cbb139a1..06e27302 100644 --- a/source/Libraries/SteamLibrary/Services/FamilyGroupsService.cs +++ b/source/Libraries/SteamLibrary/Services/FamilyGroupsService.cs @@ -1,7 +1,5 @@ -using Playnite.SDK.Models; using SteamLibrary.Models; using SteamLibrary.Services.Base; -using System; using System.Collections.Generic; using System.Linq; @@ -9,56 +7,63 @@ namespace SteamLibrary.Services { public class FamilyGroupsService : SteamApiServiceBase { - public IEnumerable GetSharedGames(SteamLibrarySettings settings, SteamUserToken userToken, out HashSet userIds) + /// + /// IFamilyGroupsService/GetSharedLibraryApps + /// + public IEnumerable GetSharedGames(SteamLibrarySettings settings, SteamUserToken userToken, HashSet userIds) { - userIds = new HashSet(); var familyGroup = GetFamilyGroupForUser(userToken); if (familyGroup.is_not_member_of_any_group) - return Enumerable.Empty(); + return Enumerable.Empty(); var sharedLibrary = GetSharedLibraryApps(settings, userToken, familyGroup.family_groupid); if (sharedLibrary.apps == null) //user is most likely in a family group without any other members - return Enumerable.Empty(); + return Enumerable.Empty(); - userIds = sharedLibrary.apps.SelectMany(a => a.owner_steamids).ToHashSet(); + userIds.UnionWith(sharedLibrary.apps.SelectMany(a => a.owner_steamids)); + var currentOwner = sharedLibrary.owner_steamid; + foreach (var app in sharedLibrary.apps) + { + // steam lies about ownership: currentOwner in the list, but game has exclude_reason - meaning it's free, coming from family + app.IsOwned = app.owner_steamids.Contains(currentOwner) && app.exclude_reason == 0; + } - return sharedLibrary.apps.Select(ToGame); + return sharedLibrary.apps.Where(x => x.IsImportable); } + + + /// + /// IFamilyGroupsService/GetFamilyGroupForUser + /// private GetFamilyGroupForUserResponse GetFamilyGroupForUser(SteamUserToken userToken) { return Get("https://api.steampowered.com/IFamilyGroupsService/GetFamilyGroupForUser/v1/", new Dictionary { { "access_token", userToken.AccessToken }, - { "steamid", userToken.UserId.ToString() }, }); } + /// + /// IFamilyGroupsService/GetSharedLibraryApps + /// private GetSharedLibraryAppsResponse GetSharedLibraryApps(SteamLibrarySettings settings, SteamUserToken userToken, string familyGroupId) { return Get("https://api.steampowered.com/IFamilyGroupsService/GetSharedLibraryApps/v1/", new Dictionary { { "access_token", userToken.AccessToken }, - { "steamid", userToken.UserId.ToString() }, { "family_groupid", familyGroupId }, { "language", settings.LanguageKey }, + { "include_own", "true" }, + { "include_excluded", "true" }, + { "include_free", "true" }, + { "include_non_games", "true" }, }); } - private static GameMetadata ToGame(FamilySharedApp sharedApp) - { - return new GameMetadata - { - GameId = sharedApp.appid.ToString(), - Name = sharedApp.name.RemoveTrademarks().Trim(), - LastActivity = GetLastPlayedDateTime(sharedApp.rt_last_played), - Playtime = sharedApp.rt_playtime * 60, - Platforms = new HashSet { new MetadataSpecProperty("pc_windows") }, - Source = new MetadataNameProperty(SourceNames.FamilySharing) - }; - } + } } diff --git a/source/Libraries/SteamLibrary/Services/PlayerService.cs b/source/Libraries/SteamLibrary/Services/PlayerService.cs index d53b80dc..469f18c5 100644 --- a/source/Libraries/SteamLibrary/Services/PlayerService.cs +++ b/source/Libraries/SteamLibrary/Services/PlayerService.cs @@ -1,21 +1,24 @@ -using Playnite.SDK.Models; using SteamLibrary.Models; using SteamLibrary.Services.Base; -using System; using System.Collections.Generic; -using System.Linq; namespace SteamLibrary.Services { public class PlayerService : SteamApiServiceBase { - public IEnumerable GetOwnedGamesWeb(SteamLibrarySettings settings, SteamUserToken userToken, bool freeSub) => - PlayerServiceGetOwnedGames(settings, userToken.UserId, "access_token", userToken.AccessToken, freeSub, true); + /// + /// IPlayerService/GetOwnedGames + /// + public IEnumerable GetOwnedGamesWeb(SteamLibrarySettings settings, SteamUserToken userToken) => + PlayerServiceGetOwnedGames(settings, userToken.UserId, "access_token", userToken.AccessToken, true); - public IEnumerable GetOwnedGamesApiKey(SteamLibrarySettings settings, ulong userId, string apiKey, bool freeSub, bool includePlaytime = true) => - PlayerServiceGetOwnedGames(settings, userId, "key", apiKey, freeSub, includePlaytime); + /// + /// IPlayerService/GetOwnedGames + /// + public IEnumerable GetOwnedGamesApiKey(SteamLibrarySettings settings, ulong userId, string apiKey, bool includePlaytime = true) => + PlayerServiceGetOwnedGames(settings, userId, "key", apiKey, includePlaytime); - private IEnumerable PlayerServiceGetOwnedGames(SteamLibrarySettings settings, ulong userId, string keyType, string key, bool freeSub, bool includePlaytime) + private IEnumerable PlayerServiceGetOwnedGames(SteamLibrarySettings settings, ulong userId, string keyType, string key, bool includePlaytime) { // For some reason Steam Web API likes to return 429 even if you // don't make a request in several hours, so just retry couple times. @@ -27,30 +30,18 @@ private IEnumerable PlayerServiceGetOwnedGames(SteamLibrarySetting { "steamid", userId.ToString() }, { "include_appinfo", "true" }, { "include_played_free_games", "true" }, - { "include_free_sub", freeSub.ToString() }, + { "include_free_sub", "true" }, + { "skip_unvetted_apps", "false" }, { "language", settings.LanguageKey }, }; var response = Get("https://api.steampowered.com/IPlayerService/GetOwnedGames/v1/", parameters, retrySettings); - return response.games.Select(g => ToGame(g, includePlaytime)); - } - - private static GameMetadata ToGame(OwnedGame game, bool includePlaytime) - { - var output = new GameMetadata + foreach (var game in response.games) { - GameId = game.appid.ToString(), - Name = game.name.RemoveTrademarks().Trim(), - Platforms = new HashSet { new MetadataSpecProperty("pc_windows") }, - Source = new MetadataNameProperty(SourceNames.Steam), - }; - - if (includePlaytime) - { - output.Playtime = game.playtime_forever * 60; - output.LastActivity = GetLastPlayedDateTime(game.rtime_last_played); + game.IncludePlaytime = includePlaytime; } - - return output; + return response.games; } + + } } diff --git a/source/Libraries/SteamLibrary/Services/SourceNames.cs b/source/Libraries/SteamLibrary/Services/SourceNames.cs index 290837f5..0ca02d1e 100644 --- a/source/Libraries/SteamLibrary/Services/SourceNames.cs +++ b/source/Libraries/SteamLibrary/Services/SourceNames.cs @@ -1,9 +1,32 @@ +using Playnite.SDK.Models; + namespace SteamLibrary.Services { public static class SourceNames { public const string Steam = "Steam"; + public const string Extras = "Steam Extras"; + public const string Video = "Steam Video"; public const string FamilySharing = "Steam Family Sharing"; - public static readonly string[] AllKnown = { Steam, FamilySharing }; + public const string FamilySharingExtras = "Steam Family Sharing Extras"; + public static readonly string[] AllKnown = {Steam, FamilySharing, Extras, FamilySharingExtras, Video}; + + // TODO i don't really see a good way to cram type (game/app/media/etc) and ownership (own/family) into one "source" property + // it was ok for gog and epic but this is starting to look insane + // also videos need to launch browser instead of installing, i decided not to break steam ids and encode this info here + // all this infinitely debatable, for example should apps not be labeled "extras"?.. + // maybe make an option to create tags with type, ownership, whatever else? + public static MetadataNameProperty GetSource(bool isOwned, string type) + { + var source = type switch + { + "video" => Video, + "game" when isOwned => Steam, + "game" => FamilySharing, + _ when isOwned => Extras, + _ => FamilySharingExtras + }; + return new MetadataNameProperty(source); + } } -} \ No newline at end of file +} diff --git a/source/Libraries/SteamLibrary/Services/SteamLocalService.cs b/source/Libraries/SteamLibrary/Services/SteamLocalService.cs index a56940d9..0c5b8184 100644 --- a/source/Libraries/SteamLibrary/Services/SteamLocalService.cs +++ b/source/Libraries/SteamLibrary/Services/SteamLocalService.cs @@ -1,12 +1,12 @@ using Playnite.Common; using Playnite.SDK; -using Playnite.SDK.Models; using SteamKit2; using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; +using SteamLibrary.Models; namespace SteamLibrary.Services { @@ -69,7 +69,7 @@ public static IDictionary GetGamesLastActivity(ulong steamId) return result; } - internal static GameMetadata GetInstalledGameFromFile(string path) + internal static LocalSteamApp GetInstalledGameFromFile(string path) { var kv = new KeyValue(); using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) @@ -113,22 +113,17 @@ internal static GameMetadata GetInstalledGameFromFile(string path) } } - var game = new GameMetadata() + return new LocalSteamApp() { - // no source because normal/family sharing source is determined later in SteamServiceAggregator - GameId = gameId.ToString(), + Id = gameId, Name = name.RemoveTrademarks().Trim(), - InstallDirectory = installDir, - IsInstalled = true, - Platforms = new HashSet { new MetadataSpecProperty("pc_windows") } + InstallDir = installDir }; - - return game; } - internal static List GetInstalledGamesFromFolder(string path) + internal static List GetInstalledGamesFromFolder(string path) { - var games = new List(); + var games = new List(); foreach (var file in Directory.GetFiles(path, @"appmanifest*")) { @@ -145,7 +140,7 @@ internal static List GetInstalledGamesFromFolder(string path) continue; } - if (game.InstallDirectory.IsNullOrEmpty() || game.InstallDirectory.Contains(@"steamapps\music")) + if (game.InstallDir.IsNullOrEmpty() || game.InstallDir.Contains(@"steamapps\music")) { logger.Info($"Steam game {game.Name} is not properly installed or it's a soundtrack, skipping."); continue; @@ -163,9 +158,9 @@ internal static List GetInstalledGamesFromFolder(string path) return games; } - internal static List GetInstalledGoldSrcModsFromFolder(string path) + internal static List GetInstalledGoldSrcModsFromFolder(string path) { - var games = new List(); + var games = new List(); var dirInfo = new DirectoryInfo(path); foreach (var folder in dirInfo.GetDirectories().Where(a => !firstPartyModPrefixes.Any(prefix => a.Name.StartsWith(prefix))).Select(a => a.FullName)) @@ -188,9 +183,9 @@ internal static List GetInstalledGoldSrcModsFromFolder(string path return games; } - internal static List GetInstalledSourceModsFromFolder(string path) + internal static List GetInstalledSourceModsFromFolder(string path) { - var games = new List(); + var games = new List(); foreach (var folder in Directory.GetDirectories(path)) { @@ -212,38 +207,14 @@ internal static List GetInstalledSourceModsFromFolder(string path) return games; } - internal static GameMetadata GetInstalledModFromFolder(string path, ModInfo.ModType modType) + internal static ModInfo GetInstalledModFromFolder(string path, ModInfo.ModType modType) { - var modInfo = ModInfo.GetFromFolder(path, modType); - if (modInfo == null) - { - return null; - } - - var game = new GameMetadata - { - Source = new MetadataNameProperty(SourceNames.Steam), - GameId = modInfo.GameId.ToString(), - Name = modInfo.Name.RemoveTrademarks().Trim(), - InstallDirectory = path, - IsInstalled = true, - Developers = new HashSet { new MetadataNameProperty(modInfo.Developer) }, - Links = modInfo.Links, - Tags = modInfo.Categories?.Select(a => new MetadataNameProperty(a)).Cast().ToHashSet(), - Platforms = new HashSet { new MetadataSpecProperty("pc_windows") } - }; - - if (!modInfo.IconPath.IsNullOrEmpty() && File.Exists(modInfo.IconPath)) - { - game.Icon = new MetadataFile(modInfo.IconPath); - } - - return game; + return ModInfo.GetFromFolder(path, modType); } - internal static Dictionary GetInstalledGames(bool includeMods = true) + internal static Dictionary GetInstalledGames(bool includeMods) { - var games = new Dictionary(); + var games = new Dictionary(); if (!Steam.IsInstalled) { throw new Exception("Steam installation not found."); @@ -257,14 +228,14 @@ internal static Dictionary GetInstalledGames(bool includeM GetInstalledGamesFromFolder(libFolder).ForEach(a => { // Ignore redist - if (a.GameId == "228980") + if (a.Id == "228980") { return; } - if (!games.ContainsKey(a.GameId)) + if (!games.ContainsKey(a.Id)) { - games.Add(a.GameId, a); + games.Add(a.Id, a); } }); } @@ -284,9 +255,9 @@ internal static Dictionary GetInstalledGames(bool includeM { GetInstalledGoldSrcModsFromFolder(Steam.ModInstallPath).ForEach(a => { - if (!games.ContainsKey(a.GameId)) + if (!games.ContainsKey(a.Id)) { - games.Add(a.GameId, a); + games.Add(a.Id, a); } }); } @@ -297,9 +268,9 @@ internal static Dictionary GetInstalledGames(bool includeM { GetInstalledSourceModsFromFolder(Steam.SourceModInstallPath).ForEach(a => { - if (!games.ContainsKey(a.GameId)) + if (!games.ContainsKey(a.Id)) { - games.Add(a.GameId, a); + games.Add(a.Id, a); } }); } diff --git a/source/Libraries/SteamLibrary/Services/SteamServiceAggregator.cs b/source/Libraries/SteamLibrary/Services/SteamServiceAggregator.cs index 9f48aaf4..e7139abb 100644 --- a/source/Libraries/SteamLibrary/Services/SteamServiceAggregator.cs +++ b/source/Libraries/SteamLibrary/Services/SteamServiceAggregator.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; +using SteamLibrary.Models; namespace SteamLibrary.Services { @@ -35,7 +36,6 @@ public SteamServiceAggregator(PlayerService playerService, SteamStoreService sto public async Task> GetGamesAsync(SteamLibrarySettings settings) { var installedGameIds = new HashSet(); - var allGames = new Dictionary(); Exception importError = null; @@ -61,17 +61,39 @@ void AddGames(IEnumerable gamesToAdd, bool overwriteName = false) if (existingGame.Playtime == 0) existingGame.Playtime = game.Playtime; - if (existingGame.Source == null) - existingGame.Source = game.Source; + existingGame.Source = game.Source; } } } - bool TryAddGames(Func> getGamesFunc, string importSource, HashSet gameIdsOutput = null, bool overwriteName = false) + bool TryAddGames(Func> getGamesFunc, string importSource, HashSet gameIdsOutput = null, bool overwriteName = false) { try { - var games = getGamesFunc().ToList(); + var apps = getGamesFunc().ToList(); + var query = apps.Where(x => x.BackendAppInfo is null) + .Select(x => x.Id) + .Distinct() + .ToList(); + var infos = playniteBackend.GetAppInfo(query).Result.ToDictionary(x => x.AppId); + foreach (var app in apps) + { + if (infos.TryGetValue((uint) app.Id.ToUInt64(), out var info)) + { + app.BackendAppInfo = info; + } + + if (string.IsNullOrWhiteSpace(app.BackendAppInfo.Name) || string.IsNullOrWhiteSpace(app.BackendAppInfo.Type)) + { + logger.Warn($"{app.Id} own={app.IsOwned} incomplete backend info: [{app.BackendAppInfo!.Name}] [{app.BackendAppInfo.Type}]"); + } + } + + var games = apps + .Where(x => Filter(x, settings)) + .Select(x => x.ToGame()) + .ToList(); + logger.Info($"Found {games.Count} {importSource} Steam games."); AddGames(games, overwriteName); @@ -89,40 +111,69 @@ bool TryAddGames(Func> getGamesFunc, string importSour } } - if (settings.ImportInstalledGames) - TryAddGames(() => SteamLocalService.GetInstalledGames().Values, "Installed", installedGameIds); + if (settings.ImportInstalled) + { + TryAddGames(() => SteamLocalService.GetInstalledGames(settings.ImportInstalledMods).Values, "Installed", installedGameIds); + } if (settings.ConnectAccount) { - var onlineLibraryGameIds = new HashSet(); + var importedOnlineOwn = new HashSet(); + var importedOnlineFamily = new HashSet(); var familySharingUserIds = new HashSet(); - - if (settings.IsPrivateAccount) + if (settings.UseApiLogin) { if (settings.UserId.IsNullOrEmpty()) { throw new Exception(playniteApi.Resources.GetString(LOC.SteamNotLoggedInError)); } - TryAddGames(() => playerService.GetOwnedGamesApiKey(settings, ulong.Parse(settings.UserId), settings.RuntimeApiKey, settings.IncludeFreeSubGames), "PlayerService (API key)", onlineLibraryGameIds, true); + TryAddGames(() => playerService.GetOwnedGamesApiKey(settings, ulong.Parse(settings.UserId), settings.RuntimeApiKey), "PlayerService (API key)", importedOnlineOwn, true); } else { try { var userToken = await storeService.GetAccessTokenAsync(); - TryAddGames(() => playerService.GetOwnedGamesWeb(settings, userToken, settings.IncludeFreeSubGames), "PlayerService (access token)", onlineLibraryGameIds, true); + // endpoint query order is important: if a game gets imported as own and from family, "Steam" source should win + + if (settings.ImportGamesFamily || settings.ImportFreeFamily || settings.ImportToolsFamily || settings.ImportToolsOwn) + { + // game, demo, beta, tool - returns family content + // also required to filter out family tools that steam wrongly displays as own + // also returns all tools + var familyGames = familyGroupsService.GetSharedGames(settings, userToken, familySharingUserIds); + TryAddGames(() => familyGames, "Family Sharing", importedOnlineFamily, true); + } + + /*if (settings.ImportFreeOwn) + { + // demo - returns inconclusive data. better results with next endpoint, this can be skipped entirely + var ownedGames = playerService.GetOwnedGamesWeb(settings, userToken); + TryAddGames(() => ownedGames, "PlayerService (access token)", importedOnlineOwn, true); + }*/ - if (!TryAddGames(() => clientCommService.GetClientAppList(settings, userToken), "GetClientAppList", onlineLibraryGameIds, true)) - TryAddGames(() => GetSteamStoreGamesAsync(settings, allGames).GetAwaiter().GetResult(), "userdata", onlineLibraryGameIds); + if (settings.ImportFreeOwn || settings.ImportToolsOwn) + { + // demo, tool - returns all demos and some tools + // works only when steam client is running. there is a note in UI about that + var clientGames = clientCommService.GetClientAppList(settings, userToken).ToList(); + //FixToolsOwnership(clientGames, importedOnlineFamily); + TryAddGames(() => clientGames, "GetClientAppList", importedOnlineOwn, true); + } - if (settings.ImportFamilySharedGames) - TryAddGames(() => familyGroupsService.GetSharedGames(settings, userToken, out familySharingUserIds), "Family Sharing", onlineLibraryGameIds, true); + if (settings.ImportGamesOwn || settings.ImportAppsOwn || settings.ImportMediaOwn || settings.ImportFreeOwn) + { + // game, app, media, beta - returns everything perfectly without any issues + // no need to query anything else for these types + var userdataGames = await GetUserdataGamesAsync(settings); + TryAddGames(() => userdataGames, "userdata", importedOnlineOwn, true); + } } catch (Exception e) { importError = e; - logger.Error(e, "Failed to get access token for Steam account."); + logger.Error(e, "Failed to import Steam games"); } } @@ -131,13 +182,14 @@ bool TryAddGames(Func> getGamesFunc, string importSour if (familySharingUserIds.Contains(extraAccount.Item1.ToString())) logger.Info($"Skipped extra account import for {extraAccount.Item1} because it's in the family sharing group"); else - TryAddGames(() => playerService.GetOwnedGamesApiKey(settings, extraAccount.Item1, extraAccount.Item2, false, false), $"Extra Account ({extraAccount.Item1})", onlineLibraryGameIds, true); + TryAddGames(() => playerService.GetOwnedGamesApiKey(settings, extraAccount.Item1, extraAccount.Item2, false), $"Extra Account ({extraAccount.Item1})", importedOnlineOwn, true); } - if (settings.IgnoreOtherInstalled) + if (settings.ImportInstalledIgnoreOthers) { - var idsOfInstalledGamesFromAccountsNotUnderUserControl = installedGameIds.Except(onlineLibraryGameIds).ToList(); - foreach (var installedGameId in idsOfInstalledGamesFromAccountsNotUnderUserControl) + // when a foreign game is installed AND also imported from family, there is no reason to exclude it + var installedNotOwnedNotFamily = installedGameIds.Except(importedOnlineOwn).Except(importedOnlineFamily); + foreach (var installedGameId in installedNotOwnedNotFamily) { if (IsModId(installedGameId)) continue; @@ -163,50 +215,99 @@ bool TryAddGames(Func> getGamesFunc, string importSour playniteApi.Notifications.Remove(plugin.ImportErrorMessageId); } - var output = allGames.Values.Where(g => !g.Name.IsNullOrWhiteSpace() && (g.IsInstalled || settings.ImportUninstalledGames)).ToList(); - - foreach (var game in output.Where(g => g.Source == null)) //installed games don't get a source by default + foreach (var unnamed in allGames.Where(x => x.Value.Name.IsNullOrWhiteSpace())) { - game.Source = new MetadataNameProperty(SourceNames.Steam); + var game = unnamed.Value; + logger.Warn($"Unnamed game [{game.GameId}]"); + allGames.Remove(unnamed.Key); } + var output = allGames.Values.ToList(); UpdateExistingGames(output); - return output; } - private async Task> GetSteamStoreGamesAsync(SteamLibrarySettings settings, Dictionary pendingImportGames) + private bool Filter(ISteamApp app, SteamLibrarySettings settings) { - var appIds = (await storeService.GetUserDataAsync()).rgOwnedApps; + var b = app.BackendAppInfo; + logger.Debug($"FILTER [{app.Id}] [{b.Name}] {app.GetType().Name} {b.Type} useless={b.IsUseless} game={b.IsGame} app={b.IsApp} media={b.IsMedia} free={b.IsFree} tool={b.IsTool} own={app.IsOwned}"); + + if (b.IsUseless) + { + return false; + } + + if (app is LocalSteamApp && settings.ImportInstalled) + { + return true; + } - var existingLibraryIds = playniteApi.Database.Games.Where(g => g.PluginId == plugin.Id).Select(g => g.GameId).ToHashSet(); + if (app is ModInfo && settings.ImportInstalledMods) + { + return true; + } - var newAppIds = appIds.Where(id => + if (app is ExtraIdSteamApp) { - var strId = id.ToString(); - return !pendingImportGames.ContainsKey(strId) - && !existingLibraryIds.Contains(strId); - }).ToList(); + return true; + } - var appInfos = playniteBackend.GetAppInfos(newAppIds).Result; + if (b.IsGame && app.IsOwned && settings.ImportGamesOwn) + { + return true; + } - var output = new List(); + if (b.IsGame && !app.IsOwned && settings.ImportGamesFamily) + { + return true; + } - foreach (var appInfo in appInfos) + if (b.IsApp && settings.ImportAppsOwn) { - if (appInfo.LocalizedNames?.TryGetValue(settings.LanguageKey, out var appName) != true || string.IsNullOrWhiteSpace(appName)) - appName = appInfo.Name; + return true; + } - if (string.IsNullOrWhiteSpace(appName) || !"game".Equals(appInfo.Type, StringComparison.OrdinalIgnoreCase)) - continue; + if (b.IsMedia && settings.ImportMediaOwn) + { + return true; + } - output.Add(new GameMetadata - { - Name = appName.RemoveTrademarks(), - GameId = appInfo.AppId.ToString(), - Platforms = new HashSet { new MetadataSpecProperty("pc_windows") }, - Source = new MetadataNameProperty(SourceNames.Steam), - }); + if (b.IsFree && app.IsOwned && settings.ImportFreeOwn) + { + return true; + } + + if (b.IsFree && !app.IsOwned && settings.ImportFreeFamily) + { + return true; + } + + if (b.IsTool && app.IsOwned && settings.ImportToolsOwn) + { + return true; + } + + if (b.IsTool && !app.IsOwned && settings.ImportToolsFamily) + { + return true; + } + + return false; + } + + /// + /// dynamicstore/userdata + playnite backend + /// + private async Task> GetUserdataGamesAsync(SteamLibrarySettings settings) + { + var userdata = await storeService.GetUserDataAsync(); + var appIds = userdata.rgOwnedApps.Select(x => new GameID(x)).ToList(); + var appInfos = await playniteBackend.GetAppInfo(appIds); + var output = new List(); + foreach (var app in appInfos) + { + app.LocalizeName(settings.LanguageKey); + output.Add(new BackendOwnSteamDbApp(app)); } return output; } @@ -266,7 +367,7 @@ GameSource GetOrCreateSource(string name) private static bool IsModId(string gameId) => new GameID(ulong.Parse(gameId)).IsMod; - private IEnumerable GetGamesFromExtraIds(SteamLibrarySettings settings) + private IEnumerable GetGamesFromExtraIds(SteamLibrarySettings settings) { if (!settings.ExtraIDsToImport.HasItems()) yield break; @@ -279,12 +380,10 @@ private IEnumerable GetGamesFromExtraIds(SteamLibrarySettings sett continue; } - yield return new GameMetadata + yield return new ExtraIdSteamApp() { - GameId = parseResult.Item1, + Id = uint.Parse(parseResult.Item1), Name = parseResult.Item2, - Source = new MetadataNameProperty(SourceNames.Steam), - Platforms = new HashSet { new MetadataSpecProperty("pc_windows") } }; } } diff --git a/source/Libraries/SteamLibrary/Services/SteamServicesClient.cs b/source/Libraries/SteamLibrary/Services/SteamServicesClient.cs index cd1390e1..ad91a543 100644 --- a/source/Libraries/SteamLibrary/Services/SteamServicesClient.cs +++ b/source/Libraries/SteamLibrary/Services/SteamServicesClient.cs @@ -1,13 +1,10 @@ -using Playnite.Backend.Steam; -using Playnite.SDK; +using Playnite.SDK; using PlayniteExtensions.Common; -using SteamLibrary.Models; -using System; using System.Collections.Generic; using System.Linq; -using System.Net.Http; -using System.Text; using System.Threading.Tasks; +using SteamKit2; +using SteamLibrary.Models; namespace SteamLibrary.Services { @@ -19,14 +16,19 @@ public SteamServicesClient(string endpoint) : base(endpoint) { } - public async Task> GetAppInfos(List appIds) + /// + /// steam/appinfo + /// + public async Task> GetAppInfo(List appIds) { - var request = new SteamDbItemsRequest() - { - AppIds = appIds + // TODO local cache maybe? + var ids = appIds.Select(x => x.ToUInt64()).ToList(); + var request = new BackendSteamDbItemsRequest() + { + AppIds = ids }; - return await PostRequest>("steam/appinfo", request); + return await PostRequest>("steam/appinfo", request); } } } diff --git a/source/Libraries/SteamLibrary/Services/SteamStoreService.cs b/source/Libraries/SteamLibrary/Services/SteamStoreService.cs index 068f35d7..9fb709ef 100644 --- a/source/Libraries/SteamLibrary/Services/SteamStoreService.cs +++ b/source/Libraries/SteamLibrary/Services/SteamStoreService.cs @@ -1,5 +1,4 @@ using AngleSharp.Parser.Html; -using Newtonsoft.Json; using Playnite.SDK; using Playnite.SDK.Events; using SteamLibrary.Models; @@ -8,6 +7,7 @@ using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; +using Playnite.SDK.Data; namespace SteamLibrary.Services { @@ -23,6 +23,9 @@ public SteamStoreService(IPlayniteAPI playniteApi) PlayniteApi = playniteApi; } + /// + /// dynamicstore/userdata + /// public async Task GetUserDataAsync() { var str = await DownloadPageSourceAsync("https://store.steampowered.com/dynamicstore/userdata/"); @@ -34,7 +37,7 @@ public async Task GetUserDataAsync() str = doc.GetElementsByTagName("body").FirstOrDefault()?.TextContent; } - var model = JsonConvert.DeserializeObject(str); + var model = Serialization.FromJson(str); return model; } diff --git a/source/Libraries/SteamLibrary/SteamGameController.cs b/source/Libraries/SteamLibrary/SteamGameController.cs index 1c5d3d2e..6812dc32 100644 --- a/source/Libraries/SteamLibrary/SteamGameController.cs +++ b/source/Libraries/SteamLibrary/SteamGameController.cs @@ -28,6 +28,11 @@ public override void Dispose() public override void Install(InstallActionArgs args) { + if (HandleVideos()) + { + return; + } + if (!Steam.IsInstalled) { throw new Exception("Steam installation not found."); @@ -61,9 +66,10 @@ await Task.Run(async () => var installed = SteamLocalService.GetInstalledGames(false); if (installed.TryGetValue(id, out var installedGame)) { + var game = installedGame.ToGame(); var installInfo = new GameInstallationData { - InstallDirectory = installedGame.InstallDirectory + InstallDirectory = game.InstallDirectory }; InvokeOnInstalled(new GameInstalledEventArgs(installInfo)); @@ -74,6 +80,21 @@ await Task.Run(async () => } }); } + + private bool HandleVideos() + { + if (Game.Source.Name != SourceNames.Video) + { + return false; + } + + var url = GetExtraUrl(Game.GameId); + ProcessStarter.StartUrl(url); + InvokeOnInstallationCancelled(new GameInstallationCancelledEventArgs()); + return true; + } + + private string GetExtraUrl(string steamId) => $"https://store.steampowered.com/video/watch/{steamId}"; } public class SteamUninstallController : UninstallController @@ -159,6 +180,7 @@ public override void Dispose() public override void Play(PlayActionArgs args) { + Dispose(); var steamExe = Steam.ClientExecPath; @@ -171,9 +193,10 @@ public override void Play(PlayActionArgs args) if (gameId.IsMod) { var allGames = SteamLocalService.GetInstalledGames(false); - if (allGames.TryGetValue(gameId.AppID.ToString(), out GameMetadata realGame)) + if (allGames.TryGetValue(gameId.AppID.ToString(), out var realGame)) { - installDirectory = realGame.InstallDirectory; + var game = realGame.ToGame(); + installDirectory = game.InstallDirectory; } } diff --git a/source/Libraries/SteamLibrary/SteamLibrary.csproj b/source/Libraries/SteamLibrary/SteamLibrary.csproj index a6708ef9..e6909b01 100644 --- a/source/Libraries/SteamLibrary/SteamLibrary.csproj +++ b/source/Libraries/SteamLibrary/SteamLibrary.csproj @@ -40,11 +40,8 @@ ..\..\packages\Microsoft.Xaml.Behaviors.Wpf.1.1.39\lib\net45\Microsoft.Xaml.Behaviors.dll - - ..\..\packages\Newtonsoft.Json.10.0.3\lib\net45\Newtonsoft.Json.dll - - - ..\..\packages\PlayniteSDK.6.4.0\lib\net462\Playnite.SDK.dll + + ..\..\packages\PlayniteSDK.6.14.0\lib\net462\Playnite.SDK.dll @@ -191,6 +188,7 @@ + diff --git a/source/Libraries/SteamLibrary/SteamLibrarySettingsView.xaml b/source/Libraries/SteamLibrary/SteamLibrarySettingsView.xaml index 07da546e..9229f0c5 100644 --- a/source/Libraries/SteamLibrary/SteamLibrarySettingsView.xaml +++ b/source/Libraries/SteamLibrary/SteamLibrarySettingsView.xaml @@ -36,11 +36,18 @@ + + IsChecked="{Binding Settings.ImportInstalled}" + Content="Import installed content"/> + + - - - + Content="Ignore installed content from other accounts"/> - -