2021-09-30 09:21:16 +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.Collections.Generic ;
2022-09-09 04:57:01 +00:00
using System.Diagnostics ;
2021-09-30 09:21:16 +00:00
using System.Linq ;
using System.Threading ;
2022-01-07 15:40:14 +00:00
using Newtonsoft.Json ;
2021-09-30 09:21:16 +00:00
using osu.Framework.Logging ;
using osu.Framework.Platform ;
using osu.Game.Beatmaps ;
using osu.Game.Database ;
using osu.Game.IO.Archives ;
2021-12-14 10:47:11 +00:00
using osu.Game.Rulesets ;
2021-09-30 09:21:16 +00:00
using osu.Game.Scoring.Legacy ;
2022-07-08 03:16:06 +00:00
using osu.Game.Online.API ;
using osu.Game.Online.API.Requests ;
using osu.Game.Online.API.Requests.Responses ;
2021-12-06 13:47:00 +00:00
using Realms ;
2021-09-30 09:21:16 +00:00
namespace osu.Game.Scoring
{
2022-06-16 09:53:13 +00:00
public class ScoreImporter : RealmArchiveModelImporter < ScoreInfo >
2021-09-30 09:21:16 +00:00
{
public override IEnumerable < string > HandledExtensions = > new [ ] { ".osr" } ;
protected override string [ ] HashableFileTypes = > new [ ] { ".osr" } ;
2021-12-14 10:47:11 +00:00
private readonly RulesetStore rulesets ;
2021-09-30 09:21:16 +00:00
private readonly Func < BeatmapManager > beatmaps ;
2022-07-08 03:16:06 +00:00
private readonly IAPIProvider api ;
2022-09-09 04:57:01 +00:00
public ScoreImporter ( RulesetStore rulesets , Func < BeatmapManager > beatmaps , Storage storage , RealmAccess realm , IAPIProvider api )
2022-01-24 10:59:58 +00:00
: base ( storage , realm )
2021-09-30 09:21:16 +00:00
{
this . rulesets = rulesets ;
this . beatmaps = beatmaps ;
2022-07-08 03:16:06 +00:00
this . api = api ;
2021-09-30 09:21:16 +00:00
}
2023-09-27 08:02:47 +00:00
protected override ScoreInfo ? CreateModel ( ArchiveReader archive , ImportParameters parameters )
2021-09-30 09:21:16 +00:00
{
2023-01-09 19:51:38 +00:00
string name = archive . Filenames . First ( f = > f . EndsWith ( ".osr" , StringComparison . OrdinalIgnoreCase ) ) ;
using ( var stream = archive . GetStream ( name ) )
2021-09-30 09:21:16 +00:00
{
try
{
return new DatabasedLegacyScoreDecoder ( rulesets , beatmaps ( ) ) . Parse ( stream ) . ScoreInfo ;
}
2023-09-27 07:55:03 +00:00
catch ( LegacyScoreDecoder . BeatmapNotFoundException notFound )
2021-09-30 09:21:16 +00:00
{
2023-09-27 07:55:03 +00:00
Logger . Log ( $@"Score '{archive.Name}' failed to import: no corresponding beatmap with the hash '{notFound.Hash}' could be found." , LoggingTarget . Database ) ;
2023-09-19 08:41:00 +00:00
2023-09-27 08:02:47 +00:00
if ( ! parameters . Batch )
{
// In the case of a missing beatmap, let's attempt to resolve it and show a prompt to the user to download the required beatmap.
2023-09-27 15:06:47 +00:00
var req = new GetBeatmapRequest ( new BeatmapInfo { MD5Hash = notFound . Hash } ) ;
req . Success + = res = > PostNotification ? . Invoke ( new MissingBeatmapNotification ( res , archive , notFound . Hash ) ) ;
2023-09-27 08:02:47 +00:00
api . Queue ( req ) ;
}
2021-09-30 09:21:16 +00:00
return null ;
}
2023-09-27 07:55:03 +00:00
catch ( Exception e )
2023-09-26 06:00:56 +00:00
{
2023-09-27 07:55:03 +00:00
Logger . Log ( $@"Failed to parse headers of score '{archive.Name}': {e}." , LoggingTarget . Database ) ;
2023-09-26 06:00:56 +00:00
return null ;
}
2021-09-30 09:21:16 +00:00
}
}
public Score GetScore ( ScoreInfo score ) = > new LegacyDatabasedScore ( score , rulesets , beatmaps ( ) , Files . Store ) ;
2022-01-13 07:27:07 +00:00
protected override void Populate ( ScoreInfo model , ArchiveReader ? archive , Realm realm , CancellationToken cancellationToken = default )
2022-01-07 08:27:48 +00:00
{
2023-07-04 05:50:34 +00:00
Debug . Assert ( model . BeatmapInfo ! = null ) ;
2022-01-07 08:27:48 +00:00
// Ensure the beatmap is not detached.
2022-01-12 09:05:25 +00:00
if ( ! model . BeatmapInfo . IsManaged )
2023-07-06 04:37:42 +00:00
model . BeatmapInfo = realm . Find < BeatmapInfo > ( model . BeatmapInfo . ID ) ! ;
2022-01-07 08:27:48 +00:00
if ( ! model . Ruleset . IsManaged )
2023-07-06 04:37:42 +00:00
model . Ruleset = realm . Find < RulesetInfo > ( model . Ruleset . ShortName ) ! ;
2022-01-07 08:27:48 +00:00
2022-01-17 05:02:15 +00:00
// These properties are known to be non-null, but these final checks ensure a null hasn't come from somewhere (or the refetch has failed).
// Under no circumstance do we want these to be written to realm as null.
2022-12-22 20:27:59 +00:00
ArgumentNullException . ThrowIfNull ( model . BeatmapInfo ) ;
ArgumentNullException . ThrowIfNull ( model . Ruleset ) ;
2022-01-17 05:02:15 +00:00
2022-01-07 15:40:14 +00:00
if ( string . IsNullOrEmpty ( model . StatisticsJson ) )
model . StatisticsJson = JsonConvert . SerializeObject ( model . Statistics ) ;
2022-08-22 12:31:10 +00:00
if ( string . IsNullOrEmpty ( model . MaximumStatisticsJson ) )
model . MaximumStatisticsJson = JsonConvert . SerializeObject ( model . MaximumStatistics ) ;
2023-06-15 19:48:57 +00:00
// for pre-ScoreV2 lazer scores, apply a best-effort conversion of total score to ScoreV2.
// this requires: max combo, statistics, max statistics (where available), and mods to already be populated on the score.
if ( StandardisedScoreMigrationTools . ShouldMigrateToNewStandardised ( model ) )
model . TotalScore = StandardisedScoreMigrationTools . GetNewStandardised ( model ) ;
2022-01-07 08:27:48 +00:00
}
2022-07-08 03:16:06 +00:00
2023-06-09 10:03:02 +00:00
// Very naive local caching to improve performance of large score imports (where the username is usually the same for most or all scores).
2024-05-06 11:24:34 +00:00
// TODO: `UserLookupCache` cannot currently be used here because of async foibles.
// It only supports lookups by user ID (username would require web changes), and even then the ID lookups cannot be used.
// That is because that component provides an async interface, and async functions cannot be consumed safely here due to the rigid structure of `RealmArchiveModelImporter`.
// The importer has two paths, one async and one sync; the async path runs the sync path in a task.
// This means that sometimes `PostImport()` is called from a sync context, and sometimes from an async one, whilst itself being a sync method.
// That in turn makes `.GetResultSafely()` not callable inside `PostImport()`, as it will throw when called from an async context,
private readonly Dictionary < int , APIUser > idLookupCache = new Dictionary < int , APIUser > ( ) ;
2023-06-09 10:03:02 +00:00
private readonly Dictionary < string , APIUser > usernameLookupCache = new Dictionary < string , APIUser > ( ) ;
2022-12-12 14:56:11 +00:00
protected override void PostImport ( ScoreInfo model , Realm realm , ImportParameters parameters )
2022-07-08 03:16:06 +00:00
{
2022-12-12 14:56:11 +00:00
base . PostImport ( model , realm , parameters ) ;
2022-07-08 03:16:06 +00:00
2023-06-09 10:03:02 +00:00
populateUserDetails ( model ) ;
2023-10-30 06:46:09 +00:00
Debug . Assert ( model . BeatmapInfo ! = null ) ;
// This needs to be run after user detail population to ensure we have a valid user id.
if ( api . IsLoggedIn & & api . LocalUser . Value . OnlineID = = model . UserID & & ( model . BeatmapInfo . LastPlayed = = null | | model . Date > model . BeatmapInfo . LastPlayed ) )
model . BeatmapInfo . LastPlayed = model . Date ;
2023-06-09 10:03:02 +00:00
}
/// <summary>
/// Legacy replays only store a username.
/// This will populate a user ID during import.
/// </summary>
private void populateUserDetails ( ScoreInfo model )
{
2023-10-10 07:28:01 +00:00
if ( model . RealmUser . OnlineID = = APIUser . SYSTEM_USER_ID )
return ;
2024-05-06 11:24:34 +00:00
if ( model . RealmUser . OnlineID > 1 )
{
model . User = lookupUserById ( model . RealmUser . OnlineID ) ? ? model . User ;
return ;
}
2024-05-03 11:48:06 +00:00
if ( model . OnlineID < 0 & & model . LegacyOnlineID < = 0 )
return ;
2024-05-06 11:24:34 +00:00
model . User = lookupUserByName ( model . RealmUser . Username ) ? ? model . User ;
}
2023-06-09 10:03:02 +00:00
2024-05-06 11:24:34 +00:00
private APIUser ? lookupUserById ( int id )
{
if ( idLookupCache . TryGetValue ( id , out var existing ) )
{
return existing ;
}
var userRequest = new GetUserRequest ( id ) ;
api . Perform ( userRequest ) ;
if ( userRequest . Response is APIUser user )
{
APIUser cachedUser ;
idLookupCache . TryAdd ( id , cachedUser = new APIUser
{
// Because this is a permanent cache, let's only store the pieces we're interested in,
// rather than the full API response. If we start to store more than these three fields
// in realm, this should be undone.
Id = user . Id ,
Username = user . Username ,
CountryCode = user . CountryCode ,
} ) ;
return cachedUser ;
}
return null ;
}
private APIUser ? lookupUserByName ( string username )
{
2023-06-09 10:03:02 +00:00
if ( usernameLookupCache . TryGetValue ( username , out var existing ) )
{
2024-05-06 11:24:34 +00:00
return existing ;
2023-06-09 10:03:02 +00:00
}
var userRequest = new GetUserRequest ( username ) ;
2022-07-11 18:46:42 +00:00
2022-07-08 03:16:06 +00:00
api . Perform ( userRequest ) ;
2022-07-11 18:46:42 +00:00
if ( userRequest . Response is APIUser user )
2023-06-09 10:03:02 +00:00
{
2024-05-06 11:24:34 +00:00
APIUser cachedUser ;
usernameLookupCache . TryAdd ( username , cachedUser = new APIUser
2023-06-09 10:03:02 +00:00
{
// Because this is a permanent cache, let's only store the pieces we're interested in,
// rather than the full API response. If we start to store more than these three fields
// in realm, this should be undone.
Id = user . Id ,
Username = user . Username ,
CountryCode = user . CountryCode ,
} ) ;
2024-05-06 11:24:34 +00:00
return cachedUser ;
2023-06-09 10:03:02 +00:00
}
2024-05-06 11:24:34 +00:00
return null ;
2022-07-08 03:16:06 +00:00
}
2021-09-30 09:21:16 +00:00
}
}