diff --git a/source/Generic/ExtraMetadataLoader/ExtraMetadataLoader.cs b/source/Generic/ExtraMetadataLoader/ExtraMetadataLoader.cs index e6404e8075..9641bbbd9c 100644 --- a/source/Generic/ExtraMetadataLoader/ExtraMetadataLoader.cs +++ b/source/Generic/ExtraMetadataLoader/ExtraMetadataLoader.cs @@ -1,6 +1,7 @@ using ExtraMetadataLoader.Helpers; using ExtraMetadataLoader.Interfaces; using ExtraMetadataLoader.LogoProviders; +using ExtraMetadataLoader.LogoProviders.LaunchBox; using ExtraMetadataLoader.Models; using ExtraMetadataLoader.Services; using ExtraMetadataLoader.ViewModels; @@ -53,6 +54,7 @@ public class ExtraMetadataLoader : GenericPlugin private readonly VideosDownloader videosDownloader; private readonly ExtraMetadataHelper extraMetadataHelper; private readonly List _logoProviders; + private readonly ILogoProvider _launchBoxLogoProvider; private VideoPlayerControl detailsVideoControl; private VideoPlayerControl gridVideoControl; private VideoPlayerControl genericVideoControl; @@ -140,6 +142,8 @@ public ExtraMetadataLoader(IPlayniteAPI api) : base(api) PlayniteUtilities.AddTextIcoFontResource(iconResource.Key, iconResource.Value); } + var launchBoxMetadataCache = new LaunchBoxMetadataCache(Path.Combine(GetPluginUserDataPath(), "LaunchBox"), logger); + _launchBoxLogoProvider = new LaunchBoxClearLogoProvider(PlayniteApi, logger, launchBoxMetadataCache); _logoProviders = new List { new SteamProvider(PlayniteApi, settings.Settings), @@ -404,6 +408,38 @@ public override IEnumerable GetGameMenuItems(GetGameMenuItemsArgs } }, new GameMenuItem + { + Description = ResourceProvider.GetString("LOCExtra_Metadata_Loader_MenuItemDescriptionDownloadLaunchBoxLogosSelectedGames"), + MenuSection = $"Extra Metadata|{logosSection}", + Icon = "emtDownloadIcon", + Action = _ => { + var overwrite = GetBoolFromYesNoDialog(ResourceProvider.GetString("LOCExtra_Metadata_Loader_DialogMessageOverwriteLogosChoice")); + var isBackgroundDownload = GetBoolFromYesNoDialog(ResourceProvider.GetString("LOCExtra_Metadata_Loader_DialogAskSelectLogosAutomatically")); + var progressTitle = ResourceProvider.GetString("LOCExtra_Metadata_Loader_DialogMessageDownloadingLogosLaunchBox"); + + var progressOptions = new GlobalProgressOptions(progressTitle, true); + progressOptions.IsIndeterminate = false; + PlayniteApi.Dialogs.ActivateGlobalProgress((a) => + { + var games = args.Games.Distinct(); + a.ProgressMaxValue = games.Count() + 1; + foreach (var game in games) + { + if (a.CancelToken.IsCancellationRequested) + { + break; + } + + a.CurrentProgressValue++; + a.Text = $"{progressTitle}\n\n{a.CurrentProgressValue}/{games.Count()}\n{game.Name}"; + + GetGameLogo(_launchBoxLogoProvider, game, isBackgroundDownload, overwrite, a.CancelToken); + }; + }, progressOptions); + PlayniteApi.Dialogs.ShowMessage(ResourceProvider.GetString("LOCExtra_Metadata_Loader_DialogMessageDone"), "Extra Metadata Loader"); + } + }, + new GameMenuItem { Description = ResourceProvider.GetString("LOCExtra_Metadata_Loader_MenuItemDescriptionDownloadGoogleLogoSelectedGame"), MenuSection = $"Extra Metadata|{logosSection}", @@ -970,14 +1006,20 @@ public override void OnLibraryUpdated(OnLibraryUpdatedEventArgs args) break; } + var logoDownloaded = false; foreach (var logoProvider in _logoProviders) { - var logoDownloaded = GetGameLogo(logoProvider, game, true, settings.Settings.LibUpdateSelectLogosAutomatically, a.CancelToken); + logoDownloaded = GetGameLogo(logoProvider, game, true, settings.Settings.LibUpdateSelectLogosAutomatically, a.CancelToken); if (logoDownloaded) { break; } } + + if (!logoDownloaded && settings.Settings.UseLaunchBoxForAutomaticLogoDownloads) + { + GetGameLogo(_launchBoxLogoProvider, game, true, settings.Settings.LibUpdateSelectLogosAutomatically, a.CancelToken); + } }; }, progressOptions); } @@ -1121,4 +1163,4 @@ public override UserControl GetSettingsView(bool firstRunSettings) return new ExtraMetadataLoaderSettingsView(); } } -} \ No newline at end of file +} diff --git a/source/Generic/ExtraMetadataLoader/ExtraMetadataLoader.csproj b/source/Generic/ExtraMetadataLoader/ExtraMetadataLoader.csproj index 4e462b0835..74dcb012d7 100644 --- a/source/Generic/ExtraMetadataLoader/ExtraMetadataLoader.csproj +++ b/source/Generic/ExtraMetadataLoader/ExtraMetadataLoader.csproj @@ -84,6 +84,8 @@ + + ..\..\packages\System.Diagnostics.DiagnosticSource.6.0.0\lib\net461\System.Diagnostics.DiagnosticSource.dll @@ -251,6 +253,9 @@ + + + @@ -334,4 +339,4 @@ xcopy "$(ProjectDir)Localization\*.xaml" "$(TargetDir)\Localization" /Y /I /E - \ No newline at end of file + diff --git a/source/Generic/ExtraMetadataLoader/ExtraMetadataLoaderSettings.cs b/source/Generic/ExtraMetadataLoader/ExtraMetadataLoaderSettings.cs index 5823dd169a..ca8cf60475 100644 --- a/source/Generic/ExtraMetadataLoader/ExtraMetadataLoaderSettings.cs +++ b/source/Generic/ExtraMetadataLoader/ExtraMetadataLoaderSettings.cs @@ -434,6 +434,18 @@ public bool LibUpdateSelectLogosAutomatically } } + [DontSerialize] + private bool useLaunchBoxForAutomaticLogoDownloads = false; + public bool UseLaunchBoxForAutomaticLogoDownloads + { + get => useLaunchBoxForAutomaticLogoDownloads; + set + { + useLaunchBoxForAutomaticLogoDownloads = value; + OnPropertyChanged(); + } + } + public DateTime LastAutoLibUpdateAssetsDownload = DateTime.MinValue; [DontSerialize] @@ -887,4 +899,4 @@ public void LoginToYoutube() webView.Dispose(); } } -} \ No newline at end of file +} diff --git a/source/Generic/ExtraMetadataLoader/ExtraMetadataLoaderSettingsView.xaml b/source/Generic/ExtraMetadataLoader/ExtraMetadataLoaderSettingsView.xaml index 9d7d96526f..7d6e8ade97 100644 --- a/source/Generic/ExtraMetadataLoader/ExtraMetadataLoaderSettingsView.xaml +++ b/source/Generic/ExtraMetadataLoader/ExtraMetadataLoaderSettingsView.xaml @@ -184,6 +184,8 @@ + - \ No newline at end of file + diff --git a/source/Generic/ExtraMetadataLoader/Localization/en_US.xaml b/source/Generic/ExtraMetadataLoader/Localization/en_US.xaml index 6f2b6b72d6..669d31cb94 100644 --- a/source/Generic/ExtraMetadataLoader/Localization/en_US.xaml +++ b/source/Generic/ExtraMetadataLoader/Localization/en_US.xaml @@ -49,6 +49,7 @@ Download Micro videos of newly added games on library update Download logos of newly added games on library update Select logos to download automatically + Use LaunchBox for automatic logo downloads Video Left @@ -66,6 +67,7 @@ Downloading videos from Steam... Downloading Micro videos from Steam... Downloading logos from Steam... + Downloading logos from LaunchBox... Downloading logos from SteamGridDB... Downloading video from YouTube... Failed to download video from YouTube. @@ -87,6 +89,7 @@ Please verify that your yt-dlp installation is up to date. Download videos from Steam for selected games Download Micro videos from Steam for selected games Download logos from Steam for selected games + Download logos from LaunchBox for selected games Download logos from SteamGridDB for selected games Open Extra Metadata directory Delete logos of selected games @@ -161,4 +164,4 @@ Failed to get video information: {0}, {1}, {2}. Failed to process video in ffmpeg: {0}, {1}, {2} Extra Metadata Loader Error during download of video from YouTube: {0}, {1}, {2} - \ No newline at end of file + diff --git a/source/Generic/ExtraMetadataLoader/LogoProviders/LaunchBox/LaunchBoxClearLogoProvider.cs b/source/Generic/ExtraMetadataLoader/LogoProviders/LaunchBox/LaunchBoxClearLogoProvider.cs new file mode 100644 index 0000000000..13bb553eb9 --- /dev/null +++ b/source/Generic/ExtraMetadataLoader/LogoProviders/LaunchBox/LaunchBoxClearLogoProvider.cs @@ -0,0 +1,640 @@ +using ExtraMetadataLoader.Interfaces; +using Playnite.SDK; +using Playnite.SDK.Models; +using PluginsCommon; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading; + +namespace ExtraMetadataLoader.LogoProviders.LaunchBox +{ + internal class LaunchBoxClearLogoProvider : ILogoProvider + { + // Metadata.zip stores image filenames; LaunchBox currently serves those files from this CDN host. + private const string ImageBaseUrl = "https://images.launchbox-app.com/"; + private const int MaxCandidateResults = 25; + private const int AutomaticMinimumScore = 110; + private const int AutomaticMinimumScoreGap = 10; + private static readonly TimeSpan ImageProbeTimeout = TimeSpan.FromSeconds(15); + private readonly IPlayniteAPI _playniteApi; + private readonly ILogger _logger; + private readonly LaunchBoxMetadataCache _metadataCache; + + public string Id => "launchBoxProvider"; + + public LaunchBoxClearLogoProvider( + IPlayniteAPI playniteApi, + ILogger logger, + LaunchBoxMetadataCache metadataCache) + { + _playniteApi = playniteApi; + _logger = logger; + _metadataCache = metadataCache; + } + + public string GetLogoUrl(Game game, bool isBackgroundDownload, CancellationToken cancelToken = default) + { + if (cancelToken.IsCancellationRequested) + { + return null; + } + + var index = _metadataCache.GetIndex(cancelToken); + if (index?.Games == null || index.Games.Count == 0) + { + _logger.Debug("LaunchBox clear-logo lookup skipped because no metadata index is available."); + return null; + } + + var matches = GetMatches(game, index.Games, game.Name).Take(MaxCandidateResults).ToList(); + if (matches.Count == 0) + { + _logger.Debug($"LaunchBox clear-logo lookup returned no candidates for '{game.Name}'."); + return null; + } + + LaunchBoxMatch selectedMatch; + if (isBackgroundDownload) + { + selectedMatch = GetAutomaticMatch(game, matches); + } + else + { + selectedMatch = GetManualMatch(game, index, matches); + } + + if (selectedMatch == null) + { + return null; + } + + var logoUrl = isBackgroundDownload + ? GetBestLogoUrl(selectedMatch.Game, cancelToken) + : GetLogoUrlFromSelection(game, selectedMatch.Game, cancelToken); + + if (logoUrl.IsNullOrWhiteSpace()) + { + _logger.Debug($"LaunchBox clear-logo lookup found no usable image URL for '{game.Name}' from '{FormatMatchForLog(selectedMatch)}'."); + return null; + } + + _logger.Debug($"LaunchBox clear-logo URL selected for '{game.Name}' from '{FormatMatchForLog(selectedMatch)}'."); + return logoUrl; + } + + private LaunchBoxMatch GetAutomaticMatch(Game game, List matches) + { + var bestMatch = matches.FirstOrDefault(); + if (bestMatch == null) + { + return null; + } + + var runnerUp = matches.Skip(1).FirstOrDefault(); + var scoreGap = runnerUp == null ? int.MaxValue : bestMatch.Score - runnerUp.Score; + if ((bestMatch.Score >= AutomaticMinimumScore || bestMatch.NameScore >= 95) && + scoreGap >= AutomaticMinimumScoreGap) + { + return bestMatch; + } + + _logger.Debug($"LaunchBox automatic logo lookup skipped ambiguous match for '{game.Name}'. Top score: {bestMatch.Score}. Runner-up score: {runnerUp?.Score.ToString(CultureInfo.InvariantCulture) ?? "none"}."); + return null; + } + + private LaunchBoxMatch GetManualMatch(Game game, LaunchBoxMetadataIndex index, List matches) + { + var bestMatch = matches.First(); + var runnerUp = matches.Skip(1).FirstOrDefault(); + if (runnerUp == null || bestMatch.Score - runnerUp.Score >= AutomaticMinimumScoreGap && bestMatch.NameScore >= 95) + { + return bestMatch; + } + + var selectedResult = _playniteApi.Dialogs.ChooseItemWithSearch( + ToGenericItemOptions(matches), + searchTerm => ToGenericItemOptions(GetMatches(game, index.Games, searchTerm).Take(MaxCandidateResults)), + game.Name.NormalizeGameName(), + ResourceProvider.GetString("LOCExtra_Metadata_Loader_DialogCaptionSelectGame")); + + if (selectedResult == null || selectedResult.Description.IsNullOrWhiteSpace()) + { + return null; + } + + var selectedMatch = GetMatches(game, index.Games, selectedResult.Name).FirstOrDefault(x => x.Game.DatabaseId == selectedResult.Description); + if (selectedMatch != null) + { + return selectedMatch; + } + + var selectedGame = index.Games.FirstOrDefault(x => x.DatabaseId == selectedResult.Description); + if (selectedGame == null) + { + return null; + } + + return new LaunchBoxMatch + { + Game = selectedGame, + Score = 0, + NameScore = 0, + MatchReason = "manual selection" + }; + } + + private string GetBestLogoUrl(LaunchBoxGameEntry game, CancellationToken cancelToken) + { + foreach (var logo in GetOrderedLogos(game)) + { + var url = GetImageUrl(logo.FileName); + if (IsImageUrlAvailable(url, logo.FileName, cancelToken)) + { + return url; + } + } + + return null; + } + + private string GetLogoUrlFromSelection(Game playniteGame, LaunchBoxGameEntry launchBoxGame, CancellationToken cancelToken) + { + var logos = GetOrderedLogos(launchBoxGame).ToList(); + if (logos.Count == 0) + { + return null; + } + + if (logos.Count == 1) + { + var singleUrl = GetImageUrl(logos[0].FileName); + return IsImageUrlAvailable(singleUrl, logos[0].FileName, cancelToken) ? singleUrl : null; + } + + var imageFileOptions = new List(); + foreach (var logo in logos) + { + var url = GetImageUrl(logo.FileName); + if (IsImageUrlAvailable(url, logo.FileName, cancelToken)) + { + imageFileOptions.Add(new ImageFileOption + { + Path = url + }); + } + } + + if (imageFileOptions.Count == 0) + { + return null; + } + + var selectedOption = _playniteApi.Dialogs.ChooseImageFile( + imageFileOptions, + string.Format(ResourceProvider.GetString("LOCExtra_Metadata_Loader_DialogCaptionSelectLogo"), playniteGame.Name)); + + return selectedOption?.Path; + } + + private IEnumerable GetMatches(Game game, IEnumerable games, string searchTerm) + { + if (searchTerm.IsNullOrWhiteSpace()) + { + searchTerm = game.Name; + } + + var gamePlatforms = GetGamePlatforms(game); + var gameReleaseYear = GetGameReleaseYear(game); + var developers = GetCompanyNames(game.Developers); + var publishers = GetCompanyNames(game.Publishers); + + return games + .Select(x => ScoreMatch(searchTerm, gamePlatforms, gameReleaseYear, developers, publishers, x)) + .Where(x => x != null) + .GroupBy(x => x.Game.DatabaseId) + .Select(x => x.OrderByDescending(y => y.Score).First()) + .OrderByDescending(x => x.Score) + .ThenByDescending(x => x.NameScore) + .ThenBy(x => x.Game.Name) + .ThenBy(x => x.Game.Platform); + } + + private LaunchBoxMatch ScoreMatch( + string searchTerm, + List gamePlatforms, + int? gameReleaseYear, + List developers, + List publishers, + LaunchBoxGameEntry launchBoxGame) + { + string matchReason; + var nameScore = GetNameScore(searchTerm, launchBoxGame, out matchReason); + if (nameScore < 70) + { + return null; + } + + var score = nameScore; + score += GetPlatformScore(gamePlatforms, launchBoxGame.Platform); + score += GetReleaseYearScore(gameReleaseYear, launchBoxGame.ReleaseYear); + score += GetCompanyScore(developers, launchBoxGame.Developer); + score += GetCompanyScore(publishers, launchBoxGame.Publisher); + + return new LaunchBoxMatch + { + Game = launchBoxGame, + Score = score, + NameScore = nameScore, + MatchReason = matchReason + }; + } + + private int GetNameScore(string searchTerm, LaunchBoxGameEntry launchBoxGame, out string matchReason) + { + matchReason = null; + var searchName = searchTerm.NormalizeGameName(); + var searchKey = NormalizeForMatch(searchTerm); + if (searchKey.IsNullOrWhiteSpace()) + { + return 0; + } + + var launchBoxName = launchBoxGame.Name.NormalizeGameName(); + var launchBoxKey = NormalizeForMatch(launchBoxGame.Name); + if (searchKey == launchBoxKey) + { + matchReason = "exact title"; + return 100; + } + + foreach (var alternateName in launchBoxGame.AlternateNames ?? new List()) + { + if (searchKey == NormalizeForMatch(alternateName)) + { + matchReason = "exact alternate title"; + return 98; + } + } + + if (searchKey.Length > 3 && launchBoxKey.Contains(searchKey)) + { + matchReason = "contained title"; + return 84; + } + + if (launchBoxKey.Length > 3 && searchKey.Contains(launchBoxKey)) + { + matchReason = "contained title"; + return 82; + } + + if (searchName.MatchesAllWords(launchBoxName) || launchBoxName.MatchesAllWords(searchName)) + { + matchReason = "word match"; + return 78; + } + + var similarity = searchName.GetJaroWinklerSimilarityIgnoreCase(launchBoxName); + if (similarity >= 0.94) + { + matchReason = "very similar title"; + return 88; + } + + if (similarity >= 0.88) + { + matchReason = "similar title"; + return 72; + } + + return 0; + } + + private static List GetGamePlatforms(Game game) + { + if (game.Platforms == null || game.Platforms.Count == 0) + { + return new List(); + } + + return game.Platforms + .Where(x => !x.Name.IsNullOrWhiteSpace()) + .Select(x => NormalizePlatformName(x.Name)) + .Distinct() + .ToList(); + } + + private static int? GetGameReleaseYear(Game game) + { + if (!game.ReleaseDate.HasValue) + { + return null; + } + + var year = game.ReleaseDate.Value.Year; + return year > 0 ? year : (int?)null; + } + + private static List GetCompanyNames(IEnumerable companies) + { + if (companies == null) + { + return new List(); + } + + return companies + .Where(x => !x.Name.IsNullOrWhiteSpace()) + .Select(x => NormalizeForMatch(x.Name)) + .Distinct() + .ToList(); + } + + private static int GetPlatformScore(List gamePlatforms, string launchBoxPlatform) + { + if (gamePlatforms.Count == 0 || launchBoxPlatform.IsNullOrWhiteSpace()) + { + return 0; + } + + var normalizedLaunchBoxPlatform = NormalizePlatformName(launchBoxPlatform); + if (gamePlatforms.Contains(normalizedLaunchBoxPlatform)) + { + return 18; + } + + if (gamePlatforms.Any(x => x.Contains(normalizedLaunchBoxPlatform) || normalizedLaunchBoxPlatform.Contains(x))) + { + return 8; + } + + return -8; + } + + private static int GetReleaseYearScore(int? playniteYear, int? launchBoxYear) + { + if (!playniteYear.HasValue || !launchBoxYear.HasValue) + { + return 0; + } + + var difference = Math.Abs(playniteYear.Value - launchBoxYear.Value); + if (difference == 0) + { + return 8; + } + + if (difference == 1) + { + return 4; + } + + return -3; + } + + private static int GetCompanyScore(List playniteCompanies, string launchBoxCompany) + { + if (playniteCompanies.Count == 0 || launchBoxCompany.IsNullOrWhiteSpace()) + { + return 0; + } + + var normalizedLaunchBoxCompany = NormalizeForMatch(launchBoxCompany); + foreach (var company in playniteCompanies) + { + if (company == normalizedLaunchBoxCompany) + { + return 4; + } + + if (normalizedLaunchBoxCompany.Contains(company) || company.Contains(normalizedLaunchBoxCompany)) + { + return 2; + } + } + + return 0; + } + + private static string NormalizeForMatch(string value) + { + return value?.NormalizeGameName().Satinize() ?? string.Empty; + } + + private static string NormalizePlatformName(string platformName) + { + var normalized = NormalizeForMatch(platformName); + switch (normalized) + { + case "pc": + case "pcwindows": + case "windows": + case "microsoftwindows": + case "ibmpccompatible": + return "windows"; + case "mac": + case "macintosh": + case "macos": + case "applemacos": + return "macos"; + case "sonyplaystation": + case "playstation": + case "ps1": + case "psx": + return "playstation"; + case "sonyplaystation2": + case "playstation2": + case "ps2": + return "playstation2"; + case "sonyplaystation3": + case "playstation3": + case "ps3": + return "playstation3"; + case "sonyplaystation4": + case "playstation4": + case "ps4": + return "playstation4"; + case "sonyplaystation5": + case "playstation5": + case "ps5": + return "playstation5"; + case "microsoftxbox": + case "xbox": + return "xbox"; + case "microsoftxbox360": + case "xbox360": + return "xbox360"; + case "microsoftxboxone": + case "xboxone": + return "xboxone"; + case "microsoftxboxseriesxs": + case "xboxseriesxs": + case "xboxseriesx": + return "xboxseriesxs"; + case "nintendones": + case "nintendoentertainmentsystem": + return "nintendoentertainmentsystem"; + case "supernintendo": + case "supernintendoentertainmentsystem": + case "snes": + return "supernintendoentertainmentsystem"; + case "nintendo64": + case "n64": + return "nintendo64"; + case "nintendogamecube": + case "gamecube": + return "nintendogamecube"; + case "nintendowii": + case "wii": + return "nintendowii"; + case "nintendowiiu": + case "wiiu": + return "nintendowiiu"; + case "nintendoswitch": + case "switch": + return "nintendoswitch"; + case "segagenesis": + case "segamegadrive": + case "megadrive": + case "genesis": + return "segagenesis"; + default: + return normalized; + } + } + + private static IEnumerable GetOrderedLogos(LaunchBoxGameEntry game) + { + return (game.Logos ?? new List()) + .Where(x => LaunchBoxMetadataCache.IsValidImageFileName(x.FileName)) + .GroupBy(x => x.FileName, StringComparer.OrdinalIgnoreCase) + .Select(x => x.First()) + .OrderByDescending(GetRegionScore) + .ThenByDescending(GetLogoArea) + .ThenBy(x => x.FileName); + } + + private static int GetRegionScore(LaunchBoxLogoEntry logo) + { + if (logo.Region.IsNullOrWhiteSpace() || logo.Region.Equals("World", StringComparison.OrdinalIgnoreCase)) + { + return 2; + } + + return 1; + } + + private static long GetLogoArea(LaunchBoxLogoEntry logo) + { + if (logo.Width.HasValue && logo.Height.HasValue) + { + return (long)logo.Width.Value * logo.Height.Value; + } + + return 0; + } + + private static string GetImageUrl(string fileName) + { + return ImageBaseUrl + Uri.EscapeDataString(fileName); + } + + private bool IsImageUrlAvailable(string url, string fileName, CancellationToken cancelToken) + { + try + { + using (var handler = new HttpClientHandler { AllowAutoRedirect = true, AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate }) + using (var client = new HttpClient(handler)) + { + client.Timeout = ImageProbeTimeout; + var response = SendImageProbe(client, HttpMethod.Head, url, cancelToken); + if (response.StatusCode == HttpStatusCode.MethodNotAllowed || response.StatusCode == HttpStatusCode.NotImplemented) + { + response.Dispose(); + response = SendImageProbe(client, HttpMethod.Get, url, cancelToken); + } + + using (response) + { + if (response.StatusCode == (HttpStatusCode)429) + { + _logger.Debug($"LaunchBox image request was rate limited for '{fileName}'."); + return false; + } + + if (!response.IsSuccessStatusCode) + { + _logger.Debug($"LaunchBox image URL failed for '{fileName}' with HTTP {(int)response.StatusCode} {response.ReasonPhrase}."); + return false; + } + + var contentType = response.Content.Headers.ContentType?.MediaType; + if (contentType.IsNullOrWhiteSpace()) + { + return LaunchBoxMetadataCache.IsValidImageFileName(fileName); + } + + if (contentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + _logger.Debug($"LaunchBox image URL skipped for '{fileName}' because content type '{contentType}' is not an image."); + return false; + } + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.Debug($"LaunchBox image URL validation failed for '{fileName}'. Error: {ex.Message}"); + return false; + } + } + + private static HttpResponseMessage SendImageProbe(HttpClient client, HttpMethod method, string url, CancellationToken cancelToken) + { + using (var request = new HttpRequestMessage(method, url)) + { + request.Headers.TryAddWithoutValidation("User-Agent", "ExtraMetadataLoader LaunchBoxProvider"); + request.Headers.TryAddWithoutValidation("Accept", "image/*,*/*;q=0.8"); + return client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancelToken).ConfigureAwait(false).GetAwaiter().GetResult(); + } + } + + private static List ToGenericItemOptions(IEnumerable matches) + { + return matches + .Select(x => new GenericItemOption(FormatMatchForSelection(x), x.Game.DatabaseId)) + .ToList(); + } + + private static string FormatMatchForSelection(LaunchBoxMatch match) + { + var parts = new List(); + if (!match.Game.Platform.IsNullOrWhiteSpace()) + { + parts.Add(match.Game.Platform); + } + + if (match.Game.ReleaseYear.HasValue) + { + parts.Add(match.Game.ReleaseYear.Value.ToString(CultureInfo.InvariantCulture)); + } + + var suffix = parts.Count > 0 ? $" ({string.Join(", ", parts)})" : string.Empty; + return $"{match.Game.Name}{suffix}"; + } + + private static string FormatMatchForLog(LaunchBoxMatch match) + { + return $"{FormatMatchForSelection(match)} [{match.MatchReason}, score {match.Score}, id {match.Game.DatabaseId}]"; + } + } +} diff --git a/source/Generic/ExtraMetadataLoader/LogoProviders/LaunchBox/LaunchBoxMetadataCache.cs b/source/Generic/ExtraMetadataLoader/LogoProviders/LaunchBox/LaunchBoxMetadataCache.cs new file mode 100644 index 0000000000..75b114121c --- /dev/null +++ b/source/Generic/ExtraMetadataLoader/LogoProviders/LaunchBox/LaunchBoxMetadataCache.cs @@ -0,0 +1,597 @@ +using Playnite.SDK; +using Playnite.SDK.Data; +using PluginsCommon; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Xml; + +namespace ExtraMetadataLoader.LogoProviders.LaunchBox +{ + internal class LaunchBoxMetadataCache + { + internal const string MetadataUrl = "https://gamesdb.launchbox-app.com/Metadata.zip"; + + private const int CacheSchemaVersion = 2; + private const string MetadataZipFileName = "Metadata.zip"; + private const string IndexFileName = "clearLogoIndex.json"; + private const string StateFileName = "clearLogoCacheState.json"; + private static readonly TimeSpan CacheCheckInterval = TimeSpan.FromDays(1); + private static readonly TimeSpan DownloadTimeout = TimeSpan.FromMinutes(10); + private readonly string _cacheDirectory; + private readonly string _metadataZipPath; + private readonly string _indexPath; + private readonly string _statePath; + private readonly ILogger _logger; + private readonly object _lock = new object(); + private LaunchBoxMetadataIndex _loadedIndex; + + public LaunchBoxMetadataCache(string cacheDirectory, ILogger logger) + { + _cacheDirectory = cacheDirectory; + _metadataZipPath = Path.Combine(cacheDirectory, MetadataZipFileName); + _indexPath = Path.Combine(cacheDirectory, IndexFileName); + _statePath = Path.Combine(cacheDirectory, StateFileName); + _logger = logger; + } + + public LaunchBoxMetadataIndex GetIndex(CancellationToken cancelToken) + { + lock (_lock) + { + cancelToken.ThrowIfCancellationRequested(); + if (_loadedIndex != null) + { + return _loadedIndex; + } + + FileSystem.CreateDirectory(_cacheDirectory); + var state = LoadState(); + if (ShouldRefresh(state)) + { + var refreshed = RefreshMetadata(state, cancelToken); + if (refreshed) + { + _loadedIndex = ParseAndSaveIndex(cancelToken); + } + } + + if (_loadedIndex == null) + { + _loadedIndex = LoadIndex(); + } + + if (_loadedIndex == null && FileSystem.FileExists(_metadataZipPath)) + { + _loadedIndex = ParseAndSaveIndex(cancelToken); + } + + if (_loadedIndex == null) + { + _logger.Debug("LaunchBox metadata cache is unavailable. No cached index could be loaded."); + _loadedIndex = CreateEmptyIndex(); + } + + return _loadedIndex; + } + } + + private bool ShouldRefresh(LaunchBoxMetadataCacheState state) + { + if (!FileSystem.FileExists(_indexPath) && !FileSystem.FileExists(_metadataZipPath)) + { + return true; + } + + if (state.SchemaVersion != CacheSchemaVersion) + { + return true; + } + + if (state.LastCheckedUtc == DateTime.MinValue) + { + return true; + } + + return DateTime.UtcNow - state.LastCheckedUtc > CacheCheckInterval; + } + + private bool RefreshMetadata(LaunchBoxMetadataCacheState state, CancellationToken cancelToken) + { + try + { + _logger.Debug("Checking LaunchBox metadata cache."); + using (var handler = new HttpClientHandler { AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate }) + using (var client = new HttpClient(handler)) + using (var request = new HttpRequestMessage(HttpMethod.Get, MetadataUrl)) + { + client.Timeout = DownloadTimeout; + request.Headers.TryAddWithoutValidation("User-Agent", "ExtraMetadataLoader LaunchBoxProvider"); + request.Headers.TryAddWithoutValidation("Accept", "application/zip, application/octet-stream"); + if (!state.MetadataETag.IsNullOrWhiteSpace()) + { + request.Headers.TryAddWithoutValidation("If-None-Match", state.MetadataETag); + } + + if (!state.MetadataLastModified.IsNullOrWhiteSpace()) + { + request.Headers.TryAddWithoutValidation("If-Modified-Since", state.MetadataLastModified); + } + + using (var response = client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancelToken).ConfigureAwait(false).GetAwaiter().GetResult()) + { + state.SchemaVersion = CacheSchemaVersion; + state.LastCheckedUtc = DateTime.UtcNow; + + if (response.StatusCode == HttpStatusCode.NotModified) + { + SaveState(state); + _logger.Debug("LaunchBox metadata cache is current."); + return false; + } + + if (response.StatusCode == (HttpStatusCode)429) + { + SaveState(state); + _logger.Debug("LaunchBox metadata request was rate limited. Existing cache will be used if available."); + return false; + } + + if (!response.IsSuccessStatusCode) + { + SaveState(state); + _logger.Debug($"LaunchBox metadata download failed with HTTP {(int)response.StatusCode} {response.ReasonPhrase}."); + return false; + } + + var tempPath = _metadataZipPath + ".tmp"; + FileSystem.DeleteFile(tempPath, true); + using (var responseStream = response.Content.ReadAsStreamAsync().ConfigureAwait(false).GetAwaiter().GetResult()) + using (var fileStream = new FileStream(FileSystem.FixPathLength(tempPath), FileMode.Create, FileAccess.Write, FileShare.None)) + { + CopyStream(responseStream, fileStream, cancelToken); + } + + FileSystem.DeleteFile(_metadataZipPath, true); + File.Move(FileSystem.FixPathLength(tempPath), FileSystem.FixPathLength(_metadataZipPath)); + + state.MetadataETag = response.Headers.ETag?.ToString(); + state.MetadataLastModified = response.Content.Headers.LastModified?.ToString("R", CultureInfo.InvariantCulture); + state.MetadataContentLength = response.Content.Headers.ContentLength; + state.MetadataDownloadedUtc = DateTime.UtcNow; + state.SourceTimestampUtc = response.Content.Headers.LastModified?.UtcDateTime ?? state.MetadataDownloadedUtc; + SaveState(state); + _logger.Debug("LaunchBox metadata cache downloaded successfully."); + return true; + } + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.Debug($"LaunchBox metadata refresh failed. Existing cache will be used if available. Error: {ex.Message}"); + return false; + } + } + + private LaunchBoxMetadataIndex LoadIndex() + { + if (!FileSystem.FileExists(_indexPath)) + { + return null; + } + + try + { + var json = File.ReadAllText(FileSystem.FixPathLength(_indexPath), Encoding.UTF8); + var index = Serialization.FromJson(json); + if (index?.SchemaVersion != CacheSchemaVersion || index.Games == null) + { + _logger.Debug("LaunchBox metadata index has an unsupported schema version and will be rebuilt."); + return null; + } + + return index; + } + catch (Exception ex) + { + _logger.Debug($"LaunchBox metadata index could not be loaded and will be rebuilt if possible. Error: {ex.Message}"); + return null; + } + } + + private LaunchBoxMetadataCacheState LoadState() + { + if (!FileSystem.FileExists(_statePath)) + { + return new LaunchBoxMetadataCacheState(); + } + + try + { + var json = File.ReadAllText(FileSystem.FixPathLength(_statePath), Encoding.UTF8); + return Serialization.FromJson(json) ?? new LaunchBoxMetadataCacheState(); + } + catch (Exception ex) + { + _logger.Debug($"LaunchBox metadata cache state could not be loaded. Error: {ex.Message}"); + return new LaunchBoxMetadataCacheState(); + } + } + + private void SaveState(LaunchBoxMetadataCacheState state) + { + FileSystem.WriteStringToFile(_statePath, Serialization.ToJson(state), true); + } + + private LaunchBoxMetadataIndex ParseAndSaveIndex(CancellationToken cancelToken) + { + try + { + var index = ParseMetadataZip(cancelToken); + FileSystem.WriteStringToFile(_indexPath, Serialization.ToJson(index), true); + _logger.Debug($"LaunchBox clear-logo metadata index saved with {index.Games.Count} games."); + return index; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.Debug($"LaunchBox metadata cache could not be parsed. Error: {ex.Message}"); + return null; + } + } + + private LaunchBoxMetadataIndex ParseMetadataZip(CancellationToken cancelToken) + { + var games = new Dictionary(StringComparer.OrdinalIgnoreCase); + var alternateNames = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var logos = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + using (var archive = ZipFile.OpenRead(FileSystem.FixPathLength(_metadataZipPath))) + { + var metadataEntry = archive.Entries.FirstOrDefault(x => x.Name.Equals("Metadata.xml", StringComparison.OrdinalIgnoreCase)) ?? + archive.Entries.FirstOrDefault(x => x.Name.EndsWith(".xml", StringComparison.OrdinalIgnoreCase)); + if (metadataEntry == null) + { + throw new InvalidDataException("Metadata.zip did not contain an XML metadata file."); + } + + var xmlSettings = new XmlReaderSettings + { + DtdProcessing = DtdProcessing.Ignore, + IgnoreComments = true, + IgnoreWhitespace = true + }; + + using (var stream = metadataEntry.Open()) + using (var reader = XmlReader.Create(stream, xmlSettings)) + { + while (reader.Read()) + { + cancelToken.ThrowIfCancellationRequested(); + if (reader.NodeType != XmlNodeType.Element) + { + continue; + } + + if (reader.LocalName.Equals("Game", StringComparison.OrdinalIgnoreCase)) + { + using (var subtree = reader.ReadSubtree()) + { + var game = ReadGame(ReadElementValues(subtree)); + if (game != null && !games.ContainsKey(game.DatabaseId)) + { + games.Add(game.DatabaseId, game); + } + } + } + else if (reader.LocalName.Equals("GameAlternateName", StringComparison.OrdinalIgnoreCase)) + { + using (var subtree = reader.ReadSubtree()) + { + ReadAlternateName(ReadElementValues(subtree), alternateNames); + } + } + else if (reader.LocalName.Equals("GameImage", StringComparison.OrdinalIgnoreCase)) + { + using (var subtree = reader.ReadSubtree()) + { + ReadImage(ReadElementValues(subtree), logos); + } + } + } + } + } + + foreach (var alternateName in alternateNames) + { + LaunchBoxGameEntry game; + if (games.TryGetValue(alternateName.Key, out game)) + { + game.AlternateNames = alternateName.Value + .Where(x => !x.IsNullOrWhiteSpace()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(x => x) + .ToList(); + } + } + + foreach (var logoGroup in logos) + { + LaunchBoxGameEntry game; + if (games.TryGetValue(logoGroup.Key, out game)) + { + game.Logos = logoGroup.Value + .GroupBy(x => x.FileName, StringComparer.OrdinalIgnoreCase) + .Select(x => x.First()) + .OrderByDescending(GetLogoArea) + .ThenBy(x => x.FileName) + .ToList(); + } + } + + var index = new LaunchBoxMetadataIndex + { + SchemaVersion = CacheSchemaVersion, + CreatedUtc = DateTime.UtcNow, + Games = games.Values + .Where(x => x.Logos != null && x.Logos.Count > 0) + .OrderBy(x => x.Name) + .ThenBy(x => x.Platform) + .ToList() + }; + + _logger.Debug($"LaunchBox metadata parsed. Games with clear logos: {index.Games.Count}."); + return index; + } + + private Dictionary ReadElementValues(XmlReader reader) + { + var values = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (!reader.Read()) + { + return values; + } + + while (!reader.EOF) + { + if (reader.NodeType == XmlNodeType.EndElement && reader.Depth == 0) + { + break; + } + + if (reader.NodeType != XmlNodeType.Element) + { + if (!reader.Read()) + { + break; + } + + continue; + } + + if (reader.Depth != 1) + { + if (!reader.Read()) + { + break; + } + + continue; + } + + var key = reader.LocalName; + if (reader.IsEmptyElement) + { + values[key] = string.Empty; + if (!reader.Read()) + { + break; + } + + continue; + } + + try + { + values[key] = reader.ReadElementContentAsString(); + } + catch (XmlException ex) + { + _logger.Debug($"LaunchBox metadata XML element '{key}' could not be read. Error: {ex.Message}"); + } + } + + return values; + } + + private LaunchBoxGameEntry ReadGame(Dictionary values) + { + var databaseId = GetValue(values, "DatabaseID", "DatabaseId", "GameID", "GameId", "ID", "Id"); + var name = GetValue(values, "Name", "Title"); + if (databaseId.IsNullOrWhiteSpace() || name.IsNullOrWhiteSpace()) + { + return null; + } + + return new LaunchBoxGameEntry + { + DatabaseId = databaseId, + Name = name, + Platform = GetValue(values, "Platform", "PlatformName"), + ReleaseYear = GetReleaseYear(GetValue(values, "ReleaseDate", "ReleaseYear", "Year")), + Developer = GetValue(values, "Developer", "Developers"), + Publisher = GetValue(values, "Publisher", "Publishers") + }; + } + + private void ReadAlternateName(Dictionary values, Dictionary> alternateNames) + { + var databaseId = GetValue(values, "DatabaseID", "DatabaseId", "GameID", "GameId", "ID", "Id"); + var name = GetValue(values, "Name", "AlternateName", "Alternate Name", "Title"); + if (databaseId.IsNullOrWhiteSpace() || name.IsNullOrWhiteSpace()) + { + return; + } + + List names; + if (!alternateNames.TryGetValue(databaseId, out names)) + { + names = new List(); + alternateNames.Add(databaseId, names); + } + + names.Add(name); + } + + private void ReadImage(Dictionary values, Dictionary> logos) + { + var databaseId = GetValue(values, "DatabaseID", "DatabaseId", "GameID", "GameId", "ID", "Id"); + var imageType = GetValue(values, "Type", "ImageType", "Image Type"); + var fileName = GetValue(values, "FileName", "File Name", "Name"); + if (databaseId.IsNullOrWhiteSpace() || !IsClearLogoType(imageType) || !IsValidImageFileName(fileName)) + { + return; + } + + List gameLogos; + if (!logos.TryGetValue(databaseId, out gameLogos)) + { + gameLogos = new List(); + logos.Add(databaseId, gameLogos); + } + + gameLogos.Add(new LaunchBoxLogoEntry + { + FileName = fileName, + Region = GetValue(values, "Region"), + Width = GetNullableInt(GetValue(values, "Width")), + Height = GetNullableInt(GetValue(values, "Height")) + }); + } + + internal static bool IsValidImageFileName(string fileName) + { + if (fileName.IsNullOrWhiteSpace() || fileName.Contains("..") || fileName.Contains("/") || fileName.Contains("\\")) + { + return false; + } + + if (fileName.Any(char.IsControl)) + { + return false; + } + + var extension = Path.GetExtension(fileName); + return extension.Equals(".png", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".jpg", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".jpeg", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".gif", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".webp", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsClearLogoType(string imageType) + { + return imageType?.Satinize() == "clearlogo"; + } + + private static string GetValue(Dictionary values, params string[] keys) + { + foreach (var key in keys) + { + string value; + if (values.TryGetValue(key, out value)) + { + return value?.Trim(); + } + } + + return null; + } + + private static int? GetNullableInt(string value) + { + int parsedValue; + if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out parsedValue)) + { + return parsedValue; + } + + return null; + } + + private static int? GetReleaseYear(string releaseDate) + { + if (releaseDate.IsNullOrWhiteSpace()) + { + return null; + } + + int year; + if (int.TryParse(releaseDate, NumberStyles.Integer, CultureInfo.InvariantCulture, out year) && + year > 0) + { + return year; + } + + DateTime parsedDate; + if (DateTime.TryParse(releaseDate, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out parsedDate)) + { + return parsedDate.Year; + } + + var match = Regex.Match(releaseDate, @"\b(?\d{4})\b"); + if (match.Success && int.TryParse(match.Groups["year"].Value, out year)) + { + return year; + } + + return null; + } + + private static long GetLogoArea(LaunchBoxLogoEntry logo) + { + if (logo.Width.HasValue && logo.Height.HasValue) + { + return (long)logo.Width.Value * logo.Height.Value; + } + + return 0; + } + + private static void CopyStream(Stream source, Stream target, CancellationToken cancelToken) + { + var buffer = new byte[81920]; + int bytesRead; + while ((bytesRead = source.Read(buffer, 0, buffer.Length)) > 0) + { + cancelToken.ThrowIfCancellationRequested(); + target.Write(buffer, 0, bytesRead); + } + } + + private static LaunchBoxMetadataIndex CreateEmptyIndex() + { + return new LaunchBoxMetadataIndex + { + SchemaVersion = CacheSchemaVersion, + CreatedUtc = DateTime.UtcNow, + Games = new List() + }; + } + } +} diff --git a/source/Generic/ExtraMetadataLoader/LogoProviders/LaunchBox/LaunchBoxModels.cs b/source/Generic/ExtraMetadataLoader/LogoProviders/LaunchBox/LaunchBoxModels.cs new file mode 100644 index 0000000000..05a167c574 --- /dev/null +++ b/source/Generic/ExtraMetadataLoader/LogoProviders/LaunchBox/LaunchBoxModels.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; + +namespace ExtraMetadataLoader.LogoProviders.LaunchBox +{ + internal class LaunchBoxMetadataIndex + { + public int SchemaVersion { get; set; } + public DateTime CreatedUtc { get; set; } + public List Games { get; set; } = new List(); + } + + internal class LaunchBoxGameEntry + { + public string DatabaseId { get; set; } + public string Name { get; set; } + public string Platform { get; set; } + public int? ReleaseYear { get; set; } + public string Developer { get; set; } + public string Publisher { get; set; } + public List AlternateNames { get; set; } = new List(); + public List Logos { get; set; } = new List(); + } + + internal class LaunchBoxLogoEntry + { + public string FileName { get; set; } + public string Region { get; set; } + public int? Width { get; set; } + public int? Height { get; set; } + } + + internal class LaunchBoxMetadataCacheState + { + public int SchemaVersion { get; set; } + public DateTime LastCheckedUtc { get; set; } + public DateTime SourceTimestampUtc { get; set; } + public DateTime MetadataDownloadedUtc { get; set; } + public string MetadataETag { get; set; } + public string MetadataLastModified { get; set; } + public long? MetadataContentLength { get; set; } + } + + internal class LaunchBoxMatch + { + public LaunchBoxGameEntry Game { get; set; } + public int Score { get; set; } + public int NameScore { get; set; } + public string MatchReason { get; set; } + } +}