1
0
mirror of https://github.com/ppy/osu.git synced 2025-01-14 19:13:20 +08:00

Merge pull request #21308 from cdwcgt/export

Make model exporting asynchronous
This commit is contained in:
Bartłomiej Dach 2023-05-06 21:04:09 +02:00 committed by GitHub
commit 0fa32fed51
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 244 additions and 122 deletions

View File

@ -1,19 +1,26 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // 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. // See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Extensions;
using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Platform; using osu.Framework.Platform;
using osu.Framework.Testing; using osu.Framework.Testing;
using osu.Game.Database; using osu.Game.Database;
using osu.Game.Overlays.Notifications;
using Realms;
namespace osu.Game.Tests.Database namespace osu.Game.Tests.Database
{ {
[TestFixture] [TestFixture]
public class LegacyExporterTest public class LegacyModelExporterTest
{ {
private TestLegacyExporter legacyExporter = null!; private TestLegacyModelExporter legacyExporter = null!;
private TemporaryNativeStorage storage = null!; private TemporaryNativeStorage storage = null!;
private const string short_filename = "normal file name"; private const string short_filename = "normal file name";
@ -25,15 +32,15 @@ namespace osu.Game.Tests.Database
public void SetUp() public void SetUp()
{ {
storage = new TemporaryNativeStorage("export-storage"); storage = new TemporaryNativeStorage("export-storage");
legacyExporter = new TestLegacyExporter(storage); legacyExporter = new TestLegacyModelExporter(storage);
} }
[Test] [Test]
public void ExportFileWithNormalNameTest() public void ExportFileWithNormalNameTest()
{ {
var item = new TestPathInfo(short_filename); var item = new TestModel(short_filename);
Assert.That(item.Filename.Length, Is.LessThan(TestLegacyExporter.MAX_FILENAME_LENGTH)); Assert.That(item.Filename.Length, Is.LessThan(TestLegacyModelExporter.MAX_FILENAME_LENGTH));
exportItemAndAssert(item, short_filename); exportItemAndAssert(item, short_filename);
} }
@ -41,9 +48,9 @@ namespace osu.Game.Tests.Database
[Test] [Test]
public void ExportFileWithNormalNameMultipleTimesTest() public void ExportFileWithNormalNameMultipleTimesTest()
{ {
var item = new TestPathInfo(short_filename); var item = new TestModel(short_filename);
Assert.That(item.Filename.Length, Is.LessThan(TestLegacyExporter.MAX_FILENAME_LENGTH)); Assert.That(item.Filename.Length, Is.LessThan(TestLegacyModelExporter.MAX_FILENAME_LENGTH));
//Export multiple times //Export multiple times
for (int i = 0; i < 100; i++) for (int i = 0; i < 100; i++)
@ -56,24 +63,24 @@ namespace osu.Game.Tests.Database
[Test] [Test]
public void ExportFileWithSuperLongNameTest() public void ExportFileWithSuperLongNameTest()
{ {
int expectedLength = TestLegacyExporter.MAX_FILENAME_LENGTH - (legacyExporter.GetExtension().Length); int expectedLength = TestLegacyModelExporter.MAX_FILENAME_LENGTH - (legacyExporter.GetExtension().Length);
string expectedName = long_filename.Remove(expectedLength); string expectedName = long_filename.Remove(expectedLength);
var item = new TestPathInfo(long_filename); var item = new TestModel(long_filename);
Assert.That(item.Filename.Length, Is.GreaterThan(TestLegacyExporter.MAX_FILENAME_LENGTH)); Assert.That(item.Filename.Length, Is.GreaterThan(TestLegacyModelExporter.MAX_FILENAME_LENGTH));
exportItemAndAssert(item, expectedName); exportItemAndAssert(item, expectedName);
} }
[Test] [Test]
public void ExportFileWithSuperLongNameMultipleTimesTest() public void ExportFileWithSuperLongNameMultipleTimesTest()
{ {
int expectedLength = TestLegacyExporter.MAX_FILENAME_LENGTH - (legacyExporter.GetExtension().Length); int expectedLength = TestLegacyModelExporter.MAX_FILENAME_LENGTH - (legacyExporter.GetExtension().Length);
string expectedName = long_filename.Remove(expectedLength); string expectedName = long_filename.Remove(expectedLength);
var item = new TestPathInfo(long_filename); var item = new TestModel(long_filename);
Assert.That(item.Filename.Length, Is.GreaterThan(TestLegacyExporter.MAX_FILENAME_LENGTH)); Assert.That(item.Filename.Length, Is.GreaterThan(TestLegacyModelExporter.MAX_FILENAME_LENGTH));
//Export multiple times //Export multiple times
for (int i = 0; i < 100; i++) for (int i = 0; i < 100; i++)
@ -83,9 +90,12 @@ namespace osu.Game.Tests.Database
} }
} }
private void exportItemAndAssert(IHasNamedFiles item, string expectedName) private void exportItemAndAssert(TestModel item, string expectedName)
{ {
Assert.DoesNotThrow(() => legacyExporter.Export(item)); Assert.DoesNotThrow(() =>
{
Task.Run(() => legacyExporter.ExportAsync(new RealmLiveUnmanaged<TestModel>(item))).WaitSafely();
});
Assert.That(storage.Exists($"exports/{expectedName}{legacyExporter.GetExtension()}"), Is.True); Assert.That(storage.Exists($"exports/{expectedName}{legacyExporter.GetExtension()}"), Is.True);
} }
@ -96,30 +106,36 @@ namespace osu.Game.Tests.Database
storage.Dispose(); storage.Dispose();
} }
private class TestPathInfo : IHasNamedFiles private class TestLegacyModelExporter : LegacyExporter<TestModel>
{ {
public string Filename { get; } public TestLegacyModelExporter(Storage storage)
public IEnumerable<INamedFileUsage> Files { get; } = new List<INamedFileUsage>();
public TestPathInfo(string filename)
{
Filename = filename;
}
public override string ToString() => Filename;
}
private class TestLegacyExporter : LegacyExporter<IHasNamedFiles>
{
public TestLegacyExporter(Storage storage)
: base(storage) : base(storage)
{ {
} }
public string GetExtension() => FileExtension; public string GetExtension() => FileExtension;
public override void ExportToStream(TestModel model, Stream outputStream, ProgressNotification? notification, CancellationToken cancellationToken = default)
{
}
protected override string FileExtension => ".test"; protected override string FileExtension => ".test";
} }
private class TestModel : RealmObject, IHasNamedFiles, IHasGuidPrimaryKey
{
public Guid ID => Guid.Empty;
public string Filename { get; }
public IEnumerable<INamedFileUsage> Files { get; } = new List<INamedFileUsage>();
public TestModel(string filename)
{
Filename = filename;
}
public override string ToString() => Filename;
}
} }
} }

