2020-09-01 08:28:41 +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 ;
2020-09-08 08:59:38 +00:00
using System.Collections.Specialized ;
2020-09-01 08:28:41 +00:00
using System.IO ;
2020-09-02 15:08:33 +00:00
using System.Linq ;
2020-09-02 14:31:37 +00:00
using System.Threading ;
using System.Threading.Tasks ;
2020-09-01 10:33:06 +00:00
using osu.Framework.Allocation ;
using osu.Framework.Bindables ;
2020-09-09 05:31:23 +00:00
using osu.Framework.Graphics ;
2020-09-02 14:31:37 +00:00
using osu.Framework.Logging ;
2020-09-01 08:28:41 +00:00
using osu.Framework.Platform ;
using osu.Game.Beatmaps ;
2021-09-30 08:42:12 +00:00
using osu.Game.Database ;
2021-05-09 15:12:58 +00:00
using osu.Game.IO ;
2020-09-01 08:28:41 +00:00
using osu.Game.IO.Legacy ;
2020-09-08 08:59:38 +00:00
using osu.Game.Overlays.Notifications ;
2020-09-01 08:28:41 +00:00
namespace osu.Game.Collections
{
2020-09-09 05:44:04 +00:00
/// <summary>
/// Handles user-defined collections of beatmaps.
/// </summary>
/// <remarks>
/// This is currently reading and writing from the osu-stable file format. This is a temporary arrangement until we refactor the
/// database backing the game. Going forward writing should be done in a similar way to other model stores.
/// </remarks>
2021-09-30 08:42:12 +00:00
public class CollectionManager : Component , IPostNotifications
2020-09-01 08:28:41 +00:00
{
2020-09-02 14:31:37 +00:00
/// <summary>
2020-09-07 12:08:48 +00:00
/// Database version in stable-compatible YYYYMMDD format.
2020-09-02 14:31:37 +00:00
/// </summary>
private const int database_version = 30000000 ;
2020-09-01 10:33:06 +00:00
private const string database_name = "collection.db" ;
2021-06-23 06:09:42 +00:00
private const string database_backup_name = "collection.db.bak" ;
2020-09-01 08:28:41 +00:00
2020-09-02 15:08:33 +00:00
public readonly BindableList < BeatmapCollection > Collections = new BindableList < BeatmapCollection > ( ) ;
2020-09-01 10:33:06 +00:00
[Resolved]
private BeatmapManager beatmaps { get ; set ; }
2020-09-07 13:47:19 +00:00
private readonly Storage storage ;
2020-09-09 06:31:08 +00:00
public CollectionManager ( Storage storage )
2020-09-07 13:47:19 +00:00
{
this . storage = storage ;
}
2022-01-26 15:34:51 +00:00
[Resolved(canBeNull: true)]
private DatabaseContextFactory efContextFactory { get ; set ; } = null ! ;
2020-09-01 10:33:06 +00:00
[BackgroundDependencyLoader]
2020-09-02 14:31:37 +00:00
private void load ( )
2020-09-08 08:59:38 +00:00
{
2022-01-26 15:34:51 +00:00
efContextFactory ? . WaitForMigrationCompletion ( ) ;
2021-06-23 12:26:52 +00:00
Collections . CollectionChanged + = collectionsChanged ;
2021-06-23 07:14:05 +00:00
if ( storage . Exists ( database_backup_name ) )
{
// If a backup file exists, it means the previous write operation didn't run to completion.
// Always prefer the backup file in such a case as it's the most recent copy that is guaranteed to not be malformed.
//
// The database is saved 100ms after any change, and again when the game is closed, so there shouldn't be a large diff between the two files in the worst case.
if ( storage . Exists ( database_name ) )
storage . Delete ( database_name ) ;
File . Copy ( storage . GetFullPath ( database_backup_name ) , storage . GetFullPath ( database_name ) ) ;
}
2020-09-09 06:40:45 +00:00
2021-06-23 07:14:05 +00:00
if ( storage . Exists ( database_name ) )
2020-09-09 06:40:45 +00:00
{
2021-05-27 10:03:59 +00:00
List < BeatmapCollection > beatmapCollections ;
2021-06-23 07:14:05 +00:00
using ( var stream = storage . GetStream ( database_name ) )
2021-05-27 10:03:59 +00:00
beatmapCollections = readCollections ( stream ) ;
// intentionally fire-and-forget async.
importCollections ( beatmapCollections ) ;
2020-09-09 06:40:45 +00:00
}
2020-09-08 08:59:38 +00:00
}
2021-06-23 12:26:52 +00:00
private void collectionsChanged ( object sender , NotifyCollectionChangedEventArgs e ) = > Schedule ( ( ) = >
2020-09-08 08:59:38 +00:00
{
switch ( e . Action )
{
case NotifyCollectionChangedAction . Add :
foreach ( var c in e . NewItems . Cast < BeatmapCollection > ( ) )
c . Changed + = backgroundSave ;
break ;
case NotifyCollectionChangedAction . Remove :
foreach ( var c in e . OldItems . Cast < BeatmapCollection > ( ) )
c . Changed - = backgroundSave ;
break ;
case NotifyCollectionChangedAction . Replace :
foreach ( var c in e . OldItems . Cast < BeatmapCollection > ( ) )
c . Changed - = backgroundSave ;
foreach ( var c in e . NewItems . Cast < BeatmapCollection > ( ) )
c . Changed + = backgroundSave ;
break ;
}
backgroundSave ( ) ;
2021-06-23 12:26:52 +00:00
} ) ;
2020-09-08 08:59:38 +00:00
2020-09-08 08:58:56 +00:00
public Action < Notification > PostNotification { protected get ; set ; }
2020-09-01 08:28:41 +00:00
2022-05-16 10:57:00 +00:00
public Task < int > GetAvailableCount ( StableStorage stableStorage )
{
if ( ! stableStorage . Exists ( database_name ) )
return Task . FromResult ( 0 ) ;
return Task . Run ( ( ) = >
{
using ( var stream = stableStorage . GetStream ( database_name ) )
return readCollections ( stream ) . Count ;
} ) ;
}
2020-09-01 08:28:41 +00:00
/// <summary>
/// This is a temporary method and will likely be replaced by a full-fledged (and more correctly placed) migration process in the future.
/// </summary>
2021-05-09 15:12:58 +00:00
public Task ImportFromStableAsync ( StableStorage stableStorage )
2020-09-02 15:08:33 +00:00
{
2021-05-09 15:12:58 +00:00
if ( ! stableStorage . Exists ( database_name ) )
2020-09-02 15:08:33 +00:00
{
// This handles situations like when the user does not have a collections.db file
Logger . Log ( $"No {database_name} available in osu!stable installation" , LoggingTarget . Information , LogLevel . Error ) ;
return Task . CompletedTask ;
}
2020-09-07 13:10:12 +00:00
return Task . Run ( async ( ) = >
2020-09-02 15:08:33 +00:00
{
2021-05-09 15:12:58 +00:00
using ( var stream = stableStorage . GetStream ( database_name ) )
2021-03-08 03:57:16 +00:00
await Import ( stream ) . ConfigureAwait ( false ) ;
2020-09-02 15:08:33 +00:00
} ) ;
}
2020-09-08 08:58:56 +00:00
public async Task Import ( Stream stream )
{
var notification = new ProgressNotification
{
State = ProgressNotificationState . Active ,
Text = "Collections import is initialising..."
} ;
PostNotification ? . Invoke ( notification ) ;
2021-02-23 06:57:41 +00:00
var collections = readCollections ( stream , notification ) ;
2021-03-08 03:57:16 +00:00
await importCollections ( collections ) . ConfigureAwait ( false ) ;
2020-09-08 08:58:56 +00:00
2021-02-23 06:57:41 +00:00
notification . CompletionText = $"Imported {collections.Count} collections" ;
2020-09-09 06:40:45 +00:00
notification . State = ProgressNotificationState . Completed ;
}
2020-09-07 13:10:12 +00:00
2021-02-18 07:19:36 +00:00
private Task importCollections ( List < BeatmapCollection > newCollections )
2020-09-02 15:08:33 +00:00
{
2021-02-18 07:19:36 +00:00
var tcs = new TaskCompletionSource < bool > ( ) ;
2020-09-02 15:08:33 +00:00
2021-02-18 07:19:36 +00:00
Schedule ( ( ) = >
{
try
2020-09-02 15:08:33 +00:00
{
2021-02-18 07:19:36 +00:00
foreach ( var newCol in newCollections )
{
2021-02-23 06:57:41 +00:00
var existing = Collections . FirstOrDefault ( c = > c . Name . Value = = newCol . Name . Value ) ;
2021-02-18 07:19:36 +00:00
if ( existing = = null )
Collections . Add ( existing = new BeatmapCollection { Name = { Value = newCol . Name . Value } } ) ;
foreach ( var newBeatmap in newCol . Beatmaps )
{
if ( ! existing . Beatmaps . Contains ( newBeatmap ) )
existing . Beatmaps . Add ( newBeatmap ) ;
}
}
tcs . SetResult ( true ) ;
2020-09-02 15:08:33 +00:00
}
2021-02-18 07:19:36 +00:00
catch ( Exception e )
{
Logger . Error ( e , "Failed to import collection." ) ;
tcs . SetException ( e ) ;
}
} ) ;
return tcs . Task ;
2020-09-02 15:08:33 +00:00
}
2020-09-08 08:58:56 +00:00
private List < BeatmapCollection > readCollections ( Stream stream , ProgressNotification notification = null )
2020-09-01 08:28:41 +00:00
{
2020-09-08 08:58:56 +00:00
if ( notification ! = null )
{
notification . Text = "Reading collections..." ;
notification . Progress = 0 ;
}
2020-09-01 08:28:41 +00:00
var result = new List < BeatmapCollection > ( ) ;
2020-09-02 14:31:37 +00:00
try
{
using ( var sr = new SerializationReader ( stream ) )
{
sr . ReadInt32 ( ) ; // Version
int collectionCount = sr . ReadInt32 ( ) ;
result . Capacity = collectionCount ;
for ( int i = 0 ; i < collectionCount ; i + + )
{
2020-09-08 08:58:56 +00:00
if ( notification ? . CancellationToken . IsCancellationRequested = = true )
return result ;
2020-09-04 19:43:51 +00:00
var collection = new BeatmapCollection { Name = { Value = sr . ReadString ( ) } } ;
2020-09-02 14:31:37 +00:00
int mapCount = sr . ReadInt32 ( ) ;
for ( int j = 0 ; j < mapCount ; j + + )
{
2020-09-08 08:58:56 +00:00
if ( notification ? . CancellationToken . IsCancellationRequested = = true )
return result ;
2020-09-02 14:31:37 +00:00
string checksum = sr . ReadString ( ) ;
2022-01-19 06:00:05 +00:00
var beatmap = beatmaps . QueryBeatmap ( b = > b . MD5Hash = = checksum ) ;
2020-09-02 14:31:37 +00:00
if ( beatmap ! = null )
collection . Beatmaps . Add ( beatmap ) ;
}
2020-09-08 08:58:56 +00:00
if ( notification ! = null )
{
notification . Text = $"Imported {i + 1} of {collectionCount} collections" ;
notification . Progress = ( float ) ( i + 1 ) / collectionCount ;
}
2020-09-02 14:31:37 +00:00
result . Add ( collection ) ;
}
}
}
catch ( Exception e )
2020-09-01 08:28:41 +00:00
{
2020-09-02 14:47:42 +00:00
Logger . Error ( e , "Failed to read collection database." ) ;
2020-09-02 14:31:37 +00:00
}
2020-09-01 08:28:41 +00:00
2020-09-02 14:31:37 +00:00
return result ;
}
2020-09-01 08:28:41 +00:00
2020-09-08 08:58:56 +00:00
public void DeleteAll ( )
{
Collections . Clear ( ) ;
2020-09-19 19:55:52 +00:00
PostNotification ? . Invoke ( new ProgressCompletionNotification { Text = "Deleted all collections!" } ) ;
2020-09-08 08:58:56 +00:00
}
2020-09-02 14:31:37 +00:00
private readonly object saveLock = new object ( ) ;
private int lastSave ;
private int saveFailures ;
/// <summary>
/// Perform a save with debounce.
/// </summary>
private void backgroundSave ( )
{
2021-10-27 04:04:41 +00:00
int current = Interlocked . Increment ( ref lastSave ) ;
2020-09-02 14:31:37 +00:00
Task . Delay ( 100 ) . ContinueWith ( task = >
{
if ( current ! = lastSave )
return ;
if ( ! save ( ) )
backgroundSave ( ) ;
} ) ;
}
private bool save ( )
{
lock ( saveLock )
{
Interlocked . Increment ( ref lastSave ) ;
2021-06-23 06:03:34 +00:00
// This is NOT thread-safe!!
2020-09-02 14:31:37 +00:00
try
2020-09-01 08:28:41 +00:00
{
2021-10-27 04:04:41 +00:00
string tempPath = Path . GetTempFileName ( ) ;
2020-09-01 08:28:41 +00:00
2021-06-23 06:03:34 +00:00
using ( var ms = new MemoryStream ( ) )
2020-09-01 08:28:41 +00:00
{
2021-06-23 06:03:34 +00:00
using ( var sw = new SerializationWriter ( ms , true ) )
{
sw . Write ( database_version ) ;
2020-09-01 08:28:41 +00:00
2021-06-23 06:03:34 +00:00
var collectionsCopy = Collections . ToArray ( ) ;
sw . Write ( collectionsCopy . Length ) ;
2021-06-08 05:25:39 +00:00
2021-06-23 06:03:34 +00:00
foreach ( var c in collectionsCopy )
{
sw . Write ( c . Name . Value ) ;
2020-09-02 14:31:37 +00:00
2021-06-23 06:03:34 +00:00
var beatmapsCopy = c . Beatmaps . ToArray ( ) ;
sw . Write ( beatmapsCopy . Length ) ;
2021-06-08 05:25:39 +00:00
2021-06-23 06:03:34 +00:00
foreach ( var b in beatmapsCopy )
sw . Write ( b . MD5Hash ) ;
}
2020-09-02 14:31:37 +00:00
}
2021-06-23 06:03:34 +00:00
using ( var fs = File . OpenWrite ( tempPath ) )
ms . WriteTo ( fs ) ;
2021-10-27 04:04:41 +00:00
string databasePath = storage . GetFullPath ( database_name ) ;
string databaseBackupPath = storage . GetFullPath ( database_backup_name ) ;
2021-06-23 06:09:42 +00:00
// Back up the existing database, clearing any existing backup.
if ( File . Exists ( databaseBackupPath ) )
File . Delete ( databaseBackupPath ) ;
if ( File . Exists ( databasePath ) )
File . Move ( databasePath , databaseBackupPath ) ;
// Move the new database in-place of the existing one.
File . Move ( tempPath , databasePath ) ;
// If everything succeeded up to this point, remove the backup file.
if ( File . Exists ( databaseBackupPath ) )
File . Delete ( databaseBackupPath ) ;
2020-09-01 08:28:41 +00:00
}
2020-09-01 10:33:06 +00:00
2020-09-02 14:42:44 +00:00
if ( saveFailures < 10 )
saveFailures = 0 ;
2020-09-02 14:31:37 +00:00
return true ;
}
catch ( Exception e )
{
// Since this code is not thread-safe, we may run into random exceptions (such as collection enumeration or out of range indexing).
2020-09-02 14:42:44 +00:00
// Failures are thus only alerted if they exceed a threshold (once) to indicate "actual" errors having occurred.
if ( + + saveFailures = = 10 )
2020-09-02 14:47:42 +00:00
Logger . Error ( e , "Failed to save collection database!" ) ;
2020-09-01 08:28:41 +00:00
}
2020-09-02 14:31:37 +00:00
return false ;
}
2020-09-01 08:28:41 +00:00
}
2020-09-02 14:32:08 +00:00
protected override void Dispose ( bool isDisposing )
{
base . Dispose ( isDisposing ) ;
save ( ) ;
}
2020-09-01 08:28:41 +00:00
}
}