1
0
mirror of https://github.com/ppy/osu.git synced 2024-11-14 17:17:24 +08:00
osu-lazer/osu.Game/Database/ArchiveModelManager.cs

924 lines
38 KiB
C#
Raw Normal View History

2019-06-20 00:33:51 +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.
2018-04-13 17:19:50 +08:00
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Humanizer;
using JetBrains.Annotations;
2018-04-13 17:19:50 +08:00
using Microsoft.EntityFrameworkCore;
2020-05-19 15:44:22 +08:00
using osu.Framework.Bindables;
using osu.Framework.Extensions;
using osu.Framework.Extensions.IEnumerableExtensions;
2018-04-13 17:19:50 +08:00
using osu.Framework.Logging;
using osu.Framework.Platform;
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
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>
public abstract class ArchiveModelManager<TModel, TFileModel> : IModelManager<TModel>, IModelFileManager<TModel, TFileModel>
2018-04-13 17:19:50 +08:00
where TModel : class, IHasFiles<TFileModel>, IHasPrimaryKey, ISoftDelete
where TFileModel : class, INamedFileInfo, IHasPrimaryKey, new()
2018-04-13 17:19:50 +08:00
{
private const int import_queue_request_concurrency = 1;
/// <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;
/// <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>));
/// <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
public Action<Notification> PostNotification { protected get; set; }
/// <summary>
/// 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>
public IBindable<WeakReference<TModel>> ItemUpdated => itemUpdated;
2020-05-19 15:44:22 +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
public virtual IEnumerable<string> HandledExtensions => new[] { @".zip" };
2018-04-13 17:19:50 +08:00
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;
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;
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
exportStorage = storage.GetStorageForDirectory(@"exports");
2020-05-24 12:44:11 +08:00
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>
/// <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
{
var notification = new ImportProgressNotification();
2018-04-13 17:19:50 +08:00
PostNotification?.Invoke(notification);
return Import(notification, paths.Select(p => new ImportTask(p)).ToArray());
}
public Task Import(params ImportTask[] tasks)
{
var notification = new ImportProgressNotification();
PostNotification?.Invoke(notification);
return Import(notification, tasks);
}
public async Task<IEnumerable<ILive<TModel>>> Import(ProgressNotification notification, params ImportTask[] tasks)
{
if (tasks.Length == 0)
{
notification.CompletionText = $"No {HumanisedModelName}s were found to import!";
notification.State = ProgressNotificationState.Completed;
return Enumerable.Empty<ILive<TModel>>();
}
notification.Progress = 0;
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
var imported = new List<ILive<TModel>>();
bool isLowPriorityImport = tasks.Length > low_priority_import_batch_size;
try
2018-04-13 17:19:50 +08:00
{
await Task.WhenAll(tasks.Select(async task =>
2018-04-13 17:19:50 +08:00
{
notification.CancellationToken.ThrowIfCancellationRequested();
2018-12-19 03:49:53 +08:00
try
{
var model = await Import(task, isLowPriorityImport, notification.CancellationToken).ConfigureAwait(false);
lock (imported)
{
if (model != null)
imported.Add(model);
current++;
2018-04-13 17:19:50 +08:00
notification.Text = $"Imported {current} of {tasks.Length} {HumanisedModelName}s";
notification.Progress = (float)current / tasks.Length;
}
}
catch (TaskCanceledException)
{
throw;
}
catch (Exception e)
{
Logger.Error(e, $@"Could not import ({task})", LoggingTarget.Database);
}
})).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
if (imported.Count == 0)
2018-04-13 17:19:50 +08:00
{
notification.State = ProgressNotificationState.Cancelled;
return imported;
2018-04-13 17:19:50 +08:00
}
}
2018-04-13 17:19:50 +08:00
2018-09-07 17:14:23 +08:00
if (imported.Count == 0)
{
notification.Text = $"{HumanisedModelName.Humanize(LetterCasing.Title)} import failed!";
2018-09-07 17:14:23 +08:00
notification.State = ProgressNotificationState.Cancelled;
}
else
{
notification.CompletionText = imported.Count == 1
? $"Imported {imported.First().Value}!"
: $"Imported {imported.Count} {HumanisedModelName}s!";
2021-10-04 15:35:55 +08:00
if (imported.Count > 0 && PostImport != null)
{
notification.CompletionText += " Click to view.";
notification.CompletionClickAction = () =>
{
2021-10-04 15:35:55 +08:00
PostImport?.Invoke(imported);
return true;
};
}
2018-09-07 17:14:23 +08:00
notification.State = ProgressNotificationState.Completed;
}
return imported;
2018-04-13 17:19:50 +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.
/// Note that this bypasses the UI flow and should only be used for special cases or testing.
/// </summary>
/// <param name="task">The <see cref="ImportTask"/> containing data about the <typeparamref name="TModel"/> to import.</param>
/// <param name="lowPriority">Whether this is a low priority import.</param>
/// <param name="cancellationToken">An optional cancellation token.</param>
/// <returns>The imported model, if successful.</returns>
public async Task<ILive<TModel>> Import(ImportTask task, bool lowPriority = false, CancellationToken cancellationToken = default)
{
2019-06-10 12:37:20 +08:00
cancellationToken.ThrowIfCancellationRequested();
ILive<TModel> import;
using (ArchiveReader reader = task.GetReader())
import = await Import(reader, lowPriority, cancellationToken).ConfigureAwait(false);
// 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
{
if (import != null && File.Exists(task.Path) && ShouldDeleteArchive(task.Path))
File.Delete(task.Path);
}
catch (Exception e)
{
LogForModel(import?.Value, $@"Could not delete original file after import ({task})", e);
}
return import;
}
2021-10-04 15:35:55 +08:00
public Action<IEnumerable<ILive<TModel>>> PostImport { protected get; set; }
2018-04-13 17:19:50 +08:00
/// <summary>
/// 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>
/// <param name="lowPriority">Whether this is a low priority import.</param>
/// <param name="cancellationToken">An optional cancellation token.</param>
public Task<ILive<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();
TModel model = null;
try
{
model = CreateModel(archive);
2020-01-02 12:20:38 +08:00
if (model == null)
return Task.FromResult<ILive<TModel>>(new EntityFrameworkLive<TModel>(null));
}
2019-06-10 12:37:20 +08:00
catch (TaskCanceledException)
{
throw;
}
catch (Exception e)
{
LogForModel(model, @$"Model creation of {archive.Name} failed.", e);
return null;
}
return Import(model, archive, lowPriority, cancellationToken);
}
/// <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>
/// <remarks>
/// This is only used by the default hash implementation. If <see cref="ComputeHash"/> is overridden, it will not be used.
/// </remarks>
protected abstract string[] HashableFileTypes { get; }
2020-05-02 13:35:12 +08:00
internal static void LogForModel(TModel model, string message, Exception e = null)
{
string prefix = $"[{(model?.Hash ?? "?????").Substring(0, 5)}]";
if (e != null)
Logger.Error(e, $"{prefix} {message}", LoggingTarget.Database);
else
Logger.Log($"{prefix} {message}", LoggingTarget.Database);
}
/// <summary>
/// Whether the implementation overrides <see cref="ComputeHash"/> with a custom implementation.
/// Custom hash implementations must bypass the early exit in the import flow (see <see cref="computeHashFast"/> usage).
/// </summary>
protected virtual bool HasCustomHashFunction => false;
/// <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"/>.
/// </summary>
/// <remarks>
/// In the case of no matching files, a hash will be generated from the passed archive's <see cref="ArchiveReader.Name"/>.
/// </remarks>
2021-10-24 22:48:46 +08:00
protected virtual string ComputeHash(TModel item)
{
var hashableFiles = item.Files
.Where(f => HashableFileTypes.Any(ext => f.Filename.EndsWith(ext, StringComparison.OrdinalIgnoreCase)))
.OrderBy(f => f.Filename)
.ToArray();
if (hashableFiles.Length > 0)
{
// for now, concatenate all hashable files in the set to create a unique hash.
MemoryStream hashable = new MemoryStream();
foreach (TFileModel file in hashableFiles)
{
using (Stream s = Files.Store.GetStream(file.FileInfo.StoragePath))
s.CopyTo(hashable);
}
2019-11-11 20:05:36 +08:00
if (hashable.Length > 0)
return hashable.ComputeSHA2Hash();
2019-11-11 19:53:22 +08:00
}
return generateFallbackHash();
}
/// <summary>
/// Silently import an item from a <typeparamref name="TModel"/>.
/// </summary>
/// <param name="item">The model to be imported.</param>
/// <param name="archive">An optional archive to use for model population.</param>
/// <param name="lowPriority">Whether this is a low priority import.</param>
/// <param name="cancellationToken">An optional cancellation token.</param>
public virtual async Task<ILive<TModel>> Import(TModel item, ArchiveReader archive = null, bool lowPriority = false, CancellationToken cancellationToken = default) => await Task.Factory.StartNew(async () =>
{
2019-06-10 12:37:20 +08:00
cancellationToken.ThrowIfCancellationRequested();
bool checkedExisting = false;
TModel existing = null;
if (archive != null && !HasCustomHashFunction)
{
// this is a fast bail condition to improve large import performance.
item.Hash = computeHashFast(archive);
checkedExisting = true;
existing = CheckForExisting(item);
if (existing != null)
{
// bare minimum comparisons
//
// note that this should really be checking filesizes on disk (of existing files) for some degree of sanity.
// or alternatively doing a faster hash check. either of these require database changes and reprocessing of existing files.
if (CanSkipImport(existing, item) &&
getFilenames(existing.Files).SequenceEqual(getShortenedFilenames(archive).Select(p => p.shortened).OrderBy(f => f)))
{
LogForModel(item, @$"Found existing (optimised) {HumanisedModelName} for {item} (ID {existing.ID}) skipping import.");
Undelete(existing);
return existing.ToEntityFrameworkLive();
}
LogForModel(item, @"Found existing (optimised) but failed pre-check.");
}
}
void rollback()
{
if (!Delete(item))
{
// We may have not yet added the model to the underlying table, but should still clean up files.
LogForModel(item, @"Dereferencing files for incomplete import.");
Files.Dereference(item.Files.Select(f => f.FileInfo).ToArray());
}
}
delayEvents();
try
2018-04-13 17:19:50 +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>();
2021-10-24 22:48:46 +08:00
item.Hash = ComputeHash(item);
await Populate(item, archive, cancellationToken).ConfigureAwait(false);
2018-08-17 12:50:27 +08:00
using (var write = ContextFactory.GetForWrite()) // used to share a context for full import. keep in mind this will block all writes.
{
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
if (!checkedExisting)
existing = CheckForExisting(item);
2018-04-13 17:19:50 +08:00
2018-05-29 15:14:09 +08:00
if (existing != null)
{
if (CanReuseExisting(existing, item))
{
Undelete(existing);
LogForModel(item, @$"Found existing {HumanisedModelName} for {item} (ID {existing.ID}) skipping import.");
// existing item will be used; rollback new import and exit early.
rollback();
flushEvents(true);
return existing.ToEntityFrameworkLive();
}
LogForModel(item, @"Found existing but failed re-use check.");
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
PreImport(item);
2018-04-13 17:19:50 +08:00
// import to store
ModelStore.Add(item);
}
catch (Exception e)
{
write.Errors.Add(e);
throw;
}
}
2018-05-29 15:14:09 +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)
{
if (!(e is TaskCanceledException))
LogForModel(item, @"Database import or population failed and has been rolled back.", e);
rollback();
flushEvents(false);
throw;
}
flushEvents(true);
return item.ToEntityFrameworkLive();
}, cancellationToken, TaskCreationOptions.HideScheduler, lowPriority ? import_scheduler_low_priority : import_scheduler).Unwrap().ConfigureAwait(false);
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)
throw new ArgumentException(@"Specified model could not be found", nameof(item));
2020-05-24 12:44:11 +08:00
2021-10-26 13:05:07 +08:00
string filename = $"{getValidFilename(item.ToString())}{HandledExtensions.First()}";
2021-10-26 13:05:07 +08:00
using (var stream = exportStorage.GetStream(filename, FileAccess.Write, FileMode.Create))
ExportModelTo(retrievedItem, stream);
exportStorage.PresentFileExternally(filename);
}
/// <summary>
/// Exports an item to the given output stream.
/// </summary>
/// <param name="model">The item to export.</param>
/// <param name="outputStream">The output stream to export to.</param>
public virtual void ExportModelTo(TModel model, Stream outputStream)
{
2020-05-24 12:44:11 +08:00
using (var archive = ZipArchive.Create())
{
foreach (var file in model.Files)
2020-05-24 12:44:11 +08:00
archive.AddEntry(file.Filename, Files.Storage.GetStream(file.FileInfo.StoragePath));
archive.SaveTo(outputStream);
2020-05-24 12:44:11 +08:00
}
}
/// <summary>
/// Replace an existing file with a new version.
/// </summary>
/// <param name="model">The item to operate on.</param>
/// <param name="file">The existing file to be replaced.</param>
/// <param name="contents">The new file contents.</param>
/// <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>
2021-05-10 21:32:56 +08:00
/// Delete an existing 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
{
using (var usage = ContextFactory.GetForWrite())
2020-01-08 11:36:07 +08:00
{
// Dereference the existing file info, since the file model will be removed.
if (file.FileInfo != null)
{
Files.Dereference(file.FileInfo);
2020-01-14 17:43:06 +08:00
if (file.ID > 0)
{
// 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);
}
}
model.Files.Remove(file);
}
}
/// <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())
{
model.Files.Add(new TFileModel
{
Filename = filename,
FileInfo = Files.Add(contents)
});
2020-01-08 11:36:07 +08:00
}
if (model.ID > 0)
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())
{
item.Hash = ComputeHash(item);
2020-01-08 11:36:07 +08:00
ModelStore.Update(item);
}
}
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
{
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>
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,
};
if (!silent)
PostNotification?.Invoke(notification);
2018-04-13 17:19:50 +08:00
int i = 0;
foreach (var b in items)
2018-04-13 17:19:50 +08:00
{
if (notification.State == ProgressNotificationState.Cancelled)
// user requested abort
return;
2018-04-13 17:19:50 +08:00
notification.Text = $"Deleting {HumanisedModelName}s ({++i} of {items.Count})";
2018-04-13 17:19:50 +08:00
Delete(b);
2018-04-13 17:19: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>
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,
};
if (!silent)
PostNotification?.Invoke(notification);
2018-04-13 17:19:50 +08:00
int i = 0;
foreach (var item in items)
2018-04-13 17:19:50 +08:00
{
if (notification.State == ProgressNotificationState.Cancelled)
// user requested abort
return;
2018-04-13 17:19:50 +08:00
notification.Text = $"Restoring ({++i} of {items.Count})";
2018-04-13 17:19:50 +08:00
Undelete(item);
2018-04-13 17:19:50 +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;
}
}
2021-06-27 19:36:01 +08:00
private string computeHashFast(ArchiveReader reader)
{
MemoryStream hashable = new MemoryStream();
foreach (string file in reader.Filenames.Where(f => HashableFileTypes.Any(ext => f.EndsWith(ext, StringComparison.OrdinalIgnoreCase))).OrderBy(f => f))
2021-06-27 19:36:01 +08:00
{
using (Stream s = reader.GetStream(file))
s.CopyTo(hashable);
}
if (hashable.Length > 0)
return hashable.ComputeSHA2Hash();
2021-06-27 19:36:01 +08:00
return generateFallbackHash();
2021-06-27 19:36:01 +08:00
}
2018-04-13 17:19:50 +08:00
/// <summary>
/// Create all required <see cref="IO.FileInfo"/>s for the provided archive, adding them to the global file store.
2018-04-13 17:19:50 +08:00
/// </summary>
private List<TFileModel> createFileInfos(ArchiveReader reader, FileStore files)
{
var fileInfos = new List<TFileModel>();
// import files to manager
foreach (var filenames in getShortenedFilenames(reader))
2019-11-11 19:53:22 +08:00
{
using (Stream s = reader.GetStream(filenames.original))
2019-11-11 19:53:22 +08:00
{
2018-04-13 17:19:50 +08:00
fileInfos.Add(new TFileModel
{
Filename = filenames.shortened,
FileInfo = files.Add(s)
2018-04-13 17:19:50 +08:00
});
2019-11-11 19:53:22 +08:00
}
}
2018-04-13 17:19:50 +08:00
return fileInfos;
}
private IEnumerable<(string original, string shortened)> getShortenedFilenames(ArchiveReader reader)
{
string prefix = reader.Filenames.GetCommonPrefix();
if (!(prefix.EndsWith('/') || prefix.EndsWith('\\')))
prefix = string.Empty;
// import files to manager
foreach (string file in reader.Filenames)
yield return (file, file.Substring(prefix.Length).ToStandardisedPath());
}
#region osu-stable import
/// <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>
protected virtual IEnumerable<string> GetStableImportPaths(Storage storage) => storage.GetDirectories(ImportFromStablePath)
.Select(path => storage.GetFullPath(path));
2019-06-20 00:33:51 +08:00
/// <summary>
/// Whether this specified path should be removed after successful import.
/// </summary>
/// <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;
2021-05-09 23:12:58 +08:00
public Task ImportFromStableAsync(StableStorage stableStorage)
{
var storage = PrepareStableStorage(stableStorage);
// Handle situations like when the user does not have a Skins folder.
if (!storage.ExistsDirectory(ImportFromStablePath))
2018-09-15 21:53:59 +08:00
{
string fullPath = storage.GetFullPath(ImportFromStablePath);
Logger.Log(@$"Folder ""{fullPath}"" not available in the target osu!stable installation to import {HumanisedModelName}s.", LoggingTarget.Information, LogLevel.Error);
return Task.CompletedTask;
}
return Task.Run(async () => await Import(GetStableImportPaths(storage).ToArray()).ConfigureAwait(false));
}
/// <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;
#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>
/// <param name="archive">The archive to use as a reference for population. May be null.</param>
/// <param name="cancellationToken">An optional cancellation token.</param>
protected abstract Task Populate(TModel model, [CanBeNull] ArchiveReader archive, CancellationToken cancellationToken = default);
2018-04-13 17:19:50 +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>
protected TModel CheckForExisting(TModel model) => model.Hash == null ? null : ModelStore.ConsumableItems.FirstOrDefault(b => b.Hash == model.Hash);
public bool IsAvailableLocally(TModel model) => CheckLocalAvailability(model, ModelStore.ConsumableItems.Where(m => !m.DeletePending));
/// <summary>
/// Performs implementation specific comparisons to determine whether a given model is present in the local store.
/// </summary>
/// <param name="model">The <typeparamref name="TModel"/> whose existence needs to be checked.</param>
/// <param name="items">The usable items present in the store.</param>
/// <returns>Whether the <typeparamref name="TModel"/> exists.</returns>
protected virtual bool CheckLocalAvailability(TModel model, IQueryable<TModel> items)
=> model.ID > 0 && items.Any(i => i.ID == model.ID && i.Files.Any());
/// <summary>
2021-08-23 19:23:46 +08:00
/// Whether import can be skipped after finding an existing import early in the process.
/// Only valid when <see cref="ComputeHash"/> is not overridden.
/// </summary>
/// <param name="existing">The existing model.</param>
/// <param name="import">The newly imported model.</param>
/// <returns>Whether to skip this import completely.</returns>
protected virtual bool CanSkipImport(TModel existing, TModel import) => true;
/// <summary>
/// After an existing <typeparamref name="TModel"/> is found during an import process, the default behaviour is to use/restore the existing
/// 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>
/// <returns>Whether the existing model should be restored and used. Returning false will delete the existing and force a re-import.</returns>
protected virtual bool CanReuseExisting(TModel existing, TModel import) =>
// 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));
private IEnumerable<long> getIDs(List<TFileModel> files)
{
foreach (var f in files.OrderBy(f => f.Filename))
yield return f.FileInfo.ID;
}
private IEnumerable<string> getFilenames(List<TFileModel> files)
{
foreach (var f in files.OrderBy(f => f.Filename))
yield return f.Filename;
}
2018-04-13 17:19:50 +08:00
private DbSet<TModel> queryModel() => ContextFactory.Get().Set<TModel>();
public virtual string HumanisedModelName => $"{typeof(TModel).Name.Replace(@"Info", "").ToLower()}";
#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
{
lock (queuedEvents)
queuedEvents.Add(a);
2019-11-11 19:53:22 +08:00
}
else
a.Invoke();
}
#endregion
2020-05-24 21:34:31 +08:00
private static string generateFallbackHash()
{
// if a hash could no be generated from file content, presume a unique / new import.
// therefore, let's use a guaranteed unique hash.
// this doesn't follow the SHA2 hashing schema intentionally, so such entries on the data store can be identified.
return Guid.NewGuid().ToString();
}
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
}
}