View File

@ -10,7 +10,6 @@ using System.Runtime.CompilerServices;
using System.Threading.Tasks; using System.Threading.Tasks;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Extensions;
using osu.Framework.Platform; using osu.Framework.Platform;
using osu.Game.Database; using osu.Game.Database;
using osu.Game.Extensions; using osu.Game.Extensions;
@ -120,10 +119,7 @@ namespace osu.Game.Tests.Skins.IO
var import1 = await loadSkinIntoOsu(osu, new ImportTask(createOskWithIni("name 1", "author 1"), "custom.osk")); var import1 = await loadSkinIntoOsu(osu, new ImportTask(createOskWithIni("name 1", "author 1"), "custom.osk"));
assertCorrectMetadata(import1, "name 1 [custom]", "author 1", osu); assertCorrectMetadata(import1, "name 1 [custom]", "author 1", osu);
import1.PerformRead(s => await new LegacySkinExporter(osu.Dependencies.Get<Storage>()).ExportToStreamAsync(import1, exportStream);
{
new LegacySkinExporter(osu.Dependencies.Get<Storage>()).ExportModelTo(s, exportStream);
});
string exportFilename = import1.GetDisplayString(); string exportFilename = import1.GetDisplayString();
@ -141,10 +137,7 @@ namespace osu.Game.Tests.Skins.IO
var import1 = await loadSkinIntoOsu(osu, new ImportTask(createOskWithIni("name 『1』", "author 1"), "custom.osk")); var import1 = await loadSkinIntoOsu(osu, new ImportTask(createOskWithIni("name 『1』", "author 1"), "custom.osk"));
assertCorrectMetadata(import1, "name 『1』 [custom]", "author 1", osu); assertCorrectMetadata(import1, "name 『1』 [custom]", "author 1", osu);
import1.PerformRead(s => await new LegacySkinExporter(osu.Dependencies.Get<Storage>()).ExportToStreamAsync(import1, exportStream);
{
new LegacySkinExporter(osu.Dependencies.Get<Storage>()).ExportModelTo(s, exportStream);
});
string exportFilename = import1.GetDisplayString().GetValidFilename(); string exportFilename = import1.GetDisplayString().GetValidFilename();
@ -208,7 +201,7 @@ namespace osu.Game.Tests.Skins.IO
}); });
[Test] [Test]
public Task TestExportThenImportDefaultSkin() => runSkinTest(osu => public Task TestExportThenImportDefaultSkin() => runSkinTest(async osu =>
{ {
var skinManager = osu.Dependencies.Get<SkinManager>(); var skinManager = osu.Dependencies.Get<SkinManager>();
@ -218,30 +211,28 @@ namespace osu.Game.Tests.Skins.IO
Guid originalSkinId = skinManager.CurrentSkinInfo.Value.ID; Guid originalSkinId = skinManager.CurrentSkinInfo.Value.ID;
skinManager.CurrentSkinInfo.Value.PerformRead(s => await skinManager.CurrentSkinInfo.Value.PerformRead(async s =>
{ {
Assert.IsFalse(s.Protected); Assert.IsFalse(s.Protected);
Assert.AreEqual(typeof(ArgonSkin), s.CreateInstance(skinManager).GetType()); Assert.AreEqual(typeof(ArgonSkin), s.CreateInstance(skinManager).GetType());
new LegacySkinExporter(osu.Dependencies.Get<Storage>()).ExportModelTo(s, exportStream); await new LegacySkinExporter(osu.Dependencies.Get<Storage>()).ExportToStreamAsync(skinManager.CurrentSkinInfo.Value, exportStream);
Assert.Greater(exportStream.Length, 0); Assert.Greater(exportStream.Length, 0);
}); });
var imported = skinManager.Import(new ImportTask(exportStream, "exported.osk")); var imported = await skinManager.Import(new ImportTask(exportStream, "exported.osk"));
imported.GetResultSafely().PerformRead(s => imported.PerformRead(s =>
{ {
Assert.IsFalse(s.Protected); Assert.IsFalse(s.Protected);
Assert.AreNotEqual(originalSkinId, s.ID); Assert.AreNotEqual(originalSkinId, s.ID);
Assert.AreEqual(typeof(ArgonSkin), s.CreateInstance(skinManager).GetType()); Assert.AreEqual(typeof(ArgonSkin), s.CreateInstance(skinManager).GetType());
}); });
return Task.CompletedTask;
}); });
[Test] [Test]
public Task TestExportThenImportClassicSkin() => runSkinTest(osu => public Task TestExportThenImportClassicSkin() => runSkinTest(async osu =>
{ {
var skinManager = osu.Dependencies.Get<SkinManager>(); var skinManager = osu.Dependencies.Get<SkinManager>();
@ -253,26 +244,24 @@ namespace osu.Game.Tests.Skins.IO
Guid originalSkinId = skinManager.CurrentSkinInfo.Value.ID; Guid originalSkinId = skinManager.CurrentSkinInfo.Value.ID;
skinManager.CurrentSkinInfo.Value.PerformRead(s => await skinManager.CurrentSkinInfo.Value.PerformRead(async s =>
{ {
Assert.IsFalse(s.Protected); Assert.IsFalse(s.Protected);
Assert.AreEqual(typeof(DefaultLegacySkin), s.CreateInstance(skinManager).GetType()); Assert.AreEqual(typeof(DefaultLegacySkin), s.CreateInstance(skinManager).GetType());
new LegacySkinExporter(osu.Dependencies.Get<Storage>()).ExportModelTo(s, exportStream); await new LegacySkinExporter(osu.Dependencies.Get<Storage>()).ExportToStreamAsync(skinManager.CurrentSkinInfo.Value, exportStream);
Assert.Greater(exportStream.Length, 0); Assert.Greater(exportStream.Length, 0);
}); });
var imported = skinManager.Import(new ImportTask(exportStream, "exported.osk")); var imported = await skinManager.Import(new ImportTask(exportStream, "exported.osk"));
imported.GetResultSafely().PerformRead(s => imported.PerformRead(s =>
{ {
Assert.IsFalse(s.Protected); Assert.IsFalse(s.Protected);
Assert.AreNotEqual(originalSkinId, s.ID); Assert.AreNotEqual(originalSkinId, s.ID);
Assert.AreEqual(typeof(DefaultLegacySkin), s.CreateInstance(skinManager).GetType()); Assert.AreEqual(typeof(DefaultLegacySkin), s.CreateInstance(skinManager).GetType());
}); });
return Task.CompletedTask;
}); });
#endregion #endregion

