2023-05-07 19:10:59 +08: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 ;
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-08 01:18:59 +08: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 19:10:59 +08: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 22:08:09 +08:00
if ( shouldFetchCache ( ) )
2023-05-07 19:10:59 +08:00
prepareLocalCache ( ) ;
}
2024-05-02 22:08:09 +08: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 19:10:59 +08:00
public bool Available = >
// no download in progress.
cacheDownloadRequest = = null
// cached database exists on disk.
& & storage . Exists ( cache_database_name ) ;
2023-05-07 23:49:31 +08:00
public bool TryLookup ( BeatmapInfo beatmapInfo , out OnlineBeatmapMetadata ? onlineMetadata )
2023-05-07 19:10:59 +08:00
{
if ( ! Available )
2023-05-07 23:49:31 +08:00
{
onlineMetadata = null ;
return false ;
}
2023-05-07 19:10:59 +08:00
if ( string . IsNullOrEmpty ( beatmapInfo . MD5Hash )
& & string . IsNullOrEmpty ( beatmapInfo . Path )
& & beatmapInfo . OnlineID < = 0 )
2023-05-07 23:49:31 +08:00
{
onlineMetadata = null ;
return false ;
}
2023-05-07 19:10:59 +08:00
Debug . Assert ( beatmapInfo . BeatmapSet ! = null ) ;
try
{
using ( var db = new SqliteConnection ( string . Concat ( @"Data Source=" , storage . GetFullPath ( @"online.db" , true ) ) ) )
{
db . Open ( ) ;
using ( var cmd = db . CreateCommand ( ) )
{
cmd . CommandText =
@"SELECT beatmapset_id, beatmap_id, approved, user_id, checksum, last_update FROM osu_beatmaps WHERE checksum = @MD5Hash OR beatmap_id = @OnlineID OR filename = @Path" ;
cmd . Parameters . Add ( new SqliteParameter ( @"@MD5Hash" , beatmapInfo . MD5Hash ) ) ;
cmd . Parameters . Add ( new SqliteParameter ( @"@OnlineID" , beatmapInfo . OnlineID ) ) ;
cmd . Parameters . Add ( new SqliteParameter ( @"@Path" , beatmapInfo . Path ) ) ;
using ( var reader = cmd . ExecuteReader ( ) )
{
if ( reader . Read ( ) )
{
logForModel ( beatmapInfo . BeatmapSet , $@"Cached local retrieval for {beatmapInfo}." ) ;
2023-05-07 23:49:31 +08:00
onlineMetadata = new OnlineBeatmapMetadata
2023-05-07 19:10:59 +08:00
{
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.
} ;
2023-05-07 23:49:31 +08:00
return true ;
2023-05-07 19:10:59 +08:00
}
}
}
}
}
catch ( Exception ex )
{
logForModel ( beatmapInfo . BeatmapSet , $@"Cached local retrieval for {beatmapInfo} failed with {ex}." ) ;
2023-05-07 23:49:31 +08:00
onlineMetadata = null ;
return false ;
2023-05-07 19:10:59 +08:00
}
2023-05-07 23:49:31 +08:00
onlineMetadata = null ;
return false ;
2023-05-07 19:10:59 +08:00
}
2024-02-11 20:05:58 +08:00
private void prepareLocalCache ( )
{
2024-05-02 22:08:09 +08:00
bool isRefetch = storage . Exists ( cache_database_name ) ;
2024-02-11 20:05:58 +08: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 22:08:09 +08: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 20:05:58 +08:00
} ;
cacheDownloadRequest . Finished + = ( ) = >
{
try
{
using ( var stream = File . OpenRead ( cacheDownloadRequest . Filename ) )
using ( var outStream = File . OpenWrite ( cacheFilePath ) )
2024-05-02 22:08:09 +08: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 20:05:58 +08:00
// set to null on completion to allow lookups to begin using the new source
cacheDownloadRequest = null ;
2024-05-02 22:08:09 +08:00
log ( @"Local cache fetch completed successfully." ) ;
2024-02-11 20:05:58 +08:00
}
catch ( Exception ex )
{
2024-05-02 22:08:09 +08: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 20:05:58 +08: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-05-02 22:08:09 +08:00
private static void log ( string message )
= > Logger . Log ( $@"[{nameof(LocalCachedBeatmapMetadataSource)}] {message}" , LoggingTarget . Database ) ;
2023-05-07 19:10:59 +08:00
private void logForModel ( BeatmapSetInfo set , string message ) = >
RealmArchiveModelImporter < BeatmapSetInfo > . LogForModel ( set , $@"[{nameof(LocalCachedBeatmapMetadataSource)}] {message}" ) ;
public void Dispose ( )
{
cacheDownloadRequest ? . Dispose ( ) ;
}
}
}