UI/Library/Thumbnails.cs
using System.Net;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
namespace sGBA;
public enum ThumbType
{
BoxArt,
Logo,
Snap,
Title
}
public static class Thumbnails
{
private const string SystemName = "Nintendo - Game Boy Advance";
private const string CacheRoot = "thumbnails";
private const string BaseUrl = "https://thumbnails.libretro.com/";
private static readonly Regex ImageLinkRegex = new( "href=\"([^\"]+\\.png)\"", RegexOptions.IgnoreCase | RegexOptions.Compiled );
private static readonly Dictionary<ThumbType, Task<Dictionary<string, string>>> IndexTasks = [];
private static readonly Dictionary<string, Task<string>> ExactPathTasks = [];
private static readonly Dictionary<string, Task<string>> FallbackPathTasks = [];
private static readonly Dictionary<string, Texture> LoadedTextures = [];
public static async Task<Texture> LoadAsync( GameEntry game, ThumbType type )
{
if ( game is null || string.IsNullOrWhiteSpace( game.NoIntroName ) )
return null;
var exactCachedTexture = TryLoadCachedTexture( game, type );
if ( exactCachedTexture != null )
return exactCachedTexture;
var exactPath = await GetExactPathAsync( game, type );
if ( !string.IsNullOrWhiteSpace( exactPath ) )
return LoadTexture( exactPath, game, type );
var fallbackPath = await GetFallbackPathAsync( game, type );
if ( string.IsNullOrWhiteSpace( fallbackPath ) )
return null;
return LoadTexture( fallbackPath, game, type );
}
public static Texture TryLoadCachedTexture( GameEntry game, ThumbType type )
{
if ( game is null || string.IsNullOrWhiteSpace( game.NoIntroName ) )
return null;
var cachePath = GetCachePath( type, GetExactFileName( game ) );
if ( !FileSystem.Data.FileExists( cachePath ) )
return null;
return LoadTexture( cachePath, game, type );
}
public static void ReleaseTexture( Texture texture )
{
if ( texture == null )
return;
foreach ( var (cachePath, loadedTexture) in LoadedTextures.ToList() )
{
if ( loadedTexture == texture )
LoadedTextures.Remove( cachePath );
}
texture.Dispose();
}
private static Task<string> GetExactPathAsync( GameEntry game, ThumbType type )
{
var key = $"exact:{type}:{game.NoIntroName}";
if ( ExactPathTasks.TryGetValue( key, out var task ) )
return task;
task = GetExactPathInternalAsync( game, type );
ExactPathTasks[key] = task;
return task;
}
private static async Task<string> GetExactPathInternalAsync( GameEntry game, ThumbType type )
{
var fileName = GetExactFileName( game );
var cachePath = GetCachePath( type, fileName );
try
{
if ( FileSystem.Data.FileExists( cachePath ) )
return cachePath;
var bytes = await Http.RequestBytesAsync( BuildUrl( type, fileName ) );
if ( !IsPng( bytes ) )
return null;
EnsureCacheDirectory( type );
FileSystem.Data.WriteAllBytes( cachePath, bytes );
return cachePath;
}
catch
{
return null;
}
}
private static Task<string> GetFallbackPathAsync( GameEntry game, ThumbType type )
{
var key = $"fallback:{type}:{game.NoIntroName}";
if ( FallbackPathTasks.TryGetValue( key, out var task ) )
return task;
task = GetFallbackPathInternalAsync( game, type );
FallbackPathTasks[key] = task;
return task;
}
private static async Task<string> GetFallbackPathInternalAsync( GameEntry game, ThumbType type )
{
try
{
var fileName = await FindIndexedFileNameAsync( game, type );
if ( string.IsNullOrWhiteSpace( fileName ) )
return null;
var cachePath = GetCachePath( type, fileName );
if ( !FileSystem.Data.FileExists( cachePath ) )
{
var bytes = await Http.RequestBytesAsync( BuildUrl( type, fileName ) );
if ( !IsPng( bytes ) )
return null;
EnsureCacheDirectory( type );
FileSystem.Data.WriteAllBytes( cachePath, bytes );
}
return cachePath;
}
catch ( Exception ex )
{
Log.Warning( $"[sGBA] {type} thumbnail failed for {game.DisplayTitle}: {ex.Message}" );
return null;
}
}
private static Texture LoadTexture( string cachePath, GameEntry game, ThumbType type )
{
if ( LoadedTextures.TryGetValue( cachePath, out var cachedTexture ) )
return cachedTexture;
try
{
var texture = Texture.LoadFromFileSystem( cachePath, FileSystem.Data );
if ( texture == null )
return null;
LoadedTextures[cachePath] = texture;
return texture;
}
catch ( Exception ex )
{
Log.Warning( $"[sGBA] {type} texture load failed for {game.DisplayTitle}: {ex.Message}" );
return null;
}
}
private static async Task<string> FindIndexedFileNameAsync( GameEntry game, ThumbType type )
{
var index = await GetIndexAsync( type );
if ( index.Count == 0 )
return null;
var exactKey = NormalizeName( game.NoIntroName );
if ( index.TryGetValue( exactKey, out var exact ) )
return exact;
var titleKey = NormalizeName( game.DisplayTitle );
return index.Values
.Where( value => NormalizeName( StripReleaseTags( System.IO.Path.GetFileNameWithoutExtension( value ) ) ) == titleKey )
.OrderBy( value => ScoreFallbackMatch( value, game ) )
.FirstOrDefault();
}
private static Task<Dictionary<string, string>> GetIndexAsync( ThumbType type )
{
if ( IndexTasks.TryGetValue( type, out var task ) )
return task;
task = LoadIndexAsync( type );
IndexTasks[type] = task;
return task;
}
private static async Task<Dictionary<string, string>> LoadIndexAsync( ThumbType type )
{
var map = new Dictionary<string, string>( StringComparer.OrdinalIgnoreCase );
var html = await Http.RequestStringAsync( BuildDirectoryUrl( type ) );
foreach ( Match match in ImageLinkRegex.Matches( html ) )
{
var fileName = DecodeFileName( match.Groups[1].Value );
if ( string.IsNullOrWhiteSpace( fileName ) || !fileName.EndsWith( ".png", StringComparison.OrdinalIgnoreCase ) )
continue;
var key = NormalizeName( System.IO.Path.GetFileNameWithoutExtension( fileName ) );
if ( string.IsNullOrWhiteSpace( key ) || map.ContainsKey( key ) )
continue;
map[key] = fileName;
}
return map;
}
private static string BuildDirectoryUrl( ThumbType type )
{
return BaseUrl + Uri.EscapeDataString( SystemName ) + "/" + GetDirectoryName( type ) + "/";
}
private static string BuildUrl( ThumbType type, string fileName )
{
return BuildDirectoryUrl( type ) + Uri.EscapeDataString( fileName );
}
private static string GetExactFileName( GameEntry game )
{
return game.NoIntroName + ".png";
}
private static string GetCachePath( ThumbType type, string fileName )
{
return $"{CacheRoot}/{SystemName}/{GetDirectoryName( type )}/{fileName}";
}
private static void EnsureCacheDirectory( ThumbType type )
{
if ( !FileSystem.Data.DirectoryExists( CacheRoot ) )
FileSystem.Data.CreateDirectory( CacheRoot );
var systemPath = $"{CacheRoot}/{SystemName}";
if ( !FileSystem.Data.DirectoryExists( systemPath ) )
FileSystem.Data.CreateDirectory( systemPath );
var typePath = $"{systemPath}/{GetDirectoryName( type )}";
if ( !FileSystem.Data.DirectoryExists( typePath ) )
FileSystem.Data.CreateDirectory( typePath );
}
private static string GetDirectoryName( ThumbType type )
{
return type switch
{
ThumbType.BoxArt => "Named_Boxarts",
ThumbType.Logo => "Named_Logos",
ThumbType.Snap => "Named_Snaps",
ThumbType.Title => "Named_Titles",
_ => "Named_Boxarts"
};
}
private static bool IsPng( byte[] bytes )
{
return bytes is { Length: >= 8 }
&& bytes[0] == 0x89
&& bytes[1] == 0x50
&& bytes[2] == 0x4E
&& bytes[3] == 0x47
&& bytes[4] == 0x0D
&& bytes[5] == 0x0A
&& bytes[6] == 0x1A
&& bytes[7] == 0x0A;
}
private static string DecodeFileName( string value )
{
try
{
var decoded = WebUtility.HtmlDecode( value );
decoded = Uri.UnescapeDataString( decoded );
return decoded.Split( '/', StringSplitOptions.RemoveEmptyEntries ).LastOrDefault();
}
catch
{
return null;
}
}
private static string NormalizeName( string value )
{
if ( string.IsNullOrWhiteSpace( value ) )
return string.Empty;
var builder = new StringBuilder( value.Length );
var pendingSpace = false;
foreach ( var ch in WebUtility.HtmlDecode( value ).Trim() )
{
if ( char.IsWhiteSpace( ch ) || ch == '_' || ch == '&' )
{
pendingSpace = builder.Length > 0;
continue;
}
if ( pendingSpace )
{
builder.Append( ' ' );
pendingSpace = false;
}
builder.Append( char.ToLowerInvariant( ch ) );
}
return builder.ToString();
}
private static string StripReleaseTags( string value )
{
return Regex.Replace( value ?? string.Empty, "\\s*\\([^)]*\\)", string.Empty ).Trim();
}
private static int ScoreFallbackMatch( string fileName, GameEntry game )
{
var name = System.IO.Path.GetFileNameWithoutExtension( fileName );
var score = 0;
if ( !string.IsNullOrWhiteSpace( game.Region ) && name.Contains( $"({game.Region})", StringComparison.OrdinalIgnoreCase ) ) score -= 30;
if ( name.Contains( "(USA", StringComparison.OrdinalIgnoreCase ) ) score -= 20;
if ( name.Contains( "(World", StringComparison.OrdinalIgnoreCase ) ) score -= 15;
if ( name.Contains( "(Europe", StringComparison.OrdinalIgnoreCase ) ) score -= 10;
if ( name.Contains( "(Virtual Console", StringComparison.OrdinalIgnoreCase ) ) score += 20;
if ( name.Contains( "(Beta", StringComparison.OrdinalIgnoreCase ) ) score += 30;
if ( name.Contains( "(Demo", StringComparison.OrdinalIgnoreCase ) ) score += 30;
if ( name.Contains( "(Proto", StringComparison.OrdinalIgnoreCase ) ) score += 30;
return score;
}
}