1
0
mirror of https://github.com/ppy/osu.git synced 2024-12-27 20:22:56 +08:00
osu-lazer/osu.Game/Database/ArchiveModelManager.cs

661 lines
26 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;
using osu.Framework;
using osu.Framework.Extensions;
using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Framework.IO.File;
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;
using osu.Game.Utils;
using SharpCompress.Common;
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>
public abstract class ArchiveModelManager<TModel, TFileModel> : ICanAcceptFiles, IModelManager<TModel>
2018-04-13 17:19:50 +08:00
where TModel : class, IHasFiles<TFileModel>, IHasPrimaryKey, ISoftDelete
where TFileModel : INamedFileInfo, new()
{
private const int import_queue_request_concurrency = 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>));
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>
/// Fired when a new <see cref="TModel"/> becomes available in the database.
/// This is not guaranteed to run on the update thread.
/// </summary>
public event Action<TModel> ItemAdded;
2018-04-13 17:19:50 +08:00
/// <summary>
/// Fired when a <see cref="TModel"/> is removed from the database.
/// This is not guaranteed to run on the update thread.
/// </summary>
public event Action<TModel> ItemRemoved;
public virtual string[] HandledExtensions => new[] { ".zip" };
public virtual bool SupportsImportFromStable => RuntimeInfo.IsDesktop;
2018-04-13 17:19:50 +08:00
protected readonly FileStore Files;
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;
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.ItemAdded += item => handleEvent(() => ItemAdded?.Invoke(item));
ModelStore.ItemRemoved += s => handleEvent(() => ItemRemoved?.Invoke(s));
2018-04-13 17:19:50 +08:00
Files = new FileStore(contextFactory, storage);
if (importHost != null)
ipc = new ArchiveImportIPCChannel(importHost, this);
ModelStore.Cleanup();
}
/// <summary>
/// Import one or more <see cref="TModel"/> items from filesystem <paramref name="paths"/>.
/// This will post notifications tracking progress.
/// </summary>
/// <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 ProgressNotification { State = ProgressNotificationState.Active };
2018-04-13 17:19:50 +08:00
PostNotification?.Invoke(notification);
2019-06-12 16:08:50 +08:00
return Import(notification, paths);
}
protected async Task Import(ProgressNotification notification, params string[] paths)
{
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<TModel>();
await Task.WhenAll(paths.Select(async path =>
2018-04-13 17:19:50 +08:00
{
notification.CancellationToken.ThrowIfCancellationRequested();
2018-04-13 17:19:50 +08:00
try
{
var model = await Import(path, notification.CancellationToken);
2018-12-19 03:49:53 +08:00
lock (imported)
{
if (model != null)
imported.Add(model);
2019-06-12 16:10:55 +08:00
current++;
2018-04-13 17:19:50 +08:00
notification.Text = $"Imported {current} of {paths.Length} {HumanisedModelName}s";
notification.Progress = (float)current / paths.Length;
}
}
catch (TaskCanceledException)
{
throw;
2018-04-13 17:19:50 +08:00
}
catch (Exception e)
{
Logger.Error(e, $@"Could not import ({Path.GetFileName(path)})", LoggingTarget.Database);
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()}!"
: $"Imported {imported.Count} {HumanisedModelName}s!";
if (imported.Count > 0 && PresentImport != null)
{
notification.CompletionText += " Click to view.";
notification.CompletionClickAction = () =>
{
PresentImport?.Invoke(imported);
return true;
};
}
2018-09-07 17:14:23 +08:00
notification.State = ProgressNotificationState.Completed;
}
2018-04-13 17:19:50 +08:00
}
/// <summary>
/// Import one <see cref="TModel"/> from the filesystem and delete the file on success.
/// </summary>
/// <param name="path">The archive location on disk.</param>
/// <param name="cancellationToken">An optional cancellation token.</param>
/// <returns>The imported model, if successful.</returns>
public async Task<TModel> Import(string path, CancellationToken cancellationToken = default)
{
2019-06-10 12:37:20 +08:00
cancellationToken.ThrowIfCancellationRequested();
TModel import;
using (ArchiveReader reader = getReaderFrom(path))
import = await Import(reader, cancellationToken);
// 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(path) && ShouldDeleteArchive(path))
File.Delete(path);
}
catch (Exception e)
{
LogForModel(import, $@"Could not delete original file after import ({Path.GetFileName(path)})", e);
}
return import;
}
/// <summary>
/// Fired when the user requests to view the resulting import.
/// </summary>
public Action<IEnumerable<TModel>> PresentImport;
2018-04-13 17:19:50 +08:00
/// <summary>
/// Import an item from an <see cref="ArchiveReader"/>.
/// </summary>
/// <param name="archive">The archive to be imported.</param>
/// <param name="cancellationToken">An optional cancellation token.</param>
2019-06-21 23:01:11 +08:00
public Task<TModel> Import(ArchiveReader archive, 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);
2019-06-21 23:01:11 +08:00
if (model == null) return Task.FromResult<TModel>(null);
2018-11-30 14:09:15 +08:00
model.Hash = computeHash(archive);
}
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;
}
2019-06-21 23:01:11 +08:00
return Import(model, archive, 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>
protected abstract string[] HashableFileTypes { get; }
protected 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>
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>
2018-11-30 14:09:15 +08:00
private string computeHash(ArchiveReader reader)
{
// for now, concatenate all .osu files in the set to create a unique hash.
MemoryStream hashable = new MemoryStream();
2018-11-30 14:09:15 +08:00
foreach (string file in reader.Filenames.Where(f => HashableFileTypes.Any(f.EndsWith)))
using (Stream s = reader.GetStream(file))
s.CopyTo(hashable);
return hashable.Length > 0 ? hashable.ComputeSHA2Hash() : null;
}
/// <summary>
/// Import an item from a <see cref="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="cancellationToken">An optional cancellation token.</param>
public async Task<TModel> Import(TModel item, ArchiveReader archive = null, CancellationToken cancellationToken = default) => await Task.Factory.StartNew(async () =>
{
2019-06-10 12:37:20 +08:00
cancellationToken.ThrowIfCancellationRequested();
2018-05-28 20:45:05 +08:00
delayEvents();
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());
}
}
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>();
await Populate(item, archive, cancellationToken);
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
var existing = CheckForExisting(item);
2018-04-13 17:19:50 +08:00
2018-05-29 15:14:09 +08:00
if (existing != null)
{
if (CanUndelete(existing, item))
{
Undelete(existing);
2019-06-12 19:41:02 +08:00
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;
}
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;
}, cancellationToken, TaskCreationOptions.HideScheduler, import_scheduler).Unwrap();
2018-04-13 17:19:50 +08:00
/// <summary>
/// Perform an update of the specified item.
/// TODO: Support file changes.
/// </summary>
/// <param name="item">The item to update.</param>
public void Update(TModel item) => ModelStore.Update(item);
/// <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;
}
}
/// <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>();
string prefix = reader.Filenames.GetCommonPrefix();
if (!(prefix.EndsWith("/") || prefix.EndsWith("\\")))
prefix = string.Empty;
2018-04-13 17:19:50 +08:00
// import files to manager
foreach (string file in reader.Filenames)
using (Stream s = reader.GetStream(file))
fileInfos.Add(new TFileModel
{
Filename = FileSafety.PathStandardise(file.Substring(prefix.Length)),
2018-04-13 17:19:50 +08:00
FileInfo = files.Add(s)
});
return fileInfos;
}
#region osu-stable import
/// <summary>
/// Set a storage with access to an osu-stable install for import purposes.
/// </summary>
public Func<Storage> GetStableStorage { private get; set; }
/// <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>
2019-07-05 13:21:56 +08:00
/// Select paths to import from stable. Default implementation iterates all directories in <see cref="ImportFromStablePath"/>.
2019-06-20 00:33:51 +08:00
/// </summary>
protected virtual IEnumerable<string> GetStableImportPaths(Storage stableStoage) => stableStoage.GetDirectories(ImportFromStablePath);
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;
/// <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()
{
var stable = GetStableStorage?.Invoke();
if (stable == null)
{
Logger.Log("No osu!stable installation available!", LoggingTarget.Information, LogLevel.Error);
return Task.CompletedTask;
}
if (!stable.ExistsDirectory(ImportFromStablePath))
2018-09-15 21:53:59 +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);
return Task.CompletedTask;
}
return Task.Run(async () => await Import(GetStableImportPaths(GetStableStorage()).Select(f => stable.GetFullPath(f)).ToArray()));
}
#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>
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
/// <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);
/// <summary>
/// After an existing <see cref="TModel"/> is found during an import process, the default behaviour is to 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 CanUndelete(TModel existing, TModel import) => true;
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()}";
2018-04-13 17:19:50 +08:00
/// <summary>
/// Creates an <see cref="ArchiveReader"/> from a valid storage path.
/// </summary>
/// <param name="path">A file or folder path resolving the archive content.</param>
/// <returns>A reader giving access to the archive's content.</returns>
private ArchiveReader getReaderFrom(string path)
{
if (ZipUtils.IsZipArchive(path))
2018-08-15 14:49:55 +08:00
return new ZipArchiveReader(File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read), Path.GetFileName(path));
2018-04-13 17:19:50 +08:00
if (Directory.Exists(path))
2018-11-28 14:13:27 +08:00
return new LegacyDirectoryArchiveReader(path);
if (File.Exists(path))
return new LegacyFileArchiveReader(path);
2019-02-28 12:31:40 +08:00
2018-04-13 17:19:50 +08:00
throw new InvalidFormatException($"{path} is not a valid archive");
}
#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)
lock (queuedEvents)
queuedEvents.Add(a);
else
a.Invoke();
}
#endregion
2018-04-13 17:19:50 +08:00
}
}