2019-06-20 00:33:51 +08:00
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
2019-01-24 16:43:03 +08:00
// See the LICENCE file in the repository root for full licence text.
2018-04-13 17:19:50 +08:00
using System ;
using System.Collections.Generic ;
using System.IO ;
using System.Linq ;
2019-05-28 17:59:21 +08:00
using System.Threading ;
2018-08-31 17:28:53 +08:00
using System.Threading.Tasks ;
2019-09-20 20:46:43 +08:00
using Humanizer ;
2018-07-18 11:58:28 +08:00
using JetBrains.Annotations ;
2018-04-13 17:19:50 +08:00
using Microsoft.EntityFrameworkCore ;
2019-02-28 12:09:38 +08:00
using osu.Framework ;
2020-05-19 15:44:22 +08:00
using osu.Framework.Bindables ;
2018-11-28 18:16:05 +08:00
using osu.Framework.Extensions ;
2019-09-19 16:35:45 +08:00
using osu.Framework.Extensions.IEnumerableExtensions ;
2018-04-13 17:19:50 +08:00
using osu.Framework.Logging ;
using osu.Framework.Platform ;
2019-05-28 17:59:21 +08:00
using osu.Framework.Threading ;
2018-04-13 17:19:50 +08:00
using osu.Game.IO ;
using osu.Game.IO.Archives ;
using osu.Game.IPC ;
using osu.Game.Overlays.Notifications ;
2020-05-24 12:44:11 +08:00
using SharpCompress.Archives.Zip ;
2018-04-13 17:19:50 +08:00
using FileInfo = osu . Game . IO . FileInfo ;
namespace osu.Game.Database
{
/// <summary>
/// Encapsulates a model store class to give it import functionality.
/// Adds cross-functionality with <see cref="FileStore"/> to give access to the central file store for the provided model.
/// </summary>
/// <typeparam name="TModel">The model type.</typeparam>
/// <typeparam name="TFileModel">The associated file join type.</typeparam>
2019-08-08 17:26:03 +08:00
public abstract class ArchiveModelManager < TModel , TFileModel > : ICanAcceptFiles , IModelManager < TModel >
2018-04-13 17:19:50 +08:00
where TModel : class , IHasFiles < TFileModel > , IHasPrimaryKey , ISoftDelete
2020-01-14 17:43:06 +08:00
where TFileModel : class , INamedFileInfo , new ( )
2018-04-13 17:19:50 +08:00
{
2019-08-08 17:26:03 +08:00
private const int import_queue_request_concurrency = 1 ;
2021-02-17 18:09:38 +08:00
/// <summary>
/// The size of a batch import operation before considering it a lower priority operation.
/// </summary>
private const int low_priority_import_batch_size = 1 ;
2019-08-08 17:26:03 +08:00
/// <summary>
/// A singleton scheduler shared by all <see cref="ArchiveModelManager{TModel,TFileModel}"/>.
/// </summary>
/// <remarks>
/// This scheduler generally performs IO and CPU intensive work so concurrency is limited harshly.
/// It is mainly being used as a queue mechanism for large imports.
/// </remarks>
private static readonly ThreadedTaskScheduler import_scheduler = new ThreadedTaskScheduler ( import_queue_request_concurrency , nameof ( ArchiveModelManager < TModel , TFileModel > ) ) ;
2021-02-17 18:09:38 +08:00
/// <summary>
/// A second scheduler for lower priority imports.
/// For simplicity, these will just run in parallel with normal priority imports, but a future refactor would see this implemented via a custom scheduler/queue.
/// See https://gist.github.com/peppy/f0e118a14751fc832ca30dd48ba3876b for an incomplete version of this.
/// </summary>
private static readonly ThreadedTaskScheduler import_scheduler_low_priority = new ThreadedTaskScheduler ( import_queue_request_concurrency , nameof ( ArchiveModelManager < TModel , TFileModel > ) ) ;
2018-04-13 17:19:50 +08:00
/// <summary>
/// Set an endpoint for notifications to be posted to.
/// </summary>
public Action < Notification > PostNotification { protected get ; set ; }
/// <summary>
2020-05-27 15:08:47 +08:00
/// Fired when a new or updated <typeparamref name="TModel"/> becomes available in the database.
2018-04-13 17:19:50 +08:00
/// This is not guaranteed to run on the update thread.
/// </summary>
2020-05-27 15:08:47 +08:00
public IBindable < WeakReference < TModel > > ItemUpdated = > itemUpdated ;
2020-05-19 15:44:22 +08:00
2020-05-27 15:08:47 +08:00
private readonly Bindable < WeakReference < TModel > > itemUpdated = new Bindable < WeakReference < TModel > > ( ) ;
2018-04-13 17:19:50 +08:00
/// <summary>
2019-11-17 20:48:23 +08:00
/// Fired when a <typeparamref name="TModel"/> is removed from the database.
2018-04-13 17:19:50 +08:00
/// This is not guaranteed to run on the update thread.
/// </summary>
2020-05-19 15:44:22 +08:00
public IBindable < WeakReference < TModel > > ItemRemoved = > itemRemoved ;
private readonly Bindable < WeakReference < TModel > > itemRemoved = new Bindable < WeakReference < TModel > > ( ) ;
2018-04-13 17:19:50 +08:00
2020-10-02 15:17:10 +08:00
public virtual IEnumerable < string > HandledExtensions = > new [ ] { ".zip" } ;
2018-04-13 17:19:50 +08:00
2019-02-28 12:09:38 +08:00
public virtual bool SupportsImportFromStable = > RuntimeInfo . IsDesktop ;
2020-01-14 18:23:34 +08:00
protected readonly FileStore Files ;
2018-04-13 17:19:50 +08:00
protected readonly IDatabaseContextFactory ContextFactory ;
protected readonly MutableDatabaseBackedStore < TModel > ModelStore ;
// ReSharper disable once NotAccessedField.Local (we should keep a reference to this so it is not finalised)
private ArchiveImportIPCChannel ipc ;
2020-05-24 12:44:11 +08:00
private readonly Storage exportStorage ;
2019-03-01 09:25:21 +08:00
protected ArchiveModelManager ( Storage storage , IDatabaseContextFactory contextFactory , MutableDatabaseBackedStoreWithFileIncludes < TModel , TFileModel > modelStore , IIpcHost importHost = null )
2018-04-13 17:19:50 +08:00
{
ContextFactory = contextFactory ;
ModelStore = modelStore ;
2020-05-27 15:08:47 +08:00
ModelStore . ItemUpdated + = item = > handleEvent ( ( ) = > itemUpdated . Value = new WeakReference < TModel > ( item ) ) ;
2020-05-19 15:44:22 +08:00
ModelStore . ItemRemoved + = item = > handleEvent ( ( ) = > itemRemoved . Value = new WeakReference < TModel > ( item ) ) ;
2018-04-13 17:19:50 +08:00
2020-05-24 12:44:11 +08:00
exportStorage = storage . GetStorageForDirectory ( "exports" ) ;
2018-04-13 17:19:50 +08:00
Files = new FileStore ( contextFactory , storage ) ;
if ( importHost ! = null )
ipc = new ArchiveImportIPCChannel ( importHost , this ) ;
ModelStore . Cleanup ( ) ;
}
/// <summary>
2019-11-17 20:48:23 +08:00
/// Import one or more <typeparamref name="TModel"/> items from filesystem <paramref name="paths"/>.
2018-04-13 17:19:50 +08:00
/// </summary>
2021-02-17 18:09:38 +08:00
/// <remarks>
/// This will be treated as a low priority import if more than one path is specified; use <see cref="Import(ImportTask[])"/> to always import at standard priority.
/// This will post notifications tracking progress.
/// </remarks>
2018-04-13 17:19:50 +08:00
/// <param name="paths">One or more archive locations on disk.</param>
2019-06-12 16:08:50 +08:00
public Task Import ( params string [ ] paths )
2018-04-13 17:19:50 +08:00
{
2019-02-25 17:24:06 +08:00
var notification = new ProgressNotification { State = ProgressNotificationState . Active } ;
2018-04-13 17:19:50 +08:00
PostNotification ? . Invoke ( notification ) ;
2019-05-28 17:59:21 +08:00
2020-12-07 17:00:45 +08:00
return Import ( notification , paths . Select ( p = > new ImportTask ( p ) ) . ToArray ( ) ) ;
2019-02-25 17:24:06 +08:00
}
2020-12-16 21:28:16 +08:00
public Task Import ( params ImportTask [ ] tasks )
2020-12-07 17:00:45 +08:00
{
var notification = new ProgressNotification { State = ProgressNotificationState . Active } ;
PostNotification ? . Invoke ( notification ) ;
2020-12-16 21:28:16 +08:00
return Import ( notification , tasks ) ;
2020-12-07 17:00:45 +08:00
}
protected async Task < IEnumerable < TModel > > Import ( ProgressNotification notification , params ImportTask [ ] tasks )
2019-02-25 17:24:06 +08:00
{
notification . Progress = 0 ;
2019-09-22 02:00:24 +08:00
notification . Text = $"{HumanisedModelName.Humanize(LetterCasing.Title)} import is initialising..." ;
2018-04-13 17:19:50 +08:00
int current = 0 ;
2019-04-01 11:16:05 +08:00
2019-06-10 12:19:58 +08:00
var imported = new List < TModel > ( ) ;
2021-02-17 18:09:38 +08:00
bool isLowPriorityImport = tasks . Length > low_priority_import_batch_size ;
2020-12-07 17:00:45 +08:00
await Task . WhenAll ( tasks . Select ( async task = >
2018-04-13 17:19:50 +08:00
{
2019-06-10 15:14:42 +08:00
notification . CancellationToken . ThrowIfCancellationRequested ( ) ;
2018-04-13 17:19:50 +08:00
try
{
2021-02-17 18:09:38 +08:00
var model = await Import ( task , isLowPriorityImport , notification . CancellationToken ) ;
2018-12-19 03:49:53 +08:00
2019-06-10 16:12:25 +08:00
lock ( imported )
2019-06-10 15:14:42 +08:00
{
2019-07-05 13:47:55 +08:00
if ( model ! = null )
imported . Add ( model ) ;
2019-06-12 16:10:55 +08:00
current + + ;
2018-04-13 17:19:50 +08:00
2020-12-07 17:00:45 +08:00
notification . Text = $"Imported {current} of {tasks.Length} {HumanisedModelName}s" ;
notification . Progress = ( float ) current / tasks . Length ;
2019-06-10 15:14:42 +08:00
}
2019-06-10 12:19:58 +08:00
}
2019-06-10 16:12:25 +08:00
catch ( TaskCanceledException )
{
throw ;
2018-04-13 17:19:50 +08:00
}
catch ( Exception e )
{
2020-12-07 17:00:45 +08:00
Logger . Error ( e , $@"Could not import ({task})" , LoggingTarget . Database ) ;
2018-04-13 17:19:50 +08:00
}
2019-06-10 16:12:25 +08:00
} ) ) ;
2018-04-13 17:19:50 +08:00
2018-09-07 17:14:23 +08:00
if ( imported . Count = = 0 )
{
2019-09-22 02:00:24 +08:00
notification . Text = $"{HumanisedModelName.Humanize(LetterCasing.Title)} import failed!" ;
2018-09-07 17:14:23 +08:00
notification . State = ProgressNotificationState . Cancelled ;
}
else
{
2019-02-25 17:24:06 +08:00
notification . CompletionText = imported . Count = = 1
? $"Imported {imported.First()}!"
2019-07-05 13:47:55 +08:00
: $"Imported {imported.Count} {HumanisedModelName}s!" ;
2019-02-25 17:24:06 +08:00
if ( imported . Count > 0 & & PresentImport ! = null )
2018-09-07 17:18:03 +08:00
{
2019-02-25 17:24:06 +08:00
notification . CompletionText + = " Click to view." ;
notification . CompletionClickAction = ( ) = >
{
PresentImport ? . Invoke ( imported ) ;
return true ;
} ;
}
2018-09-07 17:14:23 +08:00
notification . State = ProgressNotificationState . Completed ;
}
2019-10-28 16:41:42 +08:00
return imported ;
2018-04-13 17:19:50 +08:00
}
2019-01-29 17:34:10 +08:00
/// <summary>
2019-11-17 20:48:23 +08:00
/// Import one <typeparamref name="TModel"/> from the filesystem and delete the file on success.
2020-12-07 17:00:45 +08:00
/// Note that this bypasses the UI flow and should only be used for special cases or testing.
2019-01-29 17:34:10 +08:00
/// </summary>
2020-12-08 11:48:59 +08:00
/// <param name="task">The <see cref="ImportTask"/> containing data about the <typeparamref name="TModel"/> to import.</param>
2021-02-17 18:09:38 +08:00
/// <param name="lowPriority">Whether this is a low priority import.</param>
2019-05-28 17:59:21 +08:00
/// <param name="cancellationToken">An optional cancellation token.</param>
2019-01-29 17:34:10 +08:00
/// <returns>The imported model, if successful.</returns>
2021-02-17 18:09:38 +08:00
internal async Task < TModel > Import ( ImportTask task , bool lowPriority = false , CancellationToken cancellationToken = default )
2019-01-29 17:34:10 +08:00
{
2019-06-10 12:37:20 +08:00
cancellationToken . ThrowIfCancellationRequested ( ) ;
2019-01-29 17:34:10 +08:00
TModel import ;
2020-12-07 17:00:45 +08:00
using ( ArchiveReader reader = task . GetReader ( ) )
2021-02-17 18:09:38 +08:00
import = await Import ( reader , lowPriority , cancellationToken ) ;
2019-01-29 17:34:10 +08:00
// We may or may not want to delete the file depending on where it is stored.
// e.g. reconstructing/repairing database with items from default storage.
// Also, not always a single file, i.e. for LegacyFilesystemReader
// TODO: Add a check to prevent files from storage to be deleted.
try
{
2020-12-07 17:00:45 +08:00
if ( import ! = null & & File . Exists ( task . Path ) & & ShouldDeleteArchive ( task . Path ) )
File . Delete ( task . Path ) ;
2019-01-29 17:34:10 +08:00
}
catch ( Exception e )
{
2020-12-07 17:00:45 +08:00
LogForModel ( import , $@"Could not delete original file after import ({task})" , e ) ;
2019-01-29 17:34:10 +08:00
}
return import ;
}
2019-02-25 17:24:06 +08:00
/// <summary>
/// Fired when the user requests to view the resulting import.
/// </summary>
public Action < IEnumerable < TModel > > PresentImport ;
2018-09-07 15:30:11 +08:00
2018-04-13 17:19:50 +08:00
/// <summary>
2021-02-17 16:04:43 +08:00
/// Silently import an item from an <see cref="ArchiveReader"/>.
2018-04-13 17:19:50 +08:00
/// </summary>
/// <param name="archive">The archive to be imported.</param>
2021-02-17 18:09:38 +08:00
/// <param name="lowPriority">Whether this is a low priority import.</param>
2019-05-28 17:59:21 +08:00
/// <param name="cancellationToken">An optional cancellation token.</param>
2021-02-17 18:09:38 +08:00
public Task < TModel > Import ( ArchiveReader archive , bool lowPriority = false , CancellationToken cancellationToken = default )
2018-04-13 17:19:50 +08:00
{
2019-06-10 12:37:20 +08:00
cancellationToken . ThrowIfCancellationRequested ( ) ;
2019-06-10 18:34:32 +08:00
TModel model = null ;
2019-06-10 16:12:37 +08:00
2018-07-18 11:58:28 +08:00
try
{
2019-06-10 16:12:37 +08:00
model = CreateModel ( archive ) ;
2018-11-28 18:16:05 +08:00
2020-01-02 12:20:38 +08:00
if ( model = = null )
return Task . FromResult < TModel > ( null ) ;
2018-07-18 11:58:28 +08:00
}
2019-06-10 12:37:20 +08:00
catch ( TaskCanceledException )
{
throw ;
2018-07-18 11:58:28 +08:00
}
catch ( Exception e )
{
2019-06-10 18:34:32 +08:00
LogForModel ( model , $"Model creation of {archive.Name} failed." , e ) ;
2018-07-18 11:58:28 +08:00
return null ;
}
2019-06-10 16:12:37 +08:00
2021-02-17 18:09:38 +08:00
return Import ( model , archive , lowPriority , cancellationToken ) ;
2018-07-18 11:58:28 +08:00
}
2018-11-28 18:16:05 +08:00
/// <summary>
/// Any file extensions which should be included in hash creation.
/// Generally should include all file types which determine the file's uniqueness.
/// Large files should be avoided if possible.
/// </summary>
2020-09-11 14:06:10 +08:00
/// <remarks>
/// This is only used by the default hash implementation. If <see cref="ComputeHash"/> is overridden, it will not be used.
/// </remarks>
2018-11-28 18:16:05 +08:00
protected abstract string [ ] HashableFileTypes { get ; }
2020-05-02 13:35:12 +08:00
internal static void LogForModel ( TModel model , string message , Exception e = null )
2019-06-10 18:34:32 +08:00
{
string prefix = $"[{(model?.Hash ?? " ? ? ? ? ? ").Substring(0, 5)}]" ;
if ( e ! = null )
Logger . Error ( e , $"{prefix} {message}" , LoggingTarget . Database ) ;
else
Logger . Log ( $"{prefix} {message}" , LoggingTarget . Database ) ;
}
2018-11-28 18:16:05 +08:00
/// <summary>
2018-11-30 14:09:15 +08:00
/// Create a SHA-2 hash from the provided archive based on file content of all files matching <see cref="HashableFileTypes"/>.
2018-11-28 18:16:05 +08:00
/// </summary>
2019-12-26 17:44:31 +08:00
/// <remarks>
/// In the case of no matching files, a hash will be generated from the passed archive's <see cref="ArchiveReader.Name"/>.
/// </remarks>
2020-09-11 14:06:10 +08:00
protected virtual string ComputeHash ( TModel item , ArchiveReader reader = null )
2018-11-28 18:16:05 +08:00
{
// for now, concatenate all .osu files in the set to create a unique hash.
MemoryStream hashable = new MemoryStream ( ) ;
2019-11-11 20:05:36 +08:00
2020-10-16 11:58:34 +08:00
foreach ( TFileModel file in item . Files . Where ( f = > HashableFileTypes . Any ( ext = > f . Filename . EndsWith ( ext , StringComparison . OrdinalIgnoreCase ) ) ) . OrderBy ( f = > f . Filename ) )
2019-11-11 19:53:22 +08:00
{
2020-01-08 11:36:07 +08:00
using ( Stream s = Files . Store . GetStream ( file . FileInfo . StoragePath ) )
2018-11-28 18:16:05 +08:00
s . CopyTo ( hashable ) ;
2019-11-11 19:53:22 +08:00
}
2018-11-28 18:16:05 +08:00
2020-01-08 11:36:07 +08:00
if ( hashable . Length > 0 )
return hashable . ComputeSHA2Hash ( ) ;
if ( reader ! = null )
return reader . Name . ComputeSHA2Hash ( ) ;
return item . Hash ;
2018-11-28 18:16:05 +08:00
}
2018-07-18 11:58:28 +08:00
/// <summary>
2021-02-17 16:04:43 +08:00
/// Silently import an item from a <typeparamref name="TModel"/>.
2018-07-18 11:58:28 +08:00
/// </summary>
/// <param name="item">The model to be imported.</param>
/// <param name="archive">An optional archive to use for model population.</param>
2021-02-17 18:09:38 +08:00
/// <param name="lowPriority">Whether this is a low priority import.</param>
2019-05-28 17:59:21 +08:00
/// <param name="cancellationToken">An optional cancellation token.</param>
2021-02-17 18:09:38 +08:00
public virtual async Task < TModel > Import ( TModel item , ArchiveReader archive = null , bool lowPriority = false , CancellationToken cancellationToken = default ) = > await Task . Factory . StartNew ( async ( ) = >
2018-07-18 11:58:28 +08:00
{
2019-06-10 12:37:20 +08:00
cancellationToken . ThrowIfCancellationRequested ( ) ;
2018-05-28 20:45:05 +08:00
delayEvents ( ) ;
2018-05-28 18:56:27 +08:00
2019-06-10 15:14:42 +08:00
void rollback ( )
{
if ( ! Delete ( item ) )
{
// We may have not yet added the model to the underlying table, but should still clean up files.
2019-06-10 18:34:32 +08:00
LogForModel ( item , "Dereferencing files for incomplete import." ) ;
2019-06-10 15:14:42 +08:00
Files . Dereference ( item . Files . Select ( f = > f . FileInfo ) . ToArray ( ) ) ;
}
}
2018-05-28 18:56:27 +08:00
try
2018-04-13 17:19:50 +08:00
{
2019-06-10 18:34:32 +08:00
LogForModel ( item , "Beginning import..." ) ;
2018-08-17 12:50:27 +08:00
2019-06-10 13:13:36 +08:00
item . Files = archive ! = null ? createFileInfos ( archive , Files ) : new List < TFileModel > ( ) ;
2020-09-14 22:31:03 +08:00
item . Hash = ComputeHash ( item , archive ) ;
2019-05-28 17:59:21 +08:00
2019-06-10 15:14:42 +08:00
await Populate ( item , archive , cancellationToken ) ;
2018-08-17 12:50:27 +08:00
2018-05-28 18:56:27 +08:00
using ( var write = ContextFactory . GetForWrite ( ) ) // used to share a context for full import. keep in mind this will block all writes.
{
2018-05-29 12:48:14 +08:00
try
{
if ( ! write . IsTransactionLeader ) throw new InvalidOperationException ( $"Ensure there is no parent transaction so errors can correctly be handled by {this}" ) ;
2018-04-13 17:19:50 +08:00
2018-05-29 12:48:14 +08:00
var existing = CheckForExisting ( item ) ;
2018-04-13 17:19:50 +08:00
2018-05-29 15:14:09 +08:00
if ( existing ! = null )
{
2020-06-03 17:03:10 +08:00
if ( CanReuseExisting ( existing , item ) )
2019-03-11 16:03:01 +08:00
{
Undelete ( existing ) ;
2019-06-12 19:41:02 +08:00
LogForModel ( item , $"Found existing {HumanisedModelName} for {item} (ID {existing.ID}) – skipping import." ) ;
2019-06-10 16:12:37 +08:00
// existing item will be used; rollback new import and exit early.
2019-06-10 15:14:42 +08:00
rollback ( ) ;
2019-06-10 16:12:37 +08:00
flushEvents ( true ) ;
2019-03-11 16:03:01 +08:00
return existing ;
}
2019-06-10 18:34:32 +08:00
Delete ( existing ) ;
ModelStore . PurgeDeletable ( s = > s . ID = = existing . ID ) ;
2018-05-29 15:14:09 +08:00
}
2018-04-13 17:19:50 +08:00
2019-03-11 16:03:01 +08:00
PreImport ( item ) ;
2018-04-13 17:19:50 +08:00
2018-05-29 12:48:14 +08:00
// import to store
2019-02-25 17:24:06 +08:00
ModelStore . Add ( item ) ;
2018-05-29 12:48:14 +08:00
}
catch ( Exception e )
{
write . Errors . Add ( e ) ;
throw ;
}
2018-05-28 18:56:27 +08:00
}
2018-05-29 15:14:09 +08:00
2019-06-10 18:34:32 +08:00
LogForModel ( item , "Import successfully completed!" ) ;
2018-04-13 17:19:50 +08:00
}
2018-05-29 17:37:45 +08:00
catch ( Exception e )
2018-05-28 18:56:27 +08:00
{
2019-06-10 15:14:42 +08:00
if ( ! ( e is TaskCanceledException ) )
2019-06-10 18:34:32 +08:00
LogForModel ( item , "Database import or population failed and has been rolled back." , e ) ;
2019-06-10 15:14:42 +08:00
rollback ( ) ;
2019-06-10 16:12:37 +08:00
flushEvents ( false ) ;
throw ;
2018-05-29 18:43:52 +08:00
}
2018-05-28 18:56:27 +08:00
2019-06-10 16:12:37 +08:00
flushEvents ( true ) ;
2018-05-28 18:56:27 +08:00
return item ;
2021-02-17 18:09:38 +08:00
} , cancellationToken , TaskCreationOptions . HideScheduler , lowPriority ? import_scheduler_low_priority : import_scheduler ) . Unwrap ( ) ;
2018-04-13 17:19:50 +08:00
2020-05-24 12:44:11 +08:00
/// <summary>
2020-05-24 22:09:38 +08:00
/// Exports an item to a legacy (.zip based) package.
2020-05-24 12:44:11 +08:00
/// </summary>
/// <param name="item">The item to export.</param>
public void Export ( TModel item )
{
var retrievedItem = ModelStore . ConsumableItems . FirstOrDefault ( s = > s . ID = = item . ID ) ;
if ( retrievedItem = = null )
2020-05-24 22:09:38 +08:00
throw new ArgumentException ( "Specified model could not be found" , nameof ( item ) ) ;
2020-05-24 12:44:11 +08:00
using ( var archive = ZipArchive . Create ( ) )
{
foreach ( var file in retrievedItem . Files )
archive . AddEntry ( file . Filename , Files . Storage . GetStream ( file . FileInfo . StoragePath ) ) ;
2020-05-24 21:34:31 +08:00
using ( var outputStream = exportStorage . GetStream ( $"{getValidFilename(item.ToString())}{HandledExtensions.First()}" , FileAccess . Write , FileMode . Create ) )
2020-05-24 12:44:11 +08:00
archive . SaveTo ( outputStream ) ;
exportStorage . OpenInNativeExplorer ( ) ;
}
}
2020-09-01 14:50:08 +08:00
/// <summary>
2020-09-25 12:10:04 +08:00
/// Replace an existing file with a new version.
2020-09-01 14:50:08 +08:00
/// </summary>
/// <param name="model">The item to operate on.</param>
2020-09-25 12:10:04 +08:00
/// <param name="file">The existing file to be replaced.</param>
2020-09-01 14:50:08 +08:00
/// <param name="contents">The new file contents.</param>
2020-09-25 12:10:04 +08:00
/// <param name="filename">An optional filename for the new file. Will use the previous filename if not specified.</param>
public void ReplaceFile ( TModel model , TFileModel file , Stream contents , string filename = null )
{
using ( ContextFactory . GetForWrite ( ) )
{
DeleteFile ( model , file ) ;
AddFile ( model , contents , filename ? ? file . Filename ) ;
}
}
/// <summary>
/// Delete new file.
/// </summary>
/// <param name="model">The item to operate on.</param>
/// <param name="file">The existing file to be deleted.</param>
public void DeleteFile ( TModel model , TFileModel file )
2020-01-08 11:36:07 +08:00
{
2020-01-14 17:43:06 +08:00
using ( var usage = ContextFactory . GetForWrite ( ) )
2020-01-08 11:36:07 +08:00
{
2020-01-14 17:43:06 +08:00
// Dereference the existing file info, since the file model will be removed.
2020-09-01 14:50:08 +08:00
if ( file . FileInfo ! = null )
2020-09-25 17:40:20 +08:00
{
2020-09-01 14:50:08 +08:00
Files . Dereference ( file . FileInfo ) ;
2020-01-14 17:43:06 +08:00
2020-09-25 17:40:20 +08:00
// This shouldn't be required, but here for safety in case the provided TModel is not being change tracked
// Definitely can be removed once we rework the database backend.
usage . Context . Set < TFileModel > ( ) . Remove ( file ) ;
}
2020-01-14 17:43:06 +08:00
2020-01-09 18:42:47 +08:00
model . Files . Remove ( file ) ;
2020-09-25 12:10:04 +08:00
}
}
/// <summary>
/// Add a new file.
/// </summary>
/// <param name="model">The item to operate on.</param>
/// <param name="contents">The new file contents.</param>
/// <param name="filename">The filename for the new file.</param>
public void AddFile ( TModel model , Stream contents , string filename )
{
using ( ContextFactory . GetForWrite ( ) )
{
2020-01-09 18:42:47 +08:00
model . Files . Add ( new TFileModel
{
2020-09-25 12:10:04 +08:00
Filename = filename ,
2020-01-09 18:42:47 +08:00
FileInfo = Files . Add ( contents )
} ) ;
2020-01-08 11:36:07 +08:00
2020-01-09 18:42:47 +08:00
Update ( model ) ;
2020-01-08 11:36:07 +08:00
}
}
2018-04-13 17:19:50 +08:00
/// <summary>
/// Perform an update of the specified item.
2020-01-08 11:36:07 +08:00
/// TODO: Support file additions/removals.
2018-04-13 17:19:50 +08:00
/// </summary>
/// <param name="item">The item to update.</param>
2020-01-08 11:36:07 +08:00
public void Update ( TModel item )
{
using ( ContextFactory . GetForWrite ( ) )
{
2020-09-11 14:06:10 +08:00
item . Hash = ComputeHash ( item ) ;
2020-01-08 11:36:07 +08:00
ModelStore . Update ( item ) ;
}
}
2020-06-02 16:22:09 +08:00
2018-04-13 17:19:50 +08:00
/// <summary>
/// Delete an item from the manager.
/// Is a no-op for already deleted items.
/// </summary>
/// <param name="item">The item to delete.</param>
2018-09-21 11:21:27 +08:00
/// <returns>false if no operation was performed</returns>
public bool Delete ( TModel item )
2018-04-13 17:19:50 +08:00
{
2018-05-30 12:43:43 +08:00
using ( ContextFactory . GetForWrite ( ) )
2018-04-13 17:19:50 +08:00
{
// re-fetch the model on the import context.
2018-09-21 08:01:04 +08:00
var foundModel = queryModel ( ) . Include ( s = > s . Files ) . ThenInclude ( f = > f . FileInfo ) . FirstOrDefault ( s = > s . ID = = item . ID ) ;
2018-04-13 17:19:50 +08:00
2018-09-21 11:21:27 +08:00
if ( foundModel = = null | | foundModel . DeletePending ) return false ;
2018-04-13 17:19:50 +08:00
if ( ModelStore . Delete ( foundModel ) )
Files . Dereference ( foundModel . Files . Select ( f = > f . FileInfo ) . ToArray ( ) ) ;
2018-09-21 11:21:27 +08:00
return true ;
2018-04-13 17:19:50 +08:00
}
}
/// <summary>
/// Delete multiple items.
/// This will post notifications tracking progress.
/// </summary>
2019-05-09 14:15:02 +08:00
public void Delete ( List < TModel > items , bool silent = false )
2018-04-13 17:19:50 +08:00
{
if ( items . Count = = 0 ) return ;
var notification = new ProgressNotification
{
Progress = 0 ,
2019-06-12 19:41:02 +08:00
Text = $"Preparing to delete all {HumanisedModelName}s..." ,
CompletionText = $"Deleted all {HumanisedModelName}s!" ,
2018-04-13 17:19:50 +08:00
State = ProgressNotificationState . Active ,
} ;
2019-05-09 14:15:02 +08:00
if ( ! silent )
PostNotification ? . Invoke ( notification ) ;
2018-04-13 17:19:50 +08:00
int i = 0 ;
2019-10-02 17:48:50 +08:00
foreach ( var b in items )
2018-04-13 17:19:50 +08:00
{
2019-10-02 17:48:50 +08:00
if ( notification . State = = ProgressNotificationState . Cancelled )
// user requested abort
return ;
2018-04-13 17:19:50 +08:00
2019-10-02 17:48:50 +08:00
notification . Text = $"Deleting {HumanisedModelName}s ({++i} of {items.Count})" ;
2018-04-13 17:19:50 +08:00
2019-10-02 17:48:50 +08:00
Delete ( b ) ;
2018-04-13 17:19:50 +08:00
2019-10-02 17:48:50 +08:00
notification . Progress = ( float ) i / items . Count ;
2018-04-13 17:19:50 +08:00
}
notification . State = ProgressNotificationState . Completed ;
}
/// <summary>
/// Restore multiple items that were previously deleted.
/// This will post notifications tracking progress.
/// </summary>
2019-05-09 14:15:02 +08:00
public void Undelete ( List < TModel > items , bool silent = false )
2018-04-13 17:19:50 +08:00
{
if ( ! items . Any ( ) ) return ;
var notification = new ProgressNotification
{
CompletionText = "Restored all deleted items!" ,
Progress = 0 ,
State = ProgressNotificationState . Active ,
} ;
2019-05-09 14:15:02 +08:00
if ( ! silent )
PostNotification ? . Invoke ( notification ) ;
2018-04-13 17:19:50 +08:00
int i = 0 ;
2019-10-03 10:23:21 +08:00
foreach ( var item in items )
2018-04-13 17:19:50 +08:00
{
2019-10-03 10:23:21 +08:00
if ( notification . State = = ProgressNotificationState . Cancelled )
// user requested abort
return ;
2018-04-13 17:19:50 +08:00
2019-10-03 10:23:21 +08:00
notification . Text = $"Restoring ({++i} of {items.Count})" ;
2018-04-13 17:19:50 +08:00
2019-10-03 10:23:21 +08:00
Undelete ( item ) ;
2018-04-13 17:19:50 +08:00
2019-10-03 10:23:21 +08:00
notification . Progress = ( float ) i / items . Count ;
2018-04-13 17:19:50 +08:00
}
notification . State = ProgressNotificationState . Completed ;
}
/// <summary>
/// Restore an item that was previously deleted. Is a no-op if the item is not in a deleted state, or has its protected flag set.
/// </summary>
/// <param name="item">The item to restore</param>
public void Undelete ( TModel item )
{
using ( var usage = ContextFactory . GetForWrite ( ) )
{
usage . Context . ChangeTracker . AutoDetectChangesEnabled = false ;
if ( ! ModelStore . Undelete ( item ) ) return ;
Files . Reference ( item . Files . Select ( f = > f . FileInfo ) . ToArray ( ) ) ;
usage . Context . ChangeTracker . AutoDetectChangesEnabled = true ;
}
}
/// <summary>
/// Create all required <see cref="FileInfo"/>s for the provided archive, adding them to the global file store.
/// </summary>
private List < TFileModel > createFileInfos ( ArchiveReader reader , FileStore files )
{
var fileInfos = new List < TFileModel > ( ) ;
2019-09-19 16:35:45 +08:00
string prefix = reader . Filenames . GetCommonPrefix ( ) ;
2020-10-16 17:27:02 +08:00
if ( ! ( prefix . EndsWith ( '/' ) | | prefix . EndsWith ( '\\' ) ) )
2019-09-19 16:35:45 +08:00
prefix = string . Empty ;
2018-04-13 17:19:50 +08:00
// import files to manager
foreach ( string file in reader . Filenames )
2019-11-11 19:53:22 +08:00
{
2018-04-13 17:19:50 +08:00
using ( Stream s = reader . GetStream ( file ) )
2019-11-11 19:53:22 +08:00
{
2018-04-13 17:19:50 +08:00
fileInfos . Add ( new TFileModel
{
2019-12-11 16:06:56 +08:00
Filename = file . Substring ( prefix . Length ) . ToStandardisedPath ( ) ,
2018-04-13 17:19:50 +08:00
FileInfo = files . Add ( s )
} ) ;
2019-11-11 19:53:22 +08:00
}
}
2018-04-13 17:19:50 +08:00
return fileInfos ;
}
2018-08-31 17:28:53 +08:00
#region osu - stable import
/// <summary>
/// Set a storage with access to an osu-stable install for import purposes.
/// </summary>
2021-01-25 02:46:10 +08:00
public Func < StableStorage > GetStableStorage { private get ; set ; }
2018-08-31 17:28:53 +08:00
/// <summary>
/// Denotes whether an osu-stable installation is present to perform automated imports from.
/// </summary>
public bool StableInstallationAvailable = > GetStableStorage ? . Invoke ( ) ! = null ;
/// <summary>
/// The relative path from osu-stable's data directory to import items from.
/// </summary>
protected virtual string ImportFromStablePath = > null ;
2019-06-20 00:33:51 +08:00
/// <summary>
2021-01-27 03:35:42 +08:00
/// Select paths to import from stable where all paths should be absolute. Default implementation iterates all directories in <see cref="ImportFromStablePath"/>.
2019-06-20 00:33:51 +08:00
/// </summary>
2021-02-12 11:48:32 +08:00
protected virtual IEnumerable < string > GetStableImportPaths ( Storage storage ) = > storage . GetDirectories ( ImportFromStablePath )
. Select ( path = > storage . GetFullPath ( path ) ) ;
2019-06-20 00:33:51 +08:00
2019-06-27 20:41:11 +08:00
/// <summary>
2019-07-05 12:49:54 +08:00
/// Whether this specified path should be removed after successful import.
2019-06-27 20:41:11 +08:00
/// </summary>
2019-07-05 12:49:54 +08:00
/// <param name="path">The path for consideration. May be a file or a directory.</param>
/// <returns>Whether to perform deletion.</returns>
protected virtual bool ShouldDeleteArchive ( string path ) = > false ;
2019-06-27 20:41:11 +08:00
2018-08-31 17:28:53 +08: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>
public Task ImportFromStableAsync ( )
{
2021-02-12 11:48:32 +08:00
var stableStorage = GetStableStorage ? . Invoke ( ) ;
2018-08-31 17:28:53 +08:00
2021-02-12 11:48:32 +08:00
if ( stableStorage = = null )
2018-08-31 17:28:53 +08:00
{
Logger . Log ( "No osu!stable installation available!" , LoggingTarget . Information , LogLevel . Error ) ;
return Task . CompletedTask ;
}
2021-02-12 11:48:32 +08:00
var storage = PrepareStableStorage ( stableStorage ) ;
if ( ! storage . ExistsDirectory ( ImportFromStablePath ) )
2018-09-15 21:53:59 +08:00
{
2018-09-18 09:05:28 +08:00
// This handles situations like when the user does not have a Skins folder
2018-09-21 10:50:36 +08:00
Logger . Log ( $"No {ImportFromStablePath} folder available in osu!stable installation" , LoggingTarget . Information , LogLevel . Error ) ;
2018-09-18 09:05:28 +08:00
return Task . CompletedTask ;
}
2021-02-12 11:48:32 +08:00
return Task . Run ( async ( ) = > await Import ( GetStableImportPaths ( storage ) . ToArray ( ) ) ) ;
2018-08-31 17:28:53 +08:00
}
2021-02-12 11:48:32 +08:00
/// <summary>
/// Run any required traversal operations on the stable storage location before performing operations.
/// </summary>
/// <param name="stableStorage">The stable storage.</param>
/// <returns>The usable storage. Return the unchanged <paramref name="stableStorage"/> if no traversal is required.</returns>
protected virtual Storage PrepareStableStorage ( StableStorage stableStorage ) = > stableStorage ;
2018-08-31 17:28:53 +08:00
#endregion
2018-04-13 17:19:50 +08:00
/// <summary>
/// Create a barebones model from the provided archive.
/// Actual expensive population should be done in <see cref="Populate"/>; this should just prepare for duplicate checking.
/// </summary>
/// <param name="archive">The archive to create the model for.</param>
2018-08-25 13:51:42 +08:00
/// <returns>A model populated with minimal information. Returning a null will abort importing silently.</returns>
2018-04-13 17:19:50 +08:00
protected abstract TModel CreateModel ( ArchiveReader archive ) ;
/// <summary>
/// Populate the provided model completely from the given archive.
/// After this method, the model should be in a state ready to commit to a store.
/// </summary>
/// <param name="model">The model to populate.</param>
2018-07-18 11:58:28 +08:00
/// <param name="archive">The archive to use as a reference for population. May be null.</param>
2019-05-28 17:59:21 +08:00
/// <param name="cancellationToken">An optional cancellation token.</param>
2019-06-10 15:13:51 +08:00
protected virtual Task Populate ( TModel model , [ CanBeNull ] ArchiveReader archive , CancellationToken cancellationToken = default ) = > Task . CompletedTask ;
2018-04-13 17:19:50 +08:00
2019-03-11 16:03:01 +08:00
/// <summary>
/// Perform any final actions before the import to database executes.
/// </summary>
/// <param name="model">The model prepared for import.</param>
protected virtual void PreImport ( TModel model )
{
}
2018-11-28 18:01:22 +08:00
/// <summary>
/// Check whether an existing model already exists for a new import item.
/// </summary>
2019-04-25 16:36:17 +08:00
/// <param name="model">The new model proposed for import.</param>
2018-11-28 18:01:22 +08:00
/// <returns>An existing model which matches the criteria to skip importing, else null.</returns>
2019-03-11 16:03:01 +08:00
protected TModel CheckForExisting ( TModel model ) = > model . Hash = = null ? null : ModelStore . ConsumableItems . FirstOrDefault ( b = > b . Hash = = model . Hash ) ;
/// <summary>
2020-06-03 17:03:10 +08:00
/// After an existing <typeparamref name="TModel"/> is found during an import process, the default behaviour is to use/restore the existing
2019-03-11 16:03:01 +08:00
/// item and skip the import. This method allows changing that behaviour.
/// </summary>
/// <param name="existing">The existing model.</param>
/// <param name="import">The newly imported model.</param>
2019-09-20 18:39:21 +08:00
/// <returns>Whether the existing model should be restored and used. Returning false will delete the existing and force a re-import.</returns>
2020-06-03 17:03:10 +08:00
protected virtual bool CanReuseExisting ( TModel existing , TModel import ) = >
2020-06-03 21:35:01 +08:00
// for the best or worst, we copy and import files of a new import before checking whether
// it is a duplicate. so to check if anything has changed, we can just compare all FileInfo IDs.
getIDs ( existing . Files ) . SequenceEqual ( getIDs ( import . Files ) ) & &
getFilenames ( existing . Files ) . SequenceEqual ( getFilenames ( import . Files ) ) ;
2020-06-03 17:03:10 +08:00
2020-06-03 21:35:01 +08:00
private IEnumerable < long > getIDs ( List < TFileModel > files )
2020-06-03 17:03:10 +08:00
{
foreach ( var f in files . OrderBy ( f = > f . Filename ) )
2020-06-03 21:35:01 +08:00
yield return f . FileInfo . ID ;
2020-06-03 17:03:10 +08:00
}
2020-06-03 21:35:01 +08:00
private IEnumerable < string > getFilenames ( List < TFileModel > files )
2020-06-03 17:03:10 +08:00
{
foreach ( var f in files . OrderBy ( f = > f . Filename ) )
2020-06-03 21:35:01 +08:00
yield return f . Filename ;
2020-06-03 17:03:10 +08:00
}
2018-04-13 17:19:50 +08:00
private DbSet < TModel > queryModel ( ) = > ContextFactory . Get ( ) . Set < TModel > ( ) ;
2019-06-12 19:41:02 +08:00
protected virtual string HumanisedModelName = > $"{typeof(TModel).Name.Replace(" Info ", " ").ToLower()}" ;
2019-06-10 17:41:56 +08:00
2019-06-19 00:32:37 +08:00
#region Event handling / delaying
private readonly List < Action > queuedEvents = new List < Action > ( ) ;
/// <summary>
/// Allows delaying of outwards events until an operation is confirmed (at a database level).
/// </summary>
private bool delayingEvents ;
/// <summary>
/// Begin delaying outwards events.
/// </summary>
private void delayEvents ( ) = > delayingEvents = true ;
/// <summary>
/// Flush delayed events and disable delaying.
/// </summary>
/// <param name="perform">Whether the flushed events should be performed.</param>
private void flushEvents ( bool perform )
{
Action [ ] events ;
lock ( queuedEvents )
{
events = queuedEvents . ToArray ( ) ;
queuedEvents . Clear ( ) ;
}
if ( perform )
{
foreach ( var a in events )
a . Invoke ( ) ;
}
delayingEvents = false ;
}
private void handleEvent ( Action a )
{
if ( delayingEvents )
2019-11-11 19:53:22 +08:00
{
2019-06-19 00:32:37 +08:00
lock ( queuedEvents )
queuedEvents . Add ( a ) ;
2019-11-11 19:53:22 +08:00
}
2019-06-19 00:32:37 +08:00
else
a . Invoke ( ) ;
}
#endregion
2020-05-24 21:34:31 +08:00
private string getValidFilename ( string filename )
{
foreach ( char c in Path . GetInvalidFileNameChars ( ) )
filename = filename . Replace ( c , '_' ) ;
return filename ;
}
2018-04-13 17:19:50 +08:00
}
}