2023-05-07 11:10:59 +00:00
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System ;
using System.Diagnostics ;
2024-09-13 13:58:41 +00:00
using System.Diagnostics.CodeAnalysis ;
2023-05-07 11:10:59 +00:00
using System.IO ;
using System.Threading.Tasks ;
using Microsoft.Data.Sqlite ;
using osu.Framework.Development ;
using osu.Framework.IO.Network ;
using osu.Framework.Logging ;
using osu.Framework.Platform ;
using osu.Game.Database ;
using SharpCompress.Compressors ;
using SharpCompress.Compressors.BZip2 ;
using SQLitePCL ;
namespace osu.Game.Beatmaps
{
/// <summary>
2023-05-07 17:18:59 +00:00
/// Performs online metadata lookups using a copy of a database containing metadata for a large subset of beatmaps (stored to <see cref="cache_database_name"/>).
/// The database will be asynchronously downloaded - if not already present locally - when this component is constructed.
2023-05-07 11:10:59 +00:00
/// </summary>
public class LocalCachedBeatmapMetadataSource : IOnlineBeatmapMetadataSource
{
private readonly Storage storage ;
private FileWebRequest ? cacheDownloadRequest ;
private const string cache_database_name = @"online.db" ;
public LocalCachedBeatmapMetadataSource ( Storage storage )
{
try
{
// required to initialise native SQLite libraries on some platforms.
Batteries_V2 . Init ( ) ;
raw . sqlite3_config ( 2 /*SQLITE_CONFIG_MULTITHREAD*/ ) ;
}
catch
{
// may fail if platform not supported.
}
this . storage = storage ;
2024-05-02 14:08:09 +00:00
if ( shouldFetchCache ( ) )
2023-05-07 11:10:59 +00:00
prepareLocalCache ( ) ;
}
2024-05-02 14:08:09 +00:00
private bool shouldFetchCache ( )
{
// avoid downloading / using cache for unit tests.
if ( DebugUtils . IsNUnitRunning )
return false ;
if ( ! storage . Exists ( cache_database_name ) )
{
log ( @"Fetching local cache because it does not exist." ) ;
return true ;
}
// periodically update the cache to include newer beatmaps.
var fileInfo = new FileInfo ( storage . GetFullPath ( cache_database_name ) ) ;
if ( fileInfo . LastWriteTime < DateTime . Now . AddMonths ( - 1 ) )
{
log ( $@"Refetching local cache because it was last written to on {fileInfo.LastWriteTime}." ) ;
return true ;
}
return false ;
}
2023-05-07 11:10:59 +00:00
public bool Available = >
// no download in progress.
cacheDownloadRequest = = null
// cached database exists on disk.
& & storage . Exists ( cache_database_name ) ;
2024-09-13 13:58:41 +00:00
public bool TryLookup ( BeatmapInfo beatmapInfo , [ NotNullWhen ( true ) ] out OnlineBeatmapMetadata ? onlineMetadata )
2023-05-07 11:10:59 +00:00
{
2024-08-21 13:28:51 +00:00
Debug . Assert ( beatmapInfo . BeatmapSet ! = null ) ;
2023-05-07 11:10:59 +00:00
if ( ! Available )
2023-05-07 15:49:31 +00:00
{
onlineMetadata = null ;
return false ;
}
2023-05-07 11:10:59 +00:00
2024-10-30 09:23:57 +00:00
if ( string . IsNullOrEmpty ( beatmapInfo . MD5Hash )
& & string . IsNullOrEmpty ( beatmapInfo . Path ) )
2023-05-07 15:49:31 +00:00
{
onlineMetadata = null ;
return false ;
}
2023-05-07 11:10:59 +00:00
try
{
2024-09-13 13:58:41 +00:00
using ( var db = getConnection ( ) )
2023-05-07 11:10:59 +00:00
{
db . Open ( ) ;
2024-08-21 13:28:51 +00:00
switch ( getCacheVersion ( db ) )
2023-05-07 11:10:59 +00:00
{
2024-08-21 13:28:51 +00:00
case 1 :
// will eventually become irrelevant due to the monthly recycling of local caches
// can be removed 20250221
return queryCacheVersion1 ( db , beatmapInfo , out onlineMetadata ) ;
case 2 :
return queryCacheVersion2 ( db , beatmapInfo , out onlineMetadata ) ;
2023-05-07 11:10:59 +00:00
}
}
}
catch ( Exception ex )
{
logForModel ( beatmapInfo . BeatmapSet , $@"Cached local retrieval for {beatmapInfo} failed with {ex}." ) ;
2023-05-07 15:49:31 +00:00
onlineMetadata = null ;
return false ;
2023-05-07 11:10:59 +00:00
}
2023-05-07 15:49:31 +00:00
onlineMetadata = null ;
return false ;
2023-05-07 11:10:59 +00:00
}
2024-09-13 13:58:41 +00:00
private SqliteConnection getConnection ( ) = >
new SqliteConnection ( string . Concat ( @"Data Source=" , storage . GetFullPath ( @"online.db" , true ) ) ) ;
2024-02-11 12:05:58 +00:00
private void prepareLocalCache ( )
{
2024-05-02 14:08:09 +00:00
bool isRefetch = storage . Exists ( cache_database_name ) ;
2024-02-11 12:05:58 +00:00
string cacheFilePath = storage . GetFullPath ( cache_database_name ) ;
string compressedCacheFilePath = $@"{cacheFilePath}.bz2" ;
cacheDownloadRequest = new FileWebRequest ( compressedCacheFilePath , $@"https://assets.ppy.sh/client-resources/{cache_database_name}.bz2?{DateTimeOffset.UtcNow:yyyyMMdd}" ) ;
cacheDownloadRequest . Failed + = ex = >
{
File . Delete ( compressedCacheFilePath ) ;
2024-05-02 14:08:09 +00:00
// don't clobber the cache when refetching if the download didn't succeed. seems excessive.
// consequently, also null the download request to allow the existing cache to be used (see `Available`).
if ( isRefetch )
cacheDownloadRequest = null ;
else
File . Delete ( cacheFilePath ) ;
log ( $@"Online cache download failed: {ex}" ) ;
2024-02-11 12:05:58 +00:00
} ;
cacheDownloadRequest . Finished + = ( ) = >
{
try
{
using ( var stream = File . OpenRead ( cacheDownloadRequest . Filename ) )
using ( var outStream = File . OpenWrite ( cacheFilePath ) )
2024-05-02 14:08:09 +00:00
{
// ensure to clobber any and all existing data to avoid accidental corruption.
outStream . SetLength ( 0 ) ;
using ( var bz2 = new BZip2Stream ( stream , CompressionMode . Decompress , false ) )
bz2 . CopyTo ( outStream ) ;
}
2024-02-11 12:05:58 +00:00
// set to null on completion to allow lookups to begin using the new source
cacheDownloadRequest = null ;
2024-05-02 14:08:09 +00:00
log ( @"Local cache fetch completed successfully." ) ;
2024-02-11 12:05:58 +00:00
}
catch ( Exception ex )
{
2024-05-02 14:08:09 +00:00
log ( $@"Online cache extraction failed: {ex}" ) ;
// at this point clobber the cache regardless of whether we're refetching, because by this point who knows what state the cache file is in.
2024-02-11 12:05:58 +00:00
File . Delete ( cacheFilePath ) ;
}
finally
{
File . Delete ( compressedCacheFilePath ) ;
}
} ;
Task . Run ( async ( ) = >
{
try
{
await cacheDownloadRequest . PerformAsync ( ) . ConfigureAwait ( false ) ;
}
catch
{
// Prevent throwing unobserved exceptions, as they will be logged from the network request to the log file anyway.
}
} ) ;
}
2024-09-13 13:58:41 +00:00
public int GetCacheVersion ( )
{
using ( var connection = getConnection ( ) )
{
connection . Open ( ) ;
return getCacheVersion ( connection ) ;
}
}
2024-08-21 13:28:51 +00:00
private int getCacheVersion ( SqliteConnection connection )
{
using ( var cmd = connection . CreateCommand ( ) )
{
cmd . CommandText = @"SELECT COUNT(1) FROM `sqlite_master` WHERE `type` = 'table' AND `name` = 'schema_version'" ;
using var reader = cmd . ExecuteReader ( ) ;
if ( ! reader . Read ( ) )
throw new InvalidOperationException ( "Error when attempting to check for existence of `schema_version` table." ) ;
// No versioning table means that this is the very first version of the schema.
if ( reader . GetInt32 ( 0 ) = = 0 )
return 1 ;
}
using ( var cmd = connection . CreateCommand ( ) )
{
cmd . CommandText = @"SELECT `number` FROM `schema_version`" ;
using var reader = cmd . ExecuteReader ( ) ;
if ( ! reader . Read ( ) )
throw new InvalidOperationException ( "Error when attempting to query schema version." ) ;
return reader . GetInt32 ( 0 ) ;
}
}
private bool queryCacheVersion1 ( SqliteConnection db , BeatmapInfo beatmapInfo , out OnlineBeatmapMetadata ? onlineMetadata )
{
Debug . Assert ( beatmapInfo . BeatmapSet ! = null ) ;
using var cmd = db . CreateCommand ( ) ;
cmd . CommandText =
2024-10-30 09:23:57 +00:00
@"SELECT beatmapset_id, beatmap_id, approved, user_id, checksum, last_update FROM osu_beatmaps WHERE checksum = @MD5Hash OR filename = @Path" ;
2024-08-21 13:28:51 +00:00
cmd . Parameters . Add ( new SqliteParameter ( @"@MD5Hash" , beatmapInfo . MD5Hash ) ) ;
2024-10-30 09:23:57 +00:00
cmd . Parameters . Add ( new SqliteParameter ( @"@Path" , beatmapInfo . Path ) ) ;
2024-08-21 13:28:51 +00:00
using var reader = cmd . ExecuteReader ( ) ;
if ( reader . Read ( ) )
{
logForModel ( beatmapInfo . BeatmapSet , $@"Cached local retrieval for {beatmapInfo} (cache version 1)." ) ;
onlineMetadata = new OnlineBeatmapMetadata
{
BeatmapSetID = reader . GetInt32 ( 0 ) ,
BeatmapID = reader . GetInt32 ( 1 ) ,
BeatmapStatus = ( BeatmapOnlineStatus ) reader . GetByte ( 2 ) ,
BeatmapSetStatus = ( BeatmapOnlineStatus ) reader . GetByte ( 2 ) ,
AuthorID = reader . GetInt32 ( 3 ) ,
MD5Hash = reader . GetString ( 4 ) ,
LastUpdated = reader . GetDateTimeOffset ( 5 ) ,
// TODO: DateSubmitted and DateRanked are not provided by local cache in this version.
} ;
return true ;
}
onlineMetadata = null ;
return false ;
}
private bool queryCacheVersion2 ( SqliteConnection db , BeatmapInfo beatmapInfo , out OnlineBeatmapMetadata ? onlineMetadata )
{
Debug . Assert ( beatmapInfo . BeatmapSet ! = null ) ;
using var cmd = db . CreateCommand ( ) ;
cmd . CommandText =
"" "
SELECT ` b ` . ` beatmapset_id ` , ` b ` . ` beatmap_id ` , ` b ` . ` approved ` , ` b ` . ` user_id ` , ` b ` . ` checksum ` , ` b ` . ` last_update ` , ` s ` . ` submit_date ` , ` s ` . ` approved_date `
FROM ` osu_beatmaps ` AS ` b `
JOIN ` osu_beatmapsets ` AS ` s ` ON ` s ` . ` beatmapset_id ` = ` b ` . ` beatmapset_id `
2024-10-30 09:23:57 +00:00
WHERE ` b ` . ` checksum ` = @MD5Hash OR ` b ` . ` filename ` = @Path
2024-08-21 13:28:51 +00:00
"" ";
cmd . Parameters . Add ( new SqliteParameter ( @"@MD5Hash" , beatmapInfo . MD5Hash ) ) ;
2024-10-30 09:23:57 +00:00
cmd . Parameters . Add ( new SqliteParameter ( @"@Path" , beatmapInfo . Path ) ) ;
2024-08-21 13:28:51 +00:00
using var reader = cmd . ExecuteReader ( ) ;
if ( reader . Read ( ) )
{
logForModel ( beatmapInfo . BeatmapSet , $@"Cached local retrieval for {beatmapInfo} (cache version 2)." ) ;
onlineMetadata = new OnlineBeatmapMetadata
{
BeatmapSetID = reader . GetInt32 ( 0 ) ,
BeatmapID = reader . GetInt32 ( 1 ) ,
BeatmapStatus = ( BeatmapOnlineStatus ) reader . GetByte ( 2 ) ,
BeatmapSetStatus = ( BeatmapOnlineStatus ) reader . GetByte ( 2 ) ,
AuthorID = reader . GetInt32 ( 3 ) ,
MD5Hash = reader . GetString ( 4 ) ,
LastUpdated = reader . GetDateTimeOffset ( 5 ) ,
DateSubmitted = reader . GetDateTimeOffset ( 6 ) ,
DateRanked = reader . GetDateTimeOffset ( 7 ) ,
} ;
return true ;
}
onlineMetadata = null ;
return false ;
}
2024-05-02 14:08:09 +00:00
private static void log ( string message )
= > Logger . Log ( $@"[{nameof(LocalCachedBeatmapMetadataSource)}] {message}" , LoggingTarget . Database ) ;
2023-05-07 11:10:59 +00:00
private void logForModel ( BeatmapSetInfo set , string message ) = >
RealmArchiveModelImporter < BeatmapSetInfo > . LogForModel ( set , $@"[{nameof(LocalCachedBeatmapMetadataSource)}] {message}" ) ;
public void Dispose ( )
{
cacheDownloadRequest ? . Dispose ( ) ;
}
}
}