View File

@ -42,6 +42,8 @@ namespace osu.Game.Beatmaps
private readonly WorkingBeatmapCache workingBeatmapCache; private readonly WorkingBeatmapCache workingBeatmapCache;
private readonly LegacyBeatmapExporter beatmapExporter;
public ProcessBeatmapDelegate? ProcessBeatmap { private get; set; } public ProcessBeatmapDelegate? ProcessBeatmap { private get; set; }
public override bool PauseImports public override bool PauseImports
@ -76,6 +78,11 @@ namespace osu.Game.Beatmaps
beatmapImporter.PostNotification = obj => PostNotification?.Invoke(obj); beatmapImporter.PostNotification = obj => PostNotification?.Invoke(obj);
workingBeatmapCache = CreateWorkingBeatmapCache(audioManager, gameResources, userResources, defaultBeatmap, host); workingBeatmapCache = CreateWorkingBeatmapCache(audioManager, gameResources, userResources, defaultBeatmap, host);
beatmapExporter = new LegacyBeatmapExporter(storage)
{
PostNotification = obj => PostNotification?.Invoke(obj)
};
} }
protected virtual WorkingBeatmapCache CreateWorkingBeatmapCache(AudioManager audioManager, IResourceStore<byte[]> resources, IResourceStore<byte[]> storage, WorkingBeatmap? defaultBeatmap, protected virtual WorkingBeatmapCache CreateWorkingBeatmapCache(AudioManager audioManager, IResourceStore<byte[]> resources, IResourceStore<byte[]> storage, WorkingBeatmap? defaultBeatmap,
@ -393,6 +400,8 @@ namespace osu.Game.Beatmaps
public Task<Live<BeatmapSetInfo>?> ImportAsUpdate(ProgressNotification notification, ImportTask importTask, BeatmapSetInfo original) => public Task<Live<BeatmapSetInfo>?> ImportAsUpdate(ProgressNotification notification, ImportTask importTask, BeatmapSetInfo original) =>
beatmapImporter.ImportAsUpdate(notification, importTask, original); beatmapImporter.ImportAsUpdate(notification, importTask, original);
public Task Export(BeatmapSetInfo beatmap) => beatmapExporter.ExportAsync(beatmap.ToLive(Realm));
private void updateHashAndMarkDirty(BeatmapSetInfo setInfo) private void updateHashAndMarkDirty(BeatmapSetInfo setInfo)
{ {
setInfo.Hash = beatmapImporter.ComputeHash(setInfo); setInfo.Hash = beatmapImporter.ComputeHash(setInfo);

View File

@ -0,0 +1,69 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.IO;
using System.Linq;
using System.Threading;
using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Game.Extensions;
using osu.Game.Overlays.Notifications;
using Realms;
using SharpCompress.Common;
using SharpCompress.Writers;
using SharpCompress.Writers.Zip;
using Logger = osu.Framework.Logging.Logger;
namespace osu.Game.Database
{
/// <summary>
/// Handles the common scenario of exporting a model to a zip-based archive, usually with a custom file extension.
/// </summary>
public abstract class LegacyArchiveExporter<TModel> : LegacyExporter<TModel>
where TModel : RealmObject, IHasNamedFiles, IHasGuidPrimaryKey
{
protected LegacyArchiveExporter(Storage storage)
: base(storage)
{
}
public override void ExportToStream(TModel model, Stream outputStream, ProgressNotification? notification, CancellationToken cancellationToken = default)
{
using (var writer = new ZipWriter(outputStream, new ZipWriterOptions(CompressionType.Deflate)))
{
int i = 0;
int fileCount = model.Files.Count();
bool anyFileMissing = false;
foreach (var file in model.Files)
{
cancellationToken.ThrowIfCancellationRequested();
using (var stream = UserFileStorage.GetStream(file.File.GetStoragePath()))
{
if (stream == null)
{
Logger.Log($"File {file.Filename} is missing in local storage and will not be included in the export", LoggingTarget.Database);
anyFileMissing = true;
continue;
}
writer.Write(file.Filename, stream);
}
i++;
if (notification != null)
{
notification.Progress = (float)i / fileCount;
}
}
if (anyFileMissing)
{
Logger.Log("Some files are missing in local storage and will not be included in the export", LoggingTarget.Database, LogLevel.Error);
}
}
}
}
}

View File

@ -1,20 +1,18 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // 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. // See the LICENCE file in the repository root for full licence text.
#nullable disable
using osu.Framework.Platform; using osu.Framework.Platform;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
namespace osu.Game.Database namespace osu.Game.Database
{ {
public class LegacyBeatmapExporter : LegacyExporter<BeatmapSetInfo> public class LegacyBeatmapExporter : LegacyArchiveExporter<BeatmapSetInfo>
{ {
protected override string FileExtension => ".osz";
public LegacyBeatmapExporter(Storage storage) public LegacyBeatmapExporter(Storage storage)
: base(storage) : base(storage)
{ {
} }
protected override string FileExtension => @".osz";
} }
} }

View File

@ -1,24 +1,25 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // 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. // See the LICENCE file in the repository root for full licence text.
#nullable disable
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using osu.Framework.Platform; using osu.Framework.Platform;
using osu.Game.Extensions; using osu.Game.Extensions;
using osu.Game.Overlays.Notifications;
using osu.Game.Utils; using osu.Game.Utils;
using SharpCompress.Archives.Zip; using Realms;
namespace osu.Game.Database namespace osu.Game.Database
{ {
/// <summary> /// <summary>
/// A class which handles exporting legacy user data of a single type from osu-stable. /// Handles exporting models to files for sharing / consumption outside the game.
/// </summary> /// </summary>
public abstract class LegacyExporter<TModel> public abstract class LegacyExporter<TModel>
where TModel : class, IHasNamedFiles where TModel : RealmObject, IHasNamedFiles, IHasGuidPrimaryKey
{ {
/// <summary> /// <summary>
/// Max length of filename (including extension). /// Max length of filename (including extension).
@ -39,55 +40,93 @@ namespace osu.Game.Database
protected abstract string FileExtension { get; } protected abstract string FileExtension { get; }
protected readonly Storage UserFileStorage; protected readonly Storage UserFileStorage;
private readonly Storage exportStorage; private readonly Storage exportStorage;
public Action<Notification>? PostNotification { get; set; }
protected LegacyExporter(Storage storage) protected LegacyExporter(Storage storage)
{ {
exportStorage = storage.GetStorageForDirectory(@"exports"); exportStorage = storage.GetStorageForDirectory(@"exports");
UserFileStorage = storage.GetStorageForDirectory(@"files"); UserFileStorage = storage.GetStorageForDirectory(@"files");
} }
/// <summary>
/// Returns the baseline name of the file to which the <paramref name="item"/> will be exported.
/// </summary>
/// <remarks>
/// The name of the file will be run through <see cref="ModelExtensions.GetValidFilename"/> to eliminate characters
/// which are not permitted by various filesystems.
/// </remarks>
/// <param name="item">The item being exported.</param>
protected virtual string GetFilename(TModel item) => item.GetDisplayString(); protected virtual string GetFilename(TModel item) => item.GetDisplayString();
/// <summary> /// <summary>
/// Exports an item to a legacy (.zip based) package. /// Exports a model to the default export location.
/// This will create a notification tracking the progress of the export, visible to the user.
/// </summary> /// </summary>
/// <param name="item">The item to export.</param> /// <param name="model">The model to export.</param>
public void Export(TModel item) /// <param name="cancellationToken">A cancellation token.</param>
public async Task ExportAsync(Live<TModel> model, CancellationToken cancellationToken = default)
{ {
string itemFilename = GetFilename(item).GetValidFilename(); string itemFilename = model.PerformRead(s => GetFilename(s).GetValidFilename());
if (itemFilename.Length > MAX_FILENAME_LENGTH - FileExtension.Length) if (itemFilename.Length > MAX_FILENAME_LENGTH - FileExtension.Length)
itemFilename = itemFilename.Remove(MAX_FILENAME_LENGTH - FileExtension.Length); itemFilename = itemFilename.Remove(MAX_FILENAME_LENGTH - FileExtension.Length);
IEnumerable<string> existingExports = IEnumerable<string> existingExports = exportStorage
exportStorage .GetFiles(string.Empty, $"{itemFilename}*{FileExtension}")
.GetFiles(string.Empty, $"{itemFilename}*{FileExtension}") .Concat(exportStorage.GetDirectories(string.Empty));
.Concat(exportStorage.GetDirectories(string.Empty));
string filename = NamingUtils.GetNextBestFilename(existingExports, $"{itemFilename}{FileExtension}"); string filename = NamingUtils.GetNextBestFilename(existingExports, $"{itemFilename}{FileExtension}");
using (var stream = exportStorage.CreateFileSafely(filename)) ProgressNotification notification = new ProgressNotification
ExportModelTo(item, stream); {
State = ProgressNotificationState.Active,
Text = $"Exporting {itemFilename}...",
};
exportStorage.PresentFileExternally(filename); PostNotification?.Invoke(notification);
using var linkedSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, notification.CancellationToken);
try
{
using (var stream = exportStorage.CreateFileSafely(filename))
{
await ExportToStreamAsync(model, stream, notification, linkedSource.Token).ConfigureAwait(false);
}
}
catch
{
notification.State = ProgressNotificationState.Cancelled;
// cleanup if export is failed or canceled.
exportStorage.Delete(filename);
throw;
}
notification.CompletionText = $"Exported {itemFilename}! Click to view.";
notification.CompletionClickAction = () => exportStorage.PresentFileExternally(filename);
notification.State = ProgressNotificationState.Completed;
} }
/// <summary> /// <summary>
/// Exports an item to the given output stream. /// Exports a model to a provided stream.
/// </summary> /// </summary>
/// <param name="model">The item to export.</param> /// <param name="model">The model to export.</param>
/// <param name="outputStream">The output stream to export to.</param> /// <param name="outputStream">The output stream to export to.</param>
public virtual void ExportModelTo(TModel model, Stream outputStream) /// <param name="notification">An optional notification to be updated with export progress.</param>
{ /// <param name="cancellationToken">A cancellation token.</param>
using (var archive = ZipArchive.Create()) public Task ExportToStreamAsync(Live<TModel> model, Stream outputStream, ProgressNotification? notification = null, CancellationToken cancellationToken = default) =>
{ Task.Run(() => { model.PerformRead(s => ExportToStream(s, outputStream, notification, cancellationToken)); }, cancellationToken);
foreach (var file in model.Files)
archive.AddEntry(file.Filename, UserFileStorage.GetStream(file.File.GetStoragePath()));
archive.SaveTo(outputStream); /// <summary>
} /// Exports a model to a provided stream.
} /// </summary>
/// <param name="model">The model to export.</param>
/// <param name="outputStream">The output stream to export to.</param>
/// <param name="notification">An optional notification to be updated with export progress.</param>
/// <param name="cancellationToken">A cancellation token.</param>
public abstract void ExportToStream(TModel model, Stream outputStream, ProgressNotification? notification, CancellationToken cancellationToken = default);
} }
} }

View File

@ -1,20 +1,18 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // 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. // See the LICENCE file in the repository root for full licence text.
#nullable disable
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Threading;
using osu.Framework.Platform; using osu.Framework.Platform;
using osu.Game.Extensions; using osu.Game.Extensions;
using osu.Game.Overlays.Notifications;
using osu.Game.Scoring; using osu.Game.Scoring;
namespace osu.Game.Database namespace osu.Game.Database
{ {
public class LegacyScoreExporter : LegacyExporter<ScoreInfo> public class LegacyScoreExporter : LegacyExporter<ScoreInfo>
{ {
protected override string FileExtension => ".osr";
public LegacyScoreExporter(Storage storage) public LegacyScoreExporter(Storage storage)
: base(storage) : base(storage)
{ {
@ -28,7 +26,9 @@ namespace osu.Game.Database
return filename; return filename;
} }
public override void ExportModelTo(ScoreInfo model, Stream outputStream) protected override string FileExtension => @".osr";
public override void ExportToStream(ScoreInfo model, Stream outputStream, ProgressNotification? notification, CancellationToken cancellationToken = default)
{ {
var file = model.Files.SingleOrDefault(); var file = model.Files.SingleOrDefault();
if (file == null) if (file == null)

View File

@ -1,8 +1,6 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // 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. // See the LICENCE file in the repository root for full licence text.
#nullable disable
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
@ -22,7 +20,7 @@ namespace osu.Game.Database
return Enumerable.Empty<string>(); return Enumerable.Empty<string>();
return storage.GetFiles(ImportFromStablePath) return storage.GetFiles(ImportFromStablePath)
.Where(p => Importer.HandledExtensions.Any(ext => Path.GetExtension(p)?.Equals(ext, StringComparison.OrdinalIgnoreCase) ?? false)) .Where(p => Importer.HandledExtensions.Any(ext => Path.GetExtension(p).Equals(ext, StringComparison.OrdinalIgnoreCase)))
.Select(path => storage.GetFullPath(path)); .Select(path => storage.GetFullPath(path));
} }

View File

@ -1,20 +1,18 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // 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. // See the LICENCE file in the repository root for full licence text.
#nullable disable
using osu.Framework.Platform; using osu.Framework.Platform;
using osu.Game.Skinning; using osu.Game.Skinning;
namespace osu.Game.Database namespace osu.Game.Database
{ {
public class LegacySkinExporter : LegacyExporter<SkinInfo> public class LegacySkinExporter : LegacyArchiveExporter<SkinInfo>
{ {
protected override string FileExtension => ".osk";
public LegacySkinExporter(Storage storage) public LegacySkinExporter(Storage storage)
: base(storage) : base(storage)
{ {
} }
protected override string FileExtension => @".osk";
} }
} }

View File

@ -1,8 +1,6 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // 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. // See the LICENCE file in the repository root for full licence text.
#nullable disable
using osu.Game.Skinning; using osu.Game.Skinning;
namespace osu.Game.Database namespace osu.Game.Database

View File

@ -17,8 +17,6 @@ using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.UserInterface; using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input.Events; using osu.Framework.Input.Events;
using osu.Framework.Localisation; using osu.Framework.Localisation;
using osu.Framework.Platform;
using osu.Game.Database;
using osu.Game.Extensions; using osu.Game.Extensions;
using osu.Game.Graphics; using osu.Game.Graphics;
using osu.Game.Graphics.Containers; using osu.Game.Graphics.Containers;
@ -66,18 +64,18 @@ namespace osu.Game.Online.Leaderboards
private List<ScoreComponentLabel> statisticsLabels; private List<ScoreComponentLabel> statisticsLabels;
[Resolved(CanBeNull = true)] [Resolved(canBeNull: true)]
private IDialogOverlay dialogOverlay { get; set; } private IDialogOverlay dialogOverlay { get; set; }
[Resolved(CanBeNull = true)] [Resolved(canBeNull: true)]
private SongSelect songSelect { get; set; } private SongSelect songSelect { get; set; }
[Resolved]
private Storage storage { get; set; }
public ITooltip<ScoreInfo> GetCustomTooltip() => new LeaderboardScoreTooltip(); public ITooltip<ScoreInfo> GetCustomTooltip() => new LeaderboardScoreTooltip();
public virtual ScoreInfo TooltipContent => Score; public virtual ScoreInfo TooltipContent => Score;
[Resolved]
private ScoreManager scoreManager { get; set; } = null!;
public LeaderboardScore(ScoreInfo score, int? rank, bool isOnlineScope = true) public LeaderboardScore(ScoreInfo score, int? rank, bool isOnlineScope = true)
{ {
Score = score; Score = score;
@ -90,7 +88,7 @@ namespace osu.Game.Online.Leaderboards
} }
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load(IAPIProvider api, OsuColour colour, ScoreManager scoreManager) private void load(IAPIProvider api, OsuColour colour)
{ {
var user = Score.User; var user = Score.User;
@ -427,7 +425,7 @@ namespace osu.Game.Online.Leaderboards
if (Score.Files.Count > 0) if (Score.Files.Count > 0)
{ {
items.Add(new OsuMenuItem("Export", MenuItemType.Standard, () => new LegacyScoreExporter(storage).Export(Score))); items.Add(new OsuMenuItem("Export", MenuItemType.Standard, () => scoreManager.Export(Score)));
items.Add(new OsuMenuItem(CommonStrings.ButtonsDelete, MenuItemType.Destructive, () => dialogOverlay?.Push(new LocalScoreDeleteDialog(Score)))); items.Add(new OsuMenuItem(CommonStrings.ButtonsDelete, MenuItemType.Destructive, () => dialogOverlay?.Push(new LocalScoreDeleteDialog(Score))));
} }

View File

@ -13,7 +13,6 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation; using osu.Framework.Localisation;
using osu.Framework.Logging; using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Game.Database; using osu.Game.Database;
using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterface;
using osu.Game.Localisation; using osu.Game.Localisation;
@ -139,9 +138,6 @@ namespace osu.Game.Overlays.Settings.Sections
[Resolved] [Resolved]
private SkinManager skins { get; set; } private SkinManager skins { get; set; }
[Resolved]
private Storage storage { get; set; }
private Bindable<Skin> currentSkin; private Bindable<Skin> currentSkin;
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
@ -163,7 +159,7 @@ namespace osu.Game.Overlays.Settings.Sections
{ {
try try
{ {
currentSkin.Value.SkinInfo.PerformRead(s => new LegacySkinExporter(storage).Export(s)); skins.ExportCurrentSkin();
} }
catch (Exception e) catch (Exception e)
{ {

View File

@ -27,6 +27,7 @@ namespace osu.Game.Scoring
{ {
private readonly OsuConfigManager configManager; private readonly OsuConfigManager configManager;
private readonly ScoreImporter scoreImporter; private readonly ScoreImporter scoreImporter;
private readonly LegacyScoreExporter scoreExporter;
public override bool PauseImports public override bool PauseImports
{ {
@ -48,6 +49,11 @@ namespace osu.Game.Scoring
{ {
PostNotification = obj => PostNotification?.Invoke(obj) PostNotification = obj => PostNotification?.Invoke(obj)
}; };
scoreExporter = new LegacyScoreExporter(storage)
{
PostNotification = obj => PostNotification?.Invoke(obj)
};
} }
public Score GetScore(ScoreInfo score) => scoreImporter.GetScore(score); public Score GetScore(ScoreInfo score) => scoreImporter.GetScore(score);
@ -187,6 +193,8 @@ namespace osu.Game.Scoring
public Task<IEnumerable<Live<ScoreInfo>>> Import(ProgressNotification notification, ImportTask[] tasks, ImportParameters parameters = default) => scoreImporter.Import(notification, tasks); public Task<IEnumerable<Live<ScoreInfo>>> Import(ProgressNotification notification, ImportTask[] tasks, ImportParameters parameters = default) => scoreImporter.Import(notification, tasks);
public Task Export(ScoreInfo score) => scoreExporter.ExportAsync(score.ToLive(Realm));
public Task<Live<ScoreInfo>> ImportAsUpdate(ProgressNotification notification, ImportTask task, ScoreInfo original) => scoreImporter.ImportAsUpdate(notification, task, original); public Task<Live<ScoreInfo>> ImportAsUpdate(ProgressNotification notification, ImportTask task, ScoreInfo original) => scoreImporter.ImportAsUpdate(notification, task, original);
public Live<ScoreInfo> Import(ScoreInfo item, ArchiveReader archive = null, ImportParameters parameters = default, CancellationToken cancellationToken = default) => public Live<ScoreInfo> Import(ScoreInfo item, ArchiveReader archive = null, ImportParameters parameters = default, CancellationToken cancellationToken = default) =>

View File

@ -20,7 +20,6 @@ using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events; using osu.Framework.Input.Events;
using osu.Framework.Localisation; using osu.Framework.Localisation;
using osu.Framework.Logging; using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Framework.Screens; using osu.Framework.Screens;
using osu.Framework.Testing; using osu.Framework.Testing;
using osu.Framework.Threading; using osu.Framework.Threading;
@ -29,7 +28,6 @@ using osu.Game.Audio;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Configuration; using osu.Game.Configuration;
using osu.Game.Database;
using osu.Game.Graphics.Cursor; using osu.Game.Graphics.Cursor;
using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterface;
using osu.Game.Input.Bindings; using osu.Game.Input.Bindings;
@ -88,9 +86,6 @@ namespace osu.Game.Screens.Edit
[Resolved] [Resolved]
private RulesetStore rulesets { get; set; } private RulesetStore rulesets { get; set; }
[Resolved]
private Storage storage { get; set; }
[Resolved(canBeNull: true)] [Resolved(canBeNull: true)]
private IDialogOverlay dialogOverlay { get; set; } private IDialogOverlay dialogOverlay { get; set; }
@ -983,7 +978,7 @@ namespace osu.Game.Screens.Edit
private void exportBeatmap() private void exportBeatmap()
{ {
Save(); Save();
new LegacyBeatmapExporter(storage).Export(Beatmap.Value.BeatmapSetInfo); beatmapManager.Export(Beatmap.Value.BeatmapSetInfo);
} }
/// <summary> /// <summary>

View File

@ -58,6 +58,8 @@ namespace osu.Game.Skinning
private readonly SkinImporter skinImporter; private readonly SkinImporter skinImporter;
private readonly LegacySkinExporter skinExporter;
private readonly IResourceStore<byte[]> userFiles; private readonly IResourceStore<byte[]> userFiles;
private Skin argonSkin { get; } private Skin argonSkin { get; }
@ -120,6 +122,11 @@ namespace osu.Game.Skinning
SourceChanged?.Invoke(); SourceChanged?.Invoke();
}; };
skinExporter = new LegacySkinExporter(storage)
{
PostNotification = obj => PostNotification?.Invoke(obj)
};
} }
public void SelectRandomSkin() public void SelectRandomSkin()
@ -298,6 +305,10 @@ namespace osu.Game.Skinning
public Task<Live<SkinInfo>> Import(ImportTask task, ImportParameters parameters = default, CancellationToken cancellationToken = default) => public Task<Live<SkinInfo>> Import(ImportTask task, ImportParameters parameters = default, CancellationToken cancellationToken = default) =>
skinImporter.Import(task, parameters, cancellationToken); skinImporter.Import(task, parameters, cancellationToken);
public Task ExportCurrentSkin() => ExportSkin(CurrentSkinInfo.Value);
public Task ExportSkin(Live<SkinInfo> skin) => skinExporter.ExportAsync(skin);
#endregion #endregion
public void Delete([CanBeNull] Expression<Func<SkinInfo, bool>> filter = null, bool silent = false) public void Delete([CanBeNull] Expression<Func<SkinInfo, bool>> filter = null, bool silent = false)