UI/Library/LibretroDb.cs
using System.Text.RegularExpressions;
using System.Threading.Tasks;
namespace sGBA;
public sealed record GameInfo(
string Name,
string Region,
string Serial,
string Developer,
string Publisher,
string ReleaseYear,
string ReleaseMonth,
string Genre,
string Franchise,
string EsrbRating,
string MaxUsers
);
internal sealed class LibretroDb
{
private const string BaseUrl = "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/";
private const string PlatformFileName = "Nintendo%20-%20Game%20Boy%20Advance.dat";
private const string PlatformCacheFileName = "Nintendo - Game Boy Advance.dat";
private const string CacheRoot = "database";
private const string CacheDirectory = "database/metadat";
private static readonly Dictionary<string, Task<FieldIndex>> FieldTasks = [];
private static readonly Dictionary<string, Task<GameInfo>> LookupTasks = [];
private static Task<NoIntroIndex> NoIntroTask;
public static async Task WarmAsync()
{
var tasks = new List<Task>
{
GetNoIntroAsync(),
GetFieldIndexAsync( "developer", "developer", true ),
GetFieldIndexAsync( "publisher", "publisher", true ),
GetFieldIndexAsync( "releaseyear", "releaseyear", true ),
GetFieldIndexAsync( "releasemonth", "releasemonth", true ),
GetFieldIndexAsync( "genre", "genre", true ),
GetFieldIndexAsync( "franchise", "franchise", true ),
GetFieldIndexAsync( "esrb", "esrb_rating", true ),
GetFieldIndexAsync( "maxusers", "users", false )
};
foreach ( var task in tasks )
await task;
}
public static Task<GameInfo> FetchAsync( GameEntry game, GameHashes hashes = null )
{
if ( game is null )
return Task.FromResult<GameInfo>( null );
if ( LookupTasks.TryGetValue( game.Path, out var task ) )
return task;
task = FetchInternalAsync( game, hashes );
LookupTasks[game.Path] = task;
return task;
}
private static async Task<GameInfo> FetchInternalAsync( GameEntry game, GameHashes hashes )
{
var crc = NormalizeCrc( hashes?.Crc );
var noIntro = await GetNoIntroAsync();
var noIntroEntry = FindNoIntroEntry( noIntro, game, crc );
var developerTask = GetValueAsync( "developer", "developer", true, game, noIntroEntry, crc );
var publisherTask = GetValueAsync( "publisher", "publisher", true, game, noIntroEntry, crc );
var releaseYearTask = GetValueAsync( "releaseyear", "releaseyear", true, game, noIntroEntry, crc );
var releaseMonthTask = GetValueAsync( "releasemonth", "releasemonth", true, game, noIntroEntry, crc );
var genreTask = GetValueAsync( "genre", "genre", true, game, noIntroEntry, crc );
var franchiseTask = GetValueAsync( "franchise", "franchise", true, game, noIntroEntry, crc );
var esrbTask = GetValueAsync( "esrb", "esrb_rating", true, game, noIntroEntry, crc );
var maxUsersTask = GetValueAsync( "maxusers", "users", false, game, noIntroEntry, crc );
await developerTask;
await publisherTask;
await releaseYearTask;
await releaseMonthTask;
await genreTask;
await franchiseTask;
await esrbTask;
await maxUsersTask;
return new GameInfo(
Display( noIntroEntry?.Name, game.NoIntroName, game.DisplayTitle ),
Display( noIntroEntry?.Region, game.Region ),
Display( noIntroEntry?.Serial, game.GameCode ),
developerTask.Result,
publisherTask.Result,
releaseYearTask.Result,
releaseMonthTask.Result,
genreTask.Result,
franchiseTask.Result,
esrbTask.Result,
maxUsersTask.Result
);
}
private static async Task<string> GetValueAsync( string folder, string propertyName, bool quoted, GameEntry game, NoIntroEntry noIntroEntry, string crc )
{
var index = await GetFieldIndexAsync( folder, propertyName, quoted );
if ( !string.IsNullOrWhiteSpace( crc ) && index.ByCrc.TryGetValue( crc, out var value ) )
return value;
foreach ( var title in CandidateTitles( game, noIntroEntry ) )
{
var key = NormalizeTitle( title );
if ( !string.IsNullOrWhiteSpace( key ) && index.ByTitle.TryGetValue( key, out value ) )
return value;
}
return string.Empty;
}
private static Task<FieldIndex> GetFieldIndexAsync( string folder, string propertyName, bool quoted )
{
var key = $"{folder}:{propertyName}";
if ( FieldTasks.TryGetValue( key, out var task ) )
return task;
task = LoadFieldIndexAsync( folder, propertyName, quoted );
FieldTasks[key] = task;
return task;
}
private static async Task<FieldIndex> LoadFieldIndexAsync( string folder, string propertyName, bool quoted )
{
try
{
var text = await LoadDatAsync( folder );
return ParseFieldIndex( text, propertyName, quoted );
}
catch ( Exception ex )
{
Log.Warning( $"[sGBA] Libretro metadata {folder} failed: {ex.Message}" );
return new FieldIndex();
}
}
private static Task<NoIntroIndex> GetNoIntroAsync()
{
NoIntroTask ??= LoadNoIntroAsync();
return NoIntroTask;
}
private static async Task<NoIntroIndex> LoadNoIntroAsync()
{
try
{
var text = await LoadDatAsync( "no-intro" );
return ParseNoIntroIndex( text );
}
catch ( Exception ex )
{
Log.Warning( $"[sGBA] Libretro no-intro metadata failed: {ex.Message}" );
return new NoIntroIndex();
}
}
private static async Task<string> LoadDatAsync( string folder )
{
var cachePath = $"{CacheDirectory}/{folder}/{PlatformCacheFileName}";
try
{
if ( FileSystem.Data.FileExists( cachePath ) )
return FileSystem.Data.ReadAllText( cachePath );
}
catch { }
var text = await Http.RequestStringAsync( BaseUrl + folder + "/" + PlatformFileName );
try
{
if ( !FileSystem.Data.DirectoryExists( CacheRoot ) )
FileSystem.Data.CreateDirectory( CacheRoot );
if ( !FileSystem.Data.DirectoryExists( CacheDirectory ) )
FileSystem.Data.CreateDirectory( CacheDirectory );
var folderPath = $"{CacheDirectory}/{folder}";
if ( !FileSystem.Data.DirectoryExists( folderPath ) )
FileSystem.Data.CreateDirectory( folderPath );
FileSystem.Data.WriteAllText( cachePath, text );
}
catch ( Exception ex )
{
Log.Warning( $"[sGBA] Failed to cache Libretro metadata {folder}: {ex.Message}" );
}
return text;
}
private static FieldIndex ParseFieldIndex( string text, string propertyName, bool quoted )
{
var index = new FieldIndex();
foreach ( var block in EnumerateGameBlocks( text ) )
{
var value = quoted ? ExtractQuotedValue( block, propertyName ) : ExtractBareValue( block, propertyName );
if ( string.IsNullOrWhiteSpace( value ) )
continue;
var crc = ExtractCrc( block );
if ( !string.IsNullOrWhiteSpace( crc ) )
index.ByCrc[crc] = value;
var title = Display( ExtractQuotedValue( block, "comment" ), ExtractQuotedValue( block, "name" ) );
var titleKey = NormalizeTitle( title );
if ( !string.IsNullOrWhiteSpace( titleKey ) && !index.ByTitle.ContainsKey( titleKey ) )
index.ByTitle[titleKey] = value;
}
return index;
}
private static NoIntroIndex ParseNoIntroIndex( string text )
{
var index = new NoIntroIndex();
foreach ( var block in EnumerateGameBlocks( text ) )
{
var name = ExtractQuotedValue( block, "name" );
if ( string.IsNullOrWhiteSpace( name ) )
continue;
var entry = new NoIntroEntry(
name,
ExtractQuotedValue( block, "region" ),
ExtractQuotedValue( block, "serial" )
);
var crc = ExtractCrc( block );
if ( !string.IsNullOrWhiteSpace( crc ) )
index.ByCrc[crc] = entry;
var titleKey = NormalizeTitle( name );
if ( !string.IsNullOrWhiteSpace( titleKey ) && !index.ByTitle.ContainsKey( titleKey ) )
index.ByTitle[titleKey] = entry;
}
return index;
}
private static NoIntroEntry FindNoIntroEntry( NoIntroIndex index, GameEntry game, string crc )
{
if ( !string.IsNullOrWhiteSpace( crc ) && index.ByCrc.TryGetValue( crc, out var entry ) )
return entry;
foreach ( var title in CandidateTitles( game, null ) )
{
var key = NormalizeTitle( title );
if ( !string.IsNullOrWhiteSpace( key ) && index.ByTitle.TryGetValue( key, out entry ) )
return entry;
}
return null;
}
private static IEnumerable<string> EnumerateGameBlocks( string text )
{
if ( string.IsNullOrWhiteSpace( text ) )
yield break;
var cursor = 0;
while ( cursor < text.Length )
{
var gameStart = text.IndexOf( "game", cursor, StringComparison.Ordinal );
if ( gameStart < 0 )
yield break;
var openParen = text.IndexOf( '(', gameStart );
if ( openParen < 0 )
yield break;
var depth = 0;
var inQuote = false;
for ( var scan = openParen; scan < text.Length; scan++ )
{
if ( text[scan] == '"' )
{
inQuote = !inQuote;
continue;
}
if ( inQuote )
continue;
if ( text[scan] == '(' ) depth++;
else if ( text[scan] == ')' ) depth--;
if ( depth == 0 )
{
yield return text.Substring( openParen + 1, scan - openParen - 1 );
cursor = scan + 1;
break;
}
}
if ( cursor <= openParen )
yield break;
}
}
private static string ExtractQuotedValue( string block, string propertyName )
{
var match = Regex.Match( block, $"\\b{Regex.Escape( propertyName )}\\s+\"([^\"]*)\"", RegexOptions.IgnoreCase );
return match.Success ? match.Groups[1].Value.Trim() : string.Empty;
}
private static string ExtractBareValue( string block, string propertyName )
{
var match = Regex.Match( block, $"\\b{Regex.Escape( propertyName )}\\s+([^\\s\\)]+)", RegexOptions.IgnoreCase );
return match.Success ? match.Groups[1].Value.Trim() : string.Empty;
}
private static string ExtractCrc( string block )
{
var match = Regex.Match( block, "\\bcrc\\s+([0-9A-Fa-f]{8})", RegexOptions.IgnoreCase );
return match.Success ? NormalizeCrc( match.Groups[1].Value ) : string.Empty;
}
private static IEnumerable<string> CandidateTitles( GameEntry game, NoIntroEntry noIntroEntry )
{
if ( !string.IsNullOrWhiteSpace( noIntroEntry?.Name ) ) yield return noIntroEntry.Name;
if ( !string.IsNullOrWhiteSpace( game?.NoIntroName ) ) yield return game.NoIntroName;
if ( !string.IsNullOrWhiteSpace( game?.DisplayTitle ) ) yield return game.DisplayTitle;
}
private static string NormalizeTitle( string value )
{
if ( string.IsNullOrWhiteSpace( value ) )
return string.Empty;
return Regex.Replace( value.Trim().ToLowerInvariant(), "\\s+", " " );
}
private static string NormalizeCrc( string value )
{
return string.IsNullOrWhiteSpace( value ) ? string.Empty : value.Trim().ToUpperInvariant();
}
private static string Display( params string[] values )
{
foreach ( var value in values )
{
if ( !string.IsNullOrWhiteSpace( value ) )
return value;
}
return string.Empty;
}
private sealed class FieldIndex
{
public Dictionary<string, string> ByCrc { get; } = new( StringComparer.OrdinalIgnoreCase );
public Dictionary<string, string> ByTitle { get; } = new( StringComparer.OrdinalIgnoreCase );
}
private sealed class NoIntroIndex
{
public Dictionary<string, NoIntroEntry> ByCrc { get; } = new( StringComparer.OrdinalIgnoreCase );
public Dictionary<string, NoIntroEntry> ByTitle { get; } = new( StringComparer.OrdinalIgnoreCase );
}
private sealed record NoIntroEntry( string Name, string Region, string Serial );
}