diff --git a/osu.Android.props b/osu.Android.props
index 4859510e6c..8fad10d247 100644
--- a/osu.Android.props
+++ b/osu.Android.props
@@ -52,7 +52,7 @@
-
+
diff --git a/osu.Desktop/Program.cs b/osu.Desktop/Program.cs
index dc712f2593..8ccd23b418 100644
--- a/osu.Desktop/Program.cs
+++ b/osu.Desktop/Program.cs
@@ -74,7 +74,10 @@ namespace osu.Desktop
// we want to allow multiple instances to be started when in debug.
if (!DebugUtils.IsDebugBuild)
+ {
+ Logger.Log(@"osu! does not support multiple running instances.", LoggingTarget.Runtime, LogLevel.Error);
return 0;
+ }
}
if (tournamentClient)
diff --git a/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs
index 9feaa55051..82d76252d2 100644
--- a/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs
+++ b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs
@@ -52,7 +52,7 @@ namespace osu.Game.Rulesets.Catch.Difficulty
// In 2B beatmaps, it is possible that a normal Fruit is placed in the middle of a JuiceStream.
foreach (var hitObject in beatmap.HitObjects
- .SelectMany(obj => obj is JuiceStream stream ? stream.NestedHitObjects : new[] { obj })
+ .SelectMany(obj => obj is JuiceStream stream ? stream.NestedHitObjects.AsEnumerable() : new[] { obj })
.Cast()
.OrderBy(x => x.StartTime))
{
diff --git a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/PatternGenerator.cs b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/PatternGenerator.cs
index fb58d805a9..e643b82271 100644
--- a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/PatternGenerator.cs
+++ b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/PatternGenerator.cs
@@ -149,7 +149,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy
{
lowerBound ??= RandomStart;
upperBound ??= TotalColumns;
- nextColumn ??= (_ => GetRandomColumn(lowerBound, upperBound));
+ nextColumn ??= _ => GetRandomColumn(lowerBound, upperBound);
// Check for the initial column
if (isValid(initialColumn))
@@ -176,7 +176,19 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy
return initialColumn;
- bool isValid(int column) => validation?.Invoke(column) != false && !patterns.Any(p => p.ColumnHasObject(column));
+ bool isValid(int column)
+ {
+ if (validation?.Invoke(column) == false)
+ return false;
+
+ foreach (var p in patterns)
+ {
+ if (p.ColumnHasObject(column))
+ return false;
+ }
+
+ return true;
+ }
}
///
diff --git a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Pattern.cs b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Pattern.cs
index f095a0ffce..828f2ec393 100644
--- a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Pattern.cs
+++ b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Pattern.cs
@@ -12,46 +12,68 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns
///
internal class Pattern
{
- private readonly List hitObjects = new List();
+ private List hitObjects;
+ private HashSet containedColumns;
///
/// All the hit objects contained in this pattern.
///
- public IEnumerable HitObjects => hitObjects;
+ public IEnumerable HitObjects => hitObjects ?? Enumerable.Empty();
///
/// Check whether a column of this patterns contains a hit object.
///
/// The column index.
/// Whether the column with index contains a hit object.
- public bool ColumnHasObject(int column) => hitObjects.Exists(h => h.Column == column);
+ public bool ColumnHasObject(int column) => containedColumns?.Contains(column) == true;
///
/// Amount of columns taken up by hit objects in this pattern.
///
- public int ColumnWithObjects => HitObjects.GroupBy(h => h.Column).Count();
+ public int ColumnWithObjects => containedColumns?.Count ?? 0;
///
/// Adds a hit object to this pattern.
///
/// The hit object to add.
- public void Add(ManiaHitObject hitObject) => hitObjects.Add(hitObject);
+ public void Add(ManiaHitObject hitObject)
+ {
+ prepareStorage();
+
+ hitObjects.Add(hitObject);
+ containedColumns.Add(hitObject.Column);
+ }
///
/// Copies hit object from another pattern to this one.
///
/// The other pattern.
- public void Add(Pattern other) => hitObjects.AddRange(other.HitObjects);
+ public void Add(Pattern other)
+ {
+ prepareStorage();
+
+ if (other.hitObjects != null)
+ {
+ hitObjects.AddRange(other.hitObjects);
+
+ foreach (var h in other.hitObjects)
+ containedColumns.Add(h.Column);
+ }
+ }
///
/// Clears this pattern, removing all hit objects.
///
- public void Clear() => hitObjects.Clear();
+ public void Clear()
+ {
+ hitObjects?.Clear();
+ containedColumns?.Clear();
+ }
- ///
- /// Removes a hit object from this pattern.
- ///
- /// The hit object to remove.
- public bool Remove(ManiaHitObject hitObject) => hitObjects.Remove(hitObject);
+ private void prepareStorage()
+ {
+ hitObjects ??= new List();
+ containedColumns ??= new HashSet();
+ }
}
}
diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyReverseArrow.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyReverseArrow.cs
index fd7bfe7e60..7a071b5a03 100644
--- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyReverseArrow.cs
+++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyReverseArrow.cs
@@ -13,26 +13,20 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
{
public class LegacyReverseArrow : CompositeDrawable
{
- private ISkin skin { get; }
-
[Resolved(canBeNull: true)]
private DrawableHitObject drawableHitObject { get; set; }
private Drawable proxy;
- public LegacyReverseArrow(ISkin skin)
- {
- this.skin = skin;
- }
-
[BackgroundDependencyLoader]
- private void load()
+ private void load(ISkinSource skinSource)
{
AutoSizeAxes = Axes.Both;
string lookupName = new OsuSkinComponent(OsuSkinComponents.ReverseArrow).LookupName;
- InternalChild = skin.GetAnimation(lookupName, true, true) ?? Empty();
+ var skin = skinSource.FindProvider(s => s.GetTexture(lookupName) != null);
+ InternalChild = skin?.GetAnimation(lookupName, true, true) ?? Empty();
}
protected override void LoadComplete()
diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs
index 8a24e36420..ff9f6f0e07 100644
--- a/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs
+++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs
@@ -73,7 +73,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
case OsuSkinComponents.ReverseArrow:
if (hasHitCircle.Value)
- return new LegacyReverseArrow(this);
+ return new LegacyReverseArrow();
return null;
diff --git a/osu.Game.Tests/Beatmaps/IO/ImportBeatmapTest.cs b/osu.Game.Tests/Beatmaps/IO/ImportBeatmapTest.cs
index b2bd60d342..cba7f34ede 100644
--- a/osu.Game.Tests/Beatmaps/IO/ImportBeatmapTest.cs
+++ b/osu.Game.Tests/Beatmaps/IO/ImportBeatmapTest.cs
@@ -582,7 +582,6 @@ namespace osu.Game.Tests.Beatmaps.IO
[Test]
[NonParallelizable]
- [Ignore("Binding IPC on Appveyor isn't working (port in use). Need to figure out why")]
public void TestImportOverIPC()
{
using (HeadlessGameHost host = new CleanRunHeadlessGameHost($"{nameof(ImportBeatmapTest)}-host", true))
diff --git a/osu.Game.Tests/Database/TestRealmKeyBindingStore.cs b/osu.Game.Tests/Database/TestRealmKeyBindingStore.cs
index 8be74f1a7c..f10b11733e 100644
--- a/osu.Game.Tests/Database/TestRealmKeyBindingStore.cs
+++ b/osu.Game.Tests/Database/TestRealmKeyBindingStore.cs
@@ -32,7 +32,7 @@ namespace osu.Game.Tests.Database
storage = new NativeStorage(directory.FullName);
- realmContextFactory = new RealmContextFactory(storage);
+ realmContextFactory = new RealmContextFactory(storage, "test");
keyBindingStore = new RealmKeyBindingStore(realmContextFactory);
}
@@ -53,9 +53,9 @@ namespace osu.Game.Tests.Database
private int queryCount(GlobalAction? match = null)
{
- using (var usage = realmContextFactory.GetForRead())
+ using (var realm = realmContextFactory.CreateContext())
{
- var results = usage.Realm.All();
+ var results = realm.All();
if (match.HasValue)
results = results.Where(k => k.ActionInt == (int)match.Value);
return results.Count();
@@ -69,26 +69,24 @@ namespace osu.Game.Tests.Database
keyBindingStore.Register(testContainer, Enumerable.Empty());
- using (var primaryUsage = realmContextFactory.GetForRead())
+ using (var primaryRealm = realmContextFactory.CreateContext())
{
- var backBinding = primaryUsage.Realm.All().Single(k => k.ActionInt == (int)GlobalAction.Back);
+ var backBinding = primaryRealm.All().Single(k => k.ActionInt == (int)GlobalAction.Back);
Assert.That(backBinding.KeyCombination.Keys, Is.EquivalentTo(new[] { InputKey.Escape }));
var tsr = ThreadSafeReference.Create(backBinding);
- using (var usage = realmContextFactory.GetForWrite())
+ using (var threadedContext = realmContextFactory.CreateContext())
{
- var binding = usage.Realm.ResolveReference(tsr);
- binding.KeyCombination = new KeyCombination(InputKey.BackSpace);
-
- usage.Commit();
+ var binding = threadedContext.ResolveReference(tsr);
+ threadedContext.Write(() => binding.KeyCombination = new KeyCombination(InputKey.BackSpace));
}
Assert.That(backBinding.KeyCombination.Keys, Is.EquivalentTo(new[] { InputKey.BackSpace }));
// check still correct after re-query.
- backBinding = primaryUsage.Realm.All().Single(k => k.ActionInt == (int)GlobalAction.Back);
+ backBinding = primaryRealm.All().Single(k => k.ActionInt == (int)GlobalAction.Back);
Assert.That(backBinding.KeyCombination.Keys, Is.EquivalentTo(new[] { InputKey.BackSpace }));
}
}
diff --git a/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs b/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs
index 7e7e5ebc45..d38294aba9 100644
--- a/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs
+++ b/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs
@@ -158,18 +158,47 @@ namespace osu.Game.Tests.Online
public Task CurrentImportTask { get; private set; }
- protected override ArchiveDownloadRequest CreateDownloadRequest(BeatmapSetInfo set, bool minimiseDownloadSize)
- => new TestDownloadRequest(set);
-
- public TestBeatmapManager(Storage storage, IDatabaseContextFactory contextFactory, RulesetStore rulesets, IAPIProvider api, [NotNull] AudioManager audioManager, IResourceStore resources, GameHost host = null, WorkingBeatmap defaultBeatmap = null, bool performOnlineLookups = false)
- : base(storage, contextFactory, rulesets, api, audioManager, resources, host, defaultBeatmap, performOnlineLookups)
+ public TestBeatmapManager(Storage storage, IDatabaseContextFactory contextFactory, RulesetStore rulesets, IAPIProvider api, [NotNull] AudioManager audioManager, IResourceStore resources, GameHost host = null, WorkingBeatmap defaultBeatmap = null)
+ : base(storage, contextFactory, rulesets, api, audioManager, resources, host, defaultBeatmap)
{
}
- public override async Task Import(BeatmapSetInfo item, ArchiveReader archive = null, bool lowPriority = false, CancellationToken cancellationToken = default)
+ protected override BeatmapModelManager CreateBeatmapModelManager(Storage storage, IDatabaseContextFactory contextFactory, RulesetStore rulesets, IAPIProvider api, GameHost host)
{
- await AllowImport.Task.ConfigureAwait(false);
- return await (CurrentImportTask = base.Import(item, archive, lowPriority, cancellationToken)).ConfigureAwait(false);
+ return new TestBeatmapModelManager(this, storage, contextFactory, rulesets, api, host);
+ }
+
+ protected override BeatmapModelDownloader CreateBeatmapModelDownloader(BeatmapModelManager modelManager, IAPIProvider api, GameHost host)
+ {
+ return new TestBeatmapModelDownloader(modelManager, api, host);
+ }
+
+ internal class TestBeatmapModelDownloader : BeatmapModelDownloader
+ {
+ public TestBeatmapModelDownloader(BeatmapModelManager modelManager, IAPIProvider apiProvider, GameHost gameHost)
+ : base(modelManager, apiProvider, gameHost)
+ {
+ }
+
+ protected override ArchiveDownloadRequest CreateDownloadRequest(BeatmapSetInfo set, bool minimiseDownloadSize)
+ => new TestDownloadRequest(set);
+ }
+
+ internal class TestBeatmapModelManager : BeatmapModelManager
+ {
+ private readonly TestBeatmapManager testBeatmapManager;
+
+ public TestBeatmapModelManager(TestBeatmapManager testBeatmapManager, Storage storage, IDatabaseContextFactory databaseContextFactory, RulesetStore rulesetStore, IAPIProvider apiProvider, GameHost gameHost)
+ : base(storage, databaseContextFactory, rulesetStore, gameHost)
+ {
+ this.testBeatmapManager = testBeatmapManager;
+ }
+
+ public override async Task Import(BeatmapSetInfo item, ArchiveReader archive = null, bool lowPriority = false, CancellationToken cancellationToken = default)
+ {
+ await testBeatmapManager.AllowImport.Task.ConfigureAwait(false);
+ return await (testBeatmapManager.CurrentImportTask = base.Import(item, archive, lowPriority, cancellationToken)).ConfigureAwait(false);
+ }
}
}
diff --git a/osu.Game.Tests/Visual/Editing/TestSceneBlueprintOrdering.cs b/osu.Game.Tests/Visual/Editing/TestSceneBlueprintOrdering.cs
new file mode 100644
index 0000000000..5d8a6dabd7
--- /dev/null
+++ b/osu.Game.Tests/Visual/Editing/TestSceneBlueprintOrdering.cs
@@ -0,0 +1,132 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Linq;
+using NUnit.Framework;
+using osu.Framework.Testing;
+using osu.Game.Beatmaps;
+using osu.Game.Rulesets;
+using osu.Game.Rulesets.Objects;
+using osu.Game.Rulesets.Objects.Types;
+using osu.Game.Rulesets.Osu;
+using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles;
+using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components;
+using osu.Game.Rulesets.Osu.Objects;
+using osu.Game.Rulesets.Osu.UI;
+using osu.Game.Rulesets.UI;
+using osu.Game.Screens.Edit.Compose.Components;
+using osu.Game.Screens.Edit.Compose.Components.Timeline;
+using osu.Game.Tests.Beatmaps;
+using osuTK;
+using osuTK.Input;
+
+namespace osu.Game.Tests.Visual.Editing
+{
+ public class TestSceneBlueprintOrdering : EditorTestScene
+ {
+ protected override Ruleset CreateEditorRuleset() => new OsuRuleset();
+
+ protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset, false);
+
+ private EditorBlueprintContainer blueprintContainer
+ => Editor.ChildrenOfType().First();
+
+ [Test]
+ public void TestSelectedObjectHasPriorityWhenOverlapping()
+ {
+ var firstSlider = new Slider
+ {
+ Path = new SliderPath(new[]
+ {
+ new PathControlPoint(new Vector2()),
+ new PathControlPoint(new Vector2(150, -50)),
+ new PathControlPoint(new Vector2(300, 0))
+ }),
+ Position = new Vector2(0, 100)
+ };
+ var secondSlider = new Slider
+ {
+ Path = new SliderPath(new[]
+ {
+ new PathControlPoint(new Vector2()),
+ new PathControlPoint(new Vector2(-50, 50)),
+ new PathControlPoint(new Vector2(-100, 100))
+ }),
+ Position = new Vector2(200, 0)
+ };
+
+ AddStep("add overlapping sliders", () =>
+ {
+ EditorBeatmap.Add(firstSlider);
+ EditorBeatmap.Add(secondSlider);
+ });
+ AddStep("select first slider", () => EditorBeatmap.SelectedHitObjects.Add(firstSlider));
+
+ AddStep("move mouse to common point", () =>
+ {
+ var pos = blueprintContainer.ChildrenOfType().ElementAt(1).ScreenSpaceDrawQuad.Centre;
+ InputManager.MoveMouseTo(pos);
+ });
+ AddStep("right click", () => InputManager.Click(MouseButton.Right));
+
+ AddAssert("selection is unchanged", () => EditorBeatmap.SelectedHitObjects.Single() == firstSlider);
+ }
+
+ [Test]
+ public void TestOverlappingObjectsWithSameStartTime()
+ {
+ AddStep("add overlapping circles", () =>
+ {
+ EditorBeatmap.Add(createHitCircle(50, OsuPlayfield.BASE_SIZE / 2));
+ EditorBeatmap.Add(createHitCircle(50, OsuPlayfield.BASE_SIZE / 2 + new Vector2(-10, -20)));
+ EditorBeatmap.Add(createHitCircle(50, OsuPlayfield.BASE_SIZE / 2 + new Vector2(10, -20)));
+ });
+
+ AddStep("click at centre of playfield", () =>
+ {
+ var hitObjectContainer = Editor.ChildrenOfType().Single();
+ var centre = hitObjectContainer.ToScreenSpace(OsuPlayfield.BASE_SIZE / 2);
+ InputManager.MoveMouseTo(centre);
+ InputManager.Click(MouseButton.Left);
+ });
+
+ AddAssert("frontmost object selected", () =>
+ {
+ var hasCombo = Editor.ChildrenOfType().Single(b => b.IsSelected).Item as IHasComboInformation;
+ return hasCombo?.IndexInCurrentCombo == 0;
+ });
+ }
+
+ [Test]
+ public void TestPlacementOfConcurrentObjectWithDuration()
+ {
+ AddStep("seek to timing point", () => EditorClock.Seek(2170));
+ AddStep("add hit circle", () => EditorBeatmap.Add(createHitCircle(2170, Vector2.Zero)));
+
+ AddStep("choose spinner placement tool", () =>
+ {
+ InputManager.Key(Key.Number4);
+ var hitObjectContainer = Editor.ChildrenOfType().Single();
+ InputManager.MoveMouseTo(hitObjectContainer.ScreenSpaceDrawQuad.Centre);
+ });
+
+ AddStep("begin placing spinner", () =>
+ {
+ InputManager.Click(MouseButton.Left);
+ });
+ AddStep("end placing spinner", () =>
+ {
+ EditorClock.Seek(2500);
+ InputManager.Click(MouseButton.Right);
+ });
+
+ AddAssert("two timeline blueprints present", () => Editor.ChildrenOfType().Count() == 2);
+ }
+
+ private HitCircle createHitCircle(double startTime, Vector2 position) => new HitCircle
+ {
+ StartTime = startTime,
+ Position = position,
+ };
+ }
+}
diff --git a/osu.Game.Tests/Visual/Editing/TestSceneBlueprintSelection.cs b/osu.Game.Tests/Visual/Editing/TestSceneBlueprintSelection.cs
deleted file mode 100644
index 976bf93c15..0000000000
--- a/osu.Game.Tests/Visual/Editing/TestSceneBlueprintSelection.cs
+++ /dev/null
@@ -1,70 +0,0 @@
-// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
-// See the LICENCE file in the repository root for full licence text.
-
-using System.Linq;
-using NUnit.Framework;
-using osu.Framework.Testing;
-using osu.Game.Beatmaps;
-using osu.Game.Rulesets;
-using osu.Game.Rulesets.Objects;
-using osu.Game.Rulesets.Osu;
-using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components;
-using osu.Game.Rulesets.Osu.Objects;
-using osu.Game.Screens.Edit.Compose.Components;
-using osu.Game.Tests.Beatmaps;
-using osuTK;
-using osuTK.Input;
-
-namespace osu.Game.Tests.Visual.Editing
-{
- public class TestSceneBlueprintSelection : EditorTestScene
- {
- protected override Ruleset CreateEditorRuleset() => new OsuRuleset();
-
- protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset, false);
-
- private EditorBlueprintContainer blueprintContainer
- => Editor.ChildrenOfType().First();
-
- [Test]
- public void TestSelectedObjectHasPriorityWhenOverlapping()
- {
- var firstSlider = new Slider
- {
- Path = new SliderPath(new[]
- {
- new PathControlPoint(new Vector2()),
- new PathControlPoint(new Vector2(150, -50)),
- new PathControlPoint(new Vector2(300, 0))
- }),
- Position = new Vector2(0, 100)
- };
- var secondSlider = new Slider
- {
- Path = new SliderPath(new[]
- {
- new PathControlPoint(new Vector2()),
- new PathControlPoint(new Vector2(-50, 50)),
- new PathControlPoint(new Vector2(-100, 100))
- }),
- Position = new Vector2(200, 0)
- };
-
- AddStep("add overlapping sliders", () =>
- {
- EditorBeatmap.Add(firstSlider);
- EditorBeatmap.Add(secondSlider);
- });
- AddStep("select first slider", () => EditorBeatmap.SelectedHitObjects.Add(firstSlider));
-
- AddStep("move mouse to common point", () =>
- {
- var pos = blueprintContainer.ChildrenOfType().ElementAt(1).ScreenSpaceDrawQuad.Centre;
- InputManager.MoveMouseTo(pos);
- });
- AddStep("right click", () => InputManager.Click(MouseButton.Right));
-
- AddAssert("selection is unchanged", () => EditorBeatmap.SelectedHitObjects.Single() == firstSlider);
- }
- }
-}
diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorSelection.cs b/osu.Game.Tests/Visual/Editing/TestSceneComposerSelection.cs
similarity index 98%
rename from osu.Game.Tests/Visual/Editing/TestSceneEditorSelection.cs
rename to osu.Game.Tests/Visual/Editing/TestSceneComposerSelection.cs
index 4c4a87972f..a2a7b72283 100644
--- a/osu.Game.Tests/Visual/Editing/TestSceneEditorSelection.cs
+++ b/osu.Game.Tests/Visual/Editing/TestSceneComposerSelection.cs
@@ -20,14 +20,14 @@ using osuTK.Input;
namespace osu.Game.Tests.Visual.Editing
{
- public class TestSceneEditorSelection : EditorTestScene
+ public class TestSceneComposerSelection : EditorTestScene
{
protected override Ruleset CreateEditorRuleset() => new OsuRuleset();
protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset, false);
- private EditorBlueprintContainer blueprintContainer
- => Editor.ChildrenOfType().First();
+ private ComposeBlueprintContainer blueprintContainer
+ => Editor.ChildrenOfType().First();
private void moveMouseToObject(Func targetFunc)
{
diff --git a/osu.Game.Tests/Visual/Editing/TestSceneTimelineSelection.cs b/osu.Game.Tests/Visual/Editing/TestSceneTimelineSelection.cs
new file mode 100644
index 0000000000..2544b6c2a1
--- /dev/null
+++ b/osu.Game.Tests/Visual/Editing/TestSceneTimelineSelection.cs
@@ -0,0 +1,266 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using NUnit.Framework;
+using osu.Framework.Testing;
+using osu.Game.Beatmaps;
+using osu.Game.Rulesets;
+using osu.Game.Rulesets.Objects;
+using osu.Game.Rulesets.Osu;
+using osu.Game.Rulesets.Osu.Objects;
+using osu.Game.Screens.Edit.Compose.Components.Timeline;
+using osu.Game.Tests.Beatmaps;
+using osuTK;
+using osuTK.Input;
+
+namespace osu.Game.Tests.Visual.Editing
+{
+ public class TestSceneTimelineSelection : EditorTestScene
+ {
+ protected override Ruleset CreateEditorRuleset() => new OsuRuleset();
+
+ protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset, false);
+
+ private TimelineBlueprintContainer blueprintContainer
+ => Editor.ChildrenOfType().First();
+
+ private void moveMouseToObject(Func targetFunc)
+ {
+ AddStep("move mouse to object", () =>
+ {
+ var pos = blueprintContainer.SelectionBlueprints
+ .First(s => s.Item == targetFunc())
+ .ChildrenOfType()
+ .First().ScreenSpaceDrawQuad.Centre;
+
+ InputManager.MoveMouseTo(pos);
+ });
+ }
+
+ [Test]
+ public void TestNudgeSelection()
+ {
+ HitCircle[] addedObjects = null;
+
+ AddStep("add hitobjects", () => EditorBeatmap.AddRange(addedObjects = new[]
+ {
+ new HitCircle { StartTime = 100 },
+ new HitCircle { StartTime = 200, Position = new Vector2(100) },
+ new HitCircle { StartTime = 300, Position = new Vector2(200) },
+ new HitCircle { StartTime = 400, Position = new Vector2(300) },
+ }));
+
+ AddStep("select objects", () => EditorBeatmap.SelectedHitObjects.AddRange(addedObjects));
+
+ AddStep("nudge forwards", () => InputManager.Key(Key.K));
+ AddAssert("objects moved forwards in time", () => addedObjects[0].StartTime > 100);
+
+ AddStep("nudge backwards", () => InputManager.Key(Key.J));
+ AddAssert("objects reverted to original position", () => addedObjects[0].StartTime == 100);
+ }
+
+ [Test]
+ public void TestBasicSelect()
+ {
+ var addedObject = new HitCircle { StartTime = 100 };
+ AddStep("add hitobject", () => EditorBeatmap.Add(addedObject));
+
+ moveMouseToObject(() => addedObject);
+ AddStep("left click", () => InputManager.Click(MouseButton.Left));
+
+ AddAssert("hitobject selected", () => EditorBeatmap.SelectedHitObjects.Single() == addedObject);
+
+ var addedObject2 = new HitCircle
+ {
+ StartTime = 200,
+ Position = new Vector2(100),
+ };
+
+ AddStep("add one more hitobject", () => EditorBeatmap.Add(addedObject2));
+ AddAssert("selection unchanged", () => EditorBeatmap.SelectedHitObjects.Single() == addedObject);
+
+ moveMouseToObject(() => addedObject2);
+ AddStep("left click", () => InputManager.Click(MouseButton.Left));
+ AddAssert("hitobject selected", () => EditorBeatmap.SelectedHitObjects.Single() == addedObject2);
+ }
+
+ [Test]
+ public void TestMultiSelect()
+ {
+ var addedObjects = new[]
+ {
+ new HitCircle { StartTime = 100 },
+ new HitCircle { StartTime = 200, Position = new Vector2(100) },
+ new HitCircle { StartTime = 300, Position = new Vector2(200) },
+ new HitCircle { StartTime = 400, Position = new Vector2(300) },
+ };
+
+ AddStep("add hitobjects", () => EditorBeatmap.AddRange(addedObjects));
+
+ moveMouseToObject(() => addedObjects[0]);
+ AddStep("click first", () => InputManager.Click(MouseButton.Left));
+
+ AddAssert("hitobject selected", () => EditorBeatmap.SelectedHitObjects.Single() == addedObjects[0]);
+
+ AddStep("hold control", () => InputManager.PressKey(Key.ControlLeft));
+
+ moveMouseToObject(() => addedObjects[1]);
+ AddStep("click second", () => InputManager.Click(MouseButton.Left));
+ AddAssert("2 hitobjects selected", () => EditorBeatmap.SelectedHitObjects.Count == 2 && EditorBeatmap.SelectedHitObjects.Contains(addedObjects[1]));
+
+ moveMouseToObject(() => addedObjects[2]);
+ AddStep("click third", () => InputManager.Click(MouseButton.Left));
+ AddAssert("3 hitobjects selected", () => EditorBeatmap.SelectedHitObjects.Count == 3 && EditorBeatmap.SelectedHitObjects.Contains(addedObjects[2]));
+
+ moveMouseToObject(() => addedObjects[1]);
+ AddStep("click second", () => InputManager.Click(MouseButton.Left));
+ AddAssert("2 hitobjects selected", () => EditorBeatmap.SelectedHitObjects.Count == 2 && !EditorBeatmap.SelectedHitObjects.Contains(addedObjects[1]));
+
+ AddStep("release control", () => InputManager.ReleaseKey(Key.ControlLeft));
+ }
+
+ [Test]
+ public void TestBasicDeselect()
+ {
+ var addedObject = new HitCircle { StartTime = 100 };
+ AddStep("add hitobject", () => EditorBeatmap.Add(addedObject));
+
+ moveMouseToObject(() => addedObject);
+ AddStep("left click", () => InputManager.Click(MouseButton.Left));
+
+ AddAssert("hitobject selected", () => EditorBeatmap.SelectedHitObjects.Single() == addedObject);
+
+ AddStep("click away", () =>
+ {
+ InputManager.MoveMouseTo(Editor.ChildrenOfType().Single().ScreenSpaceDrawQuad.TopLeft + Vector2.One);
+ InputManager.Click(MouseButton.Left);
+ });
+
+ AddAssert("selection lost", () => EditorBeatmap.SelectedHitObjects.Count == 0);
+ }
+
+ [Test]
+ public void TestQuickDelete()
+ {
+ var addedObject = new HitCircle
+ {
+ StartTime = 0,
+ };
+
+ AddStep("add hitobject", () => EditorBeatmap.Add(addedObject));
+
+ moveMouseToObject(() => addedObject);
+
+ AddStep("hold shift", () => InputManager.PressKey(Key.ShiftLeft));
+ AddStep("right click", () => InputManager.Click(MouseButton.Right));
+ AddStep("release shift", () => InputManager.ReleaseKey(Key.ShiftLeft));
+
+ AddAssert("no hitobjects in beatmap", () => EditorBeatmap.HitObjects.Count == 0);
+ }
+
+ [Test]
+ public void TestRangeSelect()
+ {
+ var addedObjects = new[]
+ {
+ new HitCircle { StartTime = 100 },
+ new HitCircle { StartTime = 200, Position = new Vector2(100) },
+ new HitCircle { StartTime = 300, Position = new Vector2(200) },
+ new HitCircle { StartTime = 400, Position = new Vector2(300) },
+ new HitCircle { StartTime = 500, Position = new Vector2(400) },
+ };
+
+ AddStep("add hitobjects", () => EditorBeatmap.AddRange(addedObjects));
+
+ moveMouseToObject(() => addedObjects[1]);
+ AddStep("click second", () => InputManager.Click(MouseButton.Left));
+
+ AddAssert("hitobject selected", () => EditorBeatmap.SelectedHitObjects.Single() == addedObjects[1]);
+
+ AddStep("hold shift", () => InputManager.PressKey(Key.ShiftLeft));
+
+ moveMouseToObject(() => addedObjects[3]);
+ AddStep("click fourth", () => InputManager.Click(MouseButton.Left));
+ assertSelectionIs(addedObjects.Skip(1).Take(3));
+
+ moveMouseToObject(() => addedObjects[0]);
+ AddStep("click first", () => InputManager.Click(MouseButton.Left));
+ assertSelectionIs(addedObjects.Take(2));
+
+ AddStep("clear selection", () => EditorBeatmap.SelectedHitObjects.Clear());
+ AddStep("release shift", () => InputManager.ReleaseKey(Key.ShiftLeft));
+
+ moveMouseToObject(() => addedObjects[0]);
+ AddStep("click first", () => InputManager.Click(MouseButton.Left));
+ assertSelectionIs(addedObjects.Take(1));
+
+ AddStep("hold ctrl", () => InputManager.PressKey(Key.ControlLeft));
+ moveMouseToObject(() => addedObjects[2]);
+ AddStep("click third", () => InputManager.Click(MouseButton.Left));
+ assertSelectionIs(new[] { addedObjects[0], addedObjects[2] });
+
+ AddStep("hold shift", () => InputManager.PressKey(Key.ShiftLeft));
+ moveMouseToObject(() => addedObjects[4]);
+ AddStep("click fifth", () => InputManager.Click(MouseButton.Left));
+ assertSelectionIs(addedObjects.Except(new[] { addedObjects[1] }));
+
+ moveMouseToObject(() => addedObjects[0]);
+ AddStep("click first", () => InputManager.Click(MouseButton.Left));
+ assertSelectionIs(addedObjects);
+
+ AddStep("clear selection", () => EditorBeatmap.SelectedHitObjects.Clear());
+ moveMouseToObject(() => addedObjects[0]);
+ AddStep("click first", () => InputManager.Click(MouseButton.Left));
+ assertSelectionIs(addedObjects.Take(1));
+
+ moveMouseToObject(() => addedObjects[1]);
+ AddStep("click first", () => InputManager.Click(MouseButton.Left));
+ assertSelectionIs(addedObjects.Take(2));
+
+ moveMouseToObject(() => addedObjects[2]);
+ AddStep("click first", () => InputManager.Click(MouseButton.Left));
+ assertSelectionIs(addedObjects.Take(3));
+
+ AddStep("release keys", () =>
+ {
+ InputManager.ReleaseKey(Key.ControlLeft);
+ InputManager.ReleaseKey(Key.ShiftLeft);
+ });
+ }
+
+ [Test]
+ public void TestRangeSelectAfterExternalSelection()
+ {
+ var addedObjects = new[]
+ {
+ new HitCircle { StartTime = 100 },
+ new HitCircle { StartTime = 200, Position = new Vector2(100) },
+ new HitCircle { StartTime = 300, Position = new Vector2(200) },
+ new HitCircle { StartTime = 400, Position = new Vector2(300) },
+ };
+
+ AddStep("add hitobjects", () => EditorBeatmap.AddRange(addedObjects));
+
+ AddStep("select all without mouse", () => EditorBeatmap.SelectedHitObjects.AddRange(EditorBeatmap.HitObjects));
+ assertSelectionIs(addedObjects);
+
+ AddStep("hold down shift", () => InputManager.PressKey(Key.ShiftLeft));
+
+ moveMouseToObject(() => addedObjects[1]);
+ AddStep("click second object", () => InputManager.Click(MouseButton.Left));
+ assertSelectionIs(addedObjects);
+
+ moveMouseToObject(() => addedObjects[3]);
+ AddStep("click fourth object", () => InputManager.Click(MouseButton.Left));
+ assertSelectionIs(addedObjects.Skip(1));
+
+ AddStep("release shift", () => InputManager.ReleaseKey(Key.ShiftLeft));
+ }
+
+ private void assertSelectionIs(IEnumerable hitObjects)
+ => AddAssert("correct hitobjects selected", () => EditorBeatmap.SelectedHitObjects.OrderBy(h => h.StartTime).SequenceEqual(hitObjects));
+ }
+}
diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs
index bddc7ab731..04676f656f 100644
--- a/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs
+++ b/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs
@@ -3,10 +3,12 @@
using System.Linq;
using NUnit.Framework;
+using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Screens;
using osu.Framework.Testing;
+using osu.Game.Configuration;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Cursor;
using osu.Game.Rulesets;
@@ -39,6 +41,45 @@ namespace osu.Game.Tests.Visual.Gameplay
confirmClockRunning(true);
}
+ [Test]
+ public void TestPauseWithLargeOffset()
+ {
+ double lastTime;
+ bool alwaysGoingForward = true;
+
+ AddStep("force large offset", () =>
+ {
+ var offset = (BindableDouble)LocalConfig.GetBindable(OsuSetting.AudioOffset);
+
+ // use a large negative offset to avoid triggering a fail from forwards seeking.
+ offset.MinValue = -5000;
+ offset.Value = -5000;
+ });
+
+ AddStep("add time forward check hook", () =>
+ {
+ lastTime = double.MinValue;
+ alwaysGoingForward = true;
+
+ Player.OnUpdate += _ =>
+ {
+ double currentTime = Player.GameplayClockContainer.CurrentTime;
+ alwaysGoingForward &= currentTime >= lastTime;
+ lastTime = currentTime;
+ };
+ });
+
+ AddStep("move cursor outside", () => InputManager.MoveMouseTo(Player.ScreenSpaceDrawQuad.TopLeft - new Vector2(10)));
+
+ pauseAndConfirm();
+
+ resumeAndConfirm();
+
+ AddAssert("time didn't go backwards", () => alwaysGoingForward);
+
+ AddStep("reset offset", () => LocalConfig.SetValue(OsuSetting.AudioOffset, 0.0));
+ }
+
[Test]
public void TestPauseResume()
{
diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoom.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoom.cs
index b1f5781f6f..22ff2b98ce 100644
--- a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoom.cs
+++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoom.cs
@@ -43,11 +43,12 @@ namespace osu.Game.Tests.Visual.Multiplayer
Spacing = new Vector2(10),
Children = new Drawable[]
{
- createDrawableRoom(new Room
+ createLoungeRoom(new Room
{
- Name = { Value = "Flyte's Trash Playlist" },
+ Name = { Value = "Multiplayer room" },
Status = { Value = new RoomStatusOpen() },
EndDate = { Value = DateTimeOffset.Now.AddDays(1) },
+ Type = { Value = MatchType.HeadToHead },
Playlist =
{
new PlaylistItem
@@ -65,9 +66,9 @@ namespace osu.Game.Tests.Visual.Multiplayer
}
}
}),
- createDrawableRoom(new Room
+ createLoungeRoom(new Room
{
- Name = { Value = "Room 2" },
+ Name = { Value = "Playlist room with multiple beatmaps" },
Status = { Value = new RoomStatusPlaying() },
EndDate = { Value = DateTimeOffset.Now.AddDays(1) },
Playlist =
@@ -100,15 +101,15 @@ namespace osu.Game.Tests.Visual.Multiplayer
}
}
}),
- createDrawableRoom(new Room
+ createLoungeRoom(new Room
{
- Name = { Value = "Room 3" },
+ Name = { Value = "Finished room" },
Status = { Value = new RoomStatusEnded() },
EndDate = { Value = DateTimeOffset.Now },
}),
- createDrawableRoom(new Room
+ createLoungeRoom(new Room
{
- Name = { Value = "Room 4 (spotlight)" },
+ Name = { Value = "Spotlight room" },
Status = { Value = new RoomStatusOpen() },
Category = { Value = RoomCategory.Spotlight },
}),
@@ -123,14 +124,14 @@ namespace osu.Game.Tests.Visual.Multiplayer
DrawableRoom drawableRoom = null;
Room room = null;
- AddStep("create room", () => Child = drawableRoom = createDrawableRoom(room = new Room
+ AddStep("create room", () => Child = drawableRoom = createLoungeRoom(room = new Room
{
Name = { Value = "Room with password" },
Status = { Value = new RoomStatusOpen() },
Type = { Value = MatchType.HeadToHead },
}));
- AddUntilStep("wait for panel load", () => drawableRoom.ChildrenOfType().Any());
+ AddUntilStep("wait for panel load", () => drawableRoom.ChildrenOfType().Any());
AddAssert("password icon hidden", () => Precision.AlmostEquals(0, drawableRoom.ChildrenOfType().Single().Alpha));
@@ -141,7 +142,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddAssert("password icon hidden", () => Precision.AlmostEquals(0, drawableRoom.ChildrenOfType().Single().Alpha));
}
- private DrawableRoom createDrawableRoom(Room room)
+ private DrawableRoom createLoungeRoom(Room room)
{
room.Host.Value ??= new User { Username = "peppy", Id = 2 };
diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneRecentParticipantsList.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomParticipantsList.cs
similarity index 66%
rename from osu.Game.Tests/Visual/Multiplayer/TestSceneRecentParticipantsList.cs
rename to osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomParticipantsList.cs
index 50ec2bf3ac..982dfc5cd9 100644
--- a/osu.Game.Tests/Visual/Multiplayer/TestSceneRecentParticipantsList.cs
+++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomParticipantsList.cs
@@ -13,16 +13,27 @@ using osu.Game.Users.Drawables;
namespace osu.Game.Tests.Visual.Multiplayer
{
- public class TestSceneRecentParticipantsList : OnlinePlayTestScene
+ public class TestSceneDrawableRoomParticipantsList : OnlinePlayTestScene
{
- private RecentParticipantsList list;
+ private DrawableRoomParticipantsList list;
[SetUp]
public new void Setup() => Schedule(() =>
{
- SelectedRoom.Value = new Room { Name = { Value = "test room" } };
+ SelectedRoom.Value = new Room
+ {
+ Name = { Value = "test room" },
+ Host =
+ {
+ Value = new User
+ {
+ Id = 2,
+ Username = "peppy",
+ }
+ }
+ };
- Child = list = new RecentParticipantsList
+ Child = list = new DrawableRoomParticipantsList
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
@@ -40,19 +51,19 @@ namespace osu.Game.Tests.Visual.Multiplayer
});
AddStep("set 8 circles", () => list.NumberOfCircles = 8);
- AddAssert("0 hidden users", () => list.ChildrenOfType().Single().Count == 0);
+ AddAssert("0 hidden users", () => list.ChildrenOfType().Single().Count == 0);
AddStep("add one more user", () => addUser(9));
- AddAssert("2 hidden users", () => list.ChildrenOfType().Single().Count == 2);
+ AddAssert("2 hidden users", () => list.ChildrenOfType().Single().Count == 2);
AddStep("remove first user", () => removeUserAt(0));
- AddAssert("0 hidden users", () => list.ChildrenOfType().Single().Count == 0);
+ AddAssert("0 hidden users", () => list.ChildrenOfType().Single().Count == 0);
AddStep("add one more user", () => addUser(9));
- AddAssert("2 hidden users", () => list.ChildrenOfType().Single().Count == 2);
+ AddAssert("2 hidden users", () => list.ChildrenOfType().Single().Count == 2);
AddStep("remove last user", () => removeUserAt(8));
- AddAssert("0 hidden users", () => list.ChildrenOfType().Single().Count == 0);
+ AddAssert("0 hidden users", () => list.ChildrenOfType().Single().Count == 0);
}
[Test]
@@ -69,9 +80,9 @@ namespace osu.Game.Tests.Visual.Multiplayer
for (int i = 0; i < 8; i++)
{
AddStep("remove user", () => removeUserAt(0));
- int remainingUsers = 7 - i;
+ int remainingUsers = 8 - i;
- int displayedUsers = remainingUsers > 3 ? 2 : remainingUsers;
+ int displayedUsers = remainingUsers > 4 ? 3 : remainingUsers;
AddAssert($"{displayedUsers} avatars displayed", () => list.ChildrenOfType().Count() == displayedUsers);
}
}
@@ -86,12 +97,12 @@ namespace osu.Game.Tests.Visual.Multiplayer
});
AddStep("set 3 circles", () => list.NumberOfCircles = 3);
- AddAssert("2 users displayed", () => list.ChildrenOfType().Count() == 2);
- AddAssert("48 hidden users", () => list.ChildrenOfType().Single().Count == 48);
+ AddAssert("3 users displayed", () => list.ChildrenOfType().Count() == 3);
+ AddAssert("48 hidden users", () => list.ChildrenOfType().Single().Count == 48);
AddStep("set 10 circles", () => list.NumberOfCircles = 10);
- AddAssert("9 users displayed", () => list.ChildrenOfType().Count() == 9);
- AddAssert("41 hidden users", () => list.ChildrenOfType().Single().Count == 41);
+ AddAssert("10 users displayed", () => list.ChildrenOfType().Count() == 10);
+ AddAssert("41 hidden users", () => list.ChildrenOfType().Single().Count == 41);
}
[Test]
@@ -104,24 +115,24 @@ namespace osu.Game.Tests.Visual.Multiplayer
});
AddStep("remove from start", () => removeUserAt(0));
- AddAssert("3 circles displayed", () => list.ChildrenOfType().Count() == 3);
- AddAssert("46 hidden users", () => list.ChildrenOfType().Single().Count == 46);
+ AddAssert("4 circles displayed", () => list.ChildrenOfType().Count() == 4);
+ AddAssert("46 hidden users", () => list.ChildrenOfType().Single().Count == 46);
AddStep("remove from end", () => removeUserAt(SelectedRoom.Value.RecentParticipants.Count - 1));
- AddAssert("3 circles displayed", () => list.ChildrenOfType().Count() == 3);
- AddAssert("45 hidden users", () => list.ChildrenOfType().Single().Count == 45);
+ AddAssert("4 circles displayed", () => list.ChildrenOfType().Count() == 4);
+ AddAssert("45 hidden users", () => list.ChildrenOfType().Single().Count == 45);
AddRepeatStep("remove 45 users", () => removeUserAt(0), 45);
- AddAssert("3 circles displayed", () => list.ChildrenOfType().Count() == 3);
- AddAssert("0 hidden users", () => list.ChildrenOfType().Single().Count == 0);
- AddAssert("hidden users bubble hidden", () => list.ChildrenOfType().Single().Alpha < 0.5f);
+ AddAssert("4 circles displayed", () => list.ChildrenOfType().Count() == 4);
+ AddAssert("0 hidden users", () => list.ChildrenOfType().Single().Count == 0);
+ AddAssert("hidden users bubble hidden", () => list.ChildrenOfType().Single().Alpha < 0.5f);
AddStep("remove another user", () => removeUserAt(0));
- AddAssert("2 circles displayed", () => list.ChildrenOfType().Count() == 2);
- AddAssert("0 hidden users", () => list.ChildrenOfType().Single().Count == 0);
+ AddAssert("3 circles displayed", () => list.ChildrenOfType().Count() == 3);
+ AddAssert("0 hidden users", () => list.ChildrenOfType().Single().Count == 0);
AddRepeatStep("remove the remaining two users", () => removeUserAt(0), 2);
- AddAssert("0 circles displayed", () => !list.ChildrenOfType().Any());
+ AddAssert("1 circle displayed", () => list.ChildrenOfType().Count() == 1);
}
private void addUser(int id)
diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneDialogOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneDialogOverlay.cs
index f5cba2c900..405461eec8 100644
--- a/osu.Game.Tests/Visual/UserInterface/TestSceneDialogOverlay.cs
+++ b/osu.Game.Tests/Visual/UserInterface/TestSceneDialogOverlay.cs
@@ -24,9 +24,10 @@ namespace osu.Game.Tests.Visual.UserInterface
[Test]
public void TestBasic()
{
- TestPopupDialog dialog = null;
+ TestPopupDialog firstDialog = null;
+ TestPopupDialog secondDialog = null;
- AddStep("dialog #1", () => overlay.Push(dialog = new TestPopupDialog
+ AddStep("dialog #1", () => overlay.Push(firstDialog = new TestPopupDialog
{
Icon = FontAwesome.Regular.TrashAlt,
HeaderText = @"Confirm deletion of",
@@ -46,9 +47,9 @@ namespace osu.Game.Tests.Visual.UserInterface
},
}));
- AddAssert("first dialog displayed", () => overlay.CurrentDialog == dialog);
+ AddAssert("first dialog displayed", () => overlay.CurrentDialog == firstDialog);
- AddStep("dialog #2", () => overlay.Push(dialog = new TestPopupDialog
+ AddStep("dialog #2", () => overlay.Push(secondDialog = new TestPopupDialog
{
Icon = FontAwesome.Solid.Cog,
HeaderText = @"What do you want to do with",
@@ -82,30 +83,33 @@ namespace osu.Game.Tests.Visual.UserInterface
},
}));
- AddAssert("second dialog displayed", () => overlay.CurrentDialog == dialog);
+ AddAssert("second dialog displayed", () => overlay.CurrentDialog == secondDialog);
+ AddAssert("first dialog is not part of hierarchy", () => firstDialog.Parent == null);
}
[Test]
public void TestDismissBeforePush()
{
+ TestPopupDialog testDialog = null;
AddStep("dismissed dialog push", () =>
{
- overlay.Push(new TestPopupDialog
+ overlay.Push(testDialog = new TestPopupDialog
{
State = { Value = Visibility.Hidden }
});
});
AddAssert("no dialog pushed", () => overlay.CurrentDialog == null);
+ AddAssert("dialog is not part of hierarchy", () => testDialog.Parent == null);
}
[Test]
public void TestDismissBeforePushViaButtonPress()
{
+ TestPopupDialog testDialog = null;
AddStep("dismissed dialog push", () =>
{
- TestPopupDialog dialog;
- overlay.Push(dialog = new TestPopupDialog
+ overlay.Push(testDialog = new TestPopupDialog
{
Buttons = new PopupDialogButton[]
{
@@ -113,10 +117,11 @@ namespace osu.Game.Tests.Visual.UserInterface
},
});
- dialog.PerformOkAction();
+ testDialog.PerformOkAction();
});
AddAssert("no dialog pushed", () => overlay.CurrentDialog == null);
+ AddAssert("dialog is not part of hierarchy", () => testDialog.Parent == null);
}
private class TestPopupDialog : PopupDialog
diff --git a/osu.Game/Beatmaps/BeatmapManager.cs b/osu.Game/Beatmaps/BeatmapManager.cs
index bd85017d58..2f80633279 100644
--- a/osu.Game/Beatmaps/BeatmapManager.cs
+++ b/osu.Game/Beatmaps/BeatmapManager.cs
@@ -6,111 +6,67 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Linq.Expressions;
-using System.Text;
using System.Threading;
using System.Threading.Tasks;
using JetBrains.Annotations;
-using Microsoft.EntityFrameworkCore;
using osu.Framework.Audio;
-using osu.Framework.Audio.Track;
using osu.Framework.Bindables;
-using osu.Framework.Extensions;
-using osu.Framework.Graphics.Textures;
using osu.Framework.IO.Stores;
-using osu.Framework.Lists;
-using osu.Framework.Logging;
using osu.Framework.Platform;
-using osu.Framework.Statistics;
using osu.Framework.Testing;
-using osu.Game.Beatmaps.Formats;
using osu.Game.Database;
using osu.Game.IO;
using osu.Game.IO.Archives;
using osu.Game.Online.API;
-using osu.Game.Online.API.Requests;
+using osu.Game.Overlays.Notifications;
using osu.Game.Rulesets;
-using osu.Game.Rulesets.Objects;
using osu.Game.Skinning;
using osu.Game.Users;
-using Decoder = osu.Game.Beatmaps.Formats.Decoder;
namespace osu.Game.Beatmaps
{
///
- /// Handles the storage and retrieval of Beatmaps/WorkingBeatmaps.
+ /// Handles general operations related to global beatmap management.
///
[ExcludeFromDynamicCompile]
- public partial class BeatmapManager : DownloadableArchiveModelManager, IDisposable, IBeatmapResourceProvider
+ public class BeatmapManager : IModelDownloader, IModelManager, IModelFileManager, ICanAcceptFiles, IWorkingBeatmapCache, IDisposable
{
- ///
- /// Fired when a single difficulty has been hidden.
- ///
- public IBindable> BeatmapHidden => beatmapHidden;
+ private readonly BeatmapModelManager beatmapModelManager;
+ private readonly BeatmapModelDownloader beatmapModelDownloader;
- private readonly Bindable> beatmapHidden = new Bindable>();
-
- ///
- /// Fired when a single difficulty has been restored.
- ///
- public IBindable> BeatmapRestored => beatmapRestored;
-
- private readonly Bindable> beatmapRestored = new Bindable>();
-
- ///
- /// A default representation of a WorkingBeatmap to use when no beatmap is available.
- ///
- public readonly WorkingBeatmap DefaultBeatmap;
-
- public override IEnumerable HandledExtensions => new[] { ".osz" };
-
- protected override string[] HashableFileTypes => new[] { ".osu" };
-
- protected override string ImportFromStablePath => ".";
-
- protected override Storage PrepareStableStorage(StableStorage stableStorage) => stableStorage.GetSongStorage();
-
- private readonly RulesetStore rulesets;
- private readonly BeatmapStore beatmaps;
- private readonly AudioManager audioManager;
- private readonly IResourceStore resources;
- private readonly LargeTextureStore largeTextureStore;
- private readonly ITrackStore trackStore;
-
- [CanBeNull]
- private readonly GameHost host;
-
- [CanBeNull]
- private readonly BeatmapOnlineLookupQueue onlineLookupQueue;
+ private readonly WorkingBeatmapCache workingBeatmapCache;
+ private readonly BeatmapOnlineLookupQueue onlineBetamapLookupQueue;
public BeatmapManager(Storage storage, IDatabaseContextFactory contextFactory, RulesetStore rulesets, IAPIProvider api, [NotNull] AudioManager audioManager, IResourceStore resources, GameHost host = null,
WorkingBeatmap defaultBeatmap = null, bool performOnlineLookups = false)
- : base(storage, contextFactory, api, new BeatmapStore(contextFactory), host)
{
- this.rulesets = rulesets;
- this.audioManager = audioManager;
- this.resources = resources;
- this.host = host;
+ beatmapModelManager = CreateBeatmapModelManager(storage, contextFactory, rulesets, api, host);
+ beatmapModelDownloader = CreateBeatmapModelDownloader(beatmapModelManager, api, host);
+ workingBeatmapCache = CreateWorkingBeatmapCache(audioManager, resources, new FileStore(contextFactory, storage).Store, defaultBeatmap, host);
- DefaultBeatmap = defaultBeatmap;
-
- beatmaps = (BeatmapStore)ModelStore;
- beatmaps.BeatmapHidden += b => beatmapHidden.Value = new WeakReference(b);
- beatmaps.BeatmapRestored += b => beatmapRestored.Value = new WeakReference(b);
- beatmaps.ItemRemoved += removeWorkingCache;
- beatmaps.ItemUpdated += removeWorkingCache;
+ workingBeatmapCache.BeatmapManager = beatmapModelManager;
if (performOnlineLookups)
- onlineLookupQueue = new BeatmapOnlineLookupQueue(api, storage);
-
- largeTextureStore = new LargeTextureStore(host?.CreateTextureLoaderStore(Files.Store));
- trackStore = audioManager.GetTrackStore(Files.Store);
+ {
+ onlineBetamapLookupQueue = new BeatmapOnlineLookupQueue(api, storage);
+ beatmapModelManager.OnlineLookupQueue = onlineBetamapLookupQueue;
+ }
}
- protected override ArchiveDownloadRequest CreateDownloadRequest(BeatmapSetInfo set, bool minimiseDownloadSize) =>
- new DownloadBeatmapSetRequest(set, minimiseDownloadSize);
+ protected virtual BeatmapModelDownloader CreateBeatmapModelDownloader(BeatmapModelManager modelManager, IAPIProvider api, GameHost host)
+ {
+ return new BeatmapModelDownloader(modelManager, api, host);
+ }
- protected override bool ShouldDeleteArchive(string path) => Path.GetExtension(path)?.ToLowerInvariant() == ".osz";
+ protected virtual WorkingBeatmapCache CreateWorkingBeatmapCache(AudioManager audioManager, IResourceStore resources, IResourceStore storage, WorkingBeatmap defaultBeatmap, GameHost host) =>
+ new WorkingBeatmapCache(audioManager, resources, storage, defaultBeatmap, host);
+ protected virtual BeatmapModelManager CreateBeatmapModelManager(Storage storage, IDatabaseContextFactory contextFactory, RulesetStore rulesets, IAPIProvider api, GameHost host) =>
+ new BeatmapModelManager(storage, contextFactory, rulesets, host);
+
+ ///
+ /// Create a new .
+ ///
public WorkingBeatmap CreateNew(RulesetInfo ruleset, User user)
{
var metadata = new BeatmapMetadata
@@ -134,112 +90,21 @@ namespace osu.Game.Beatmaps
}
};
- var working = Import(set).Result;
+ var working = beatmapModelManager.Import(set).Result;
return GetWorkingBeatmap(working.Beatmaps.First());
}
- protected override async Task Populate(BeatmapSetInfo beatmapSet, ArchiveReader archive, CancellationToken cancellationToken = default)
- {
- if (archive != null)
- beatmapSet.Beatmaps = createBeatmapDifficulties(beatmapSet.Files);
-
- foreach (BeatmapInfo b in beatmapSet.Beatmaps)
- {
- // remove metadata from difficulties where it matches the set
- if (beatmapSet.Metadata.Equals(b.Metadata))
- b.Metadata = null;
-
- b.BeatmapSet = beatmapSet;
- }
-
- validateOnlineIds(beatmapSet);
-
- bool hadOnlineBeatmapIDs = beatmapSet.Beatmaps.Any(b => b.OnlineBeatmapID > 0);
-
- if (onlineLookupQueue != null)
- await onlineLookupQueue.UpdateAsync(beatmapSet, cancellationToken).ConfigureAwait(false);
-
- // ensure at least one beatmap was able to retrieve or keep an online ID, else drop the set ID.
- if (hadOnlineBeatmapIDs && !beatmapSet.Beatmaps.Any(b => b.OnlineBeatmapID > 0))
- {
- if (beatmapSet.OnlineBeatmapSetID != null)
- {
- beatmapSet.OnlineBeatmapSetID = null;
- LogForModel(beatmapSet, "Disassociating beatmap set ID due to loss of all beatmap IDs");
- }
- }
- }
-
- protected override void PreImport(BeatmapSetInfo beatmapSet)
- {
- if (beatmapSet.Beatmaps.Any(b => b.BaseDifficulty == null))
- throw new InvalidOperationException($"Cannot import {nameof(BeatmapInfo)} with null {nameof(BeatmapInfo.BaseDifficulty)}.");
-
- // check if a set already exists with the same online id, delete if it does.
- if (beatmapSet.OnlineBeatmapSetID != null)
- {
- var existingOnlineId = beatmaps.ConsumableItems.FirstOrDefault(b => b.OnlineBeatmapSetID == beatmapSet.OnlineBeatmapSetID);
-
- if (existingOnlineId != null)
- {
- Delete(existingOnlineId);
-
- // in order to avoid a unique key constraint, immediately remove the online ID from the previous set.
- existingOnlineId.OnlineBeatmapSetID = null;
- foreach (var b in existingOnlineId.Beatmaps)
- b.OnlineBeatmapID = null;
-
- LogForModel(beatmapSet, $"Found existing beatmap set with same OnlineBeatmapSetID ({beatmapSet.OnlineBeatmapSetID}). It has been deleted.");
- }
- }
- }
-
- private void validateOnlineIds(BeatmapSetInfo beatmapSet)
- {
- var beatmapIds = beatmapSet.Beatmaps.Where(b => b.OnlineBeatmapID.HasValue).Select(b => b.OnlineBeatmapID).ToList();
-
- // ensure all IDs are unique
- if (beatmapIds.GroupBy(b => b).Any(g => g.Count() > 1))
- {
- LogForModel(beatmapSet, "Found non-unique IDs, resetting...");
- resetIds();
- return;
- }
-
- // find any existing beatmaps in the database that have matching online ids
- var existingBeatmaps = QueryBeatmaps(b => beatmapIds.Contains(b.OnlineBeatmapID)).ToList();
-
- if (existingBeatmaps.Count > 0)
- {
- // reset the import ids (to force a re-fetch) *unless* they match the candidate CheckForExisting set.
- // we can ignore the case where the new ids are contained by the CheckForExisting set as it will either be used (import skipped) or deleted.
- var existing = CheckForExisting(beatmapSet);
-
- if (existing == null || existingBeatmaps.Any(b => !existing.Beatmaps.Contains(b)))
- {
- LogForModel(beatmapSet, "Found existing import with IDs already, resetting...");
- resetIds();
- }
- }
-
- void resetIds() => beatmapSet.Beatmaps.ForEach(b => b.OnlineBeatmapID = null);
- }
-
- protected override bool CheckLocalAvailability(BeatmapSetInfo model, IQueryable items)
- => base.CheckLocalAvailability(model, items)
- || (model.OnlineBeatmapSetID != null && items.Any(b => b.OnlineBeatmapSetID == model.OnlineBeatmapSetID));
+ #region Delegation to BeatmapModelManager (methods which previously existed locally).
///
- /// Delete a beatmap difficulty.
+ /// Fired when a single difficulty has been hidden.
///
- /// The beatmap difficulty to hide.
- public void Hide(BeatmapInfo beatmap) => beatmaps.Hide(beatmap);
+ public IBindable> BeatmapHidden => beatmapModelManager.BeatmapHidden;
///
- /// Restore a beatmap difficulty.
+ /// Fired when a single difficulty has been restored.
///
- /// The beatmap difficulty to restore.
- public void Restore(BeatmapInfo beatmap) => beatmaps.Restore(beatmap);
+ public IBindable> BeatmapRestored => beatmapModelManager.BeatmapRestored;
///
/// Saves an file against a given .
@@ -247,109 +112,13 @@ namespace osu.Game.Beatmaps
/// The to save the content against. The file referenced by will be replaced.
/// The content to write.
/// The beatmap content to write, null if to be omitted.
- public virtual void Save(BeatmapInfo info, IBeatmap beatmapContent, ISkin beatmapSkin = null)
- {
- var setInfo = info.BeatmapSet;
-
- using (var stream = new MemoryStream())
- {
- using (var sw = new StreamWriter(stream, Encoding.UTF8, 1024, true))
- new LegacyBeatmapEncoder(beatmapContent, beatmapSkin).Encode(sw);
-
- stream.Seek(0, SeekOrigin.Begin);
-
- using (ContextFactory.GetForWrite())
- {
- var beatmapInfo = setInfo.Beatmaps.Single(b => b.ID == info.ID);
- var metadata = beatmapInfo.Metadata ?? setInfo.Metadata;
-
- // grab the original file (or create a new one if not found).
- var fileInfo = setInfo.Files.SingleOrDefault(f => string.Equals(f.Filename, beatmapInfo.Path, StringComparison.OrdinalIgnoreCase)) ?? new BeatmapSetFileInfo();
-
- // metadata may have changed; update the path with the standard format.
- beatmapInfo.Path = $"{metadata.Artist} - {metadata.Title} ({metadata.Author}) [{beatmapInfo.Version}].osu";
- beatmapInfo.MD5Hash = stream.ComputeMD5Hash();
-
- // update existing or populate new file's filename.
- fileInfo.Filename = beatmapInfo.Path;
-
- stream.Seek(0, SeekOrigin.Begin);
- ReplaceFile(setInfo, fileInfo, stream);
- }
- }
-
- removeWorkingCache(info);
- }
-
- private readonly WeakList workingCache = new WeakList();
-
- ///
- /// Retrieve a instance for the provided
- ///
- /// The beatmap to lookup.
- /// A instance correlating to the provided .
- public virtual WorkingBeatmap GetWorkingBeatmap(BeatmapInfo beatmapInfo)
- {
- // if there are no files, presume the full beatmap info has not yet been fetched from the database.
- if (beatmapInfo?.BeatmapSet?.Files.Count == 0)
- {
- int lookupId = beatmapInfo.ID;
- beatmapInfo = QueryBeatmap(b => b.ID == lookupId);
- }
-
- if (beatmapInfo?.BeatmapSet == null)
- return DefaultBeatmap;
-
- lock (workingCache)
- {
- var working = workingCache.FirstOrDefault(w => w.BeatmapInfo?.ID == beatmapInfo.ID);
- if (working != null)
- return working;
-
- beatmapInfo.Metadata ??= beatmapInfo.BeatmapSet.Metadata;
-
- workingCache.Add(working = new BeatmapManagerWorkingBeatmap(beatmapInfo, this));
-
- // best effort; may be higher than expected.
- GlobalStatistics.Get(nameof(Beatmaps), $"Cached {nameof(WorkingBeatmap)}s").Value = workingCache.Count();
-
- return working;
- }
- }
-
- ///
- /// Perform a lookup query on available s.
- ///
- /// The query.
- /// The first result for the provided query, or null if no results were found.
- public BeatmapSetInfo QueryBeatmapSet(Expression> query) => beatmaps.ConsumableItems.AsNoTracking().FirstOrDefault(query);
-
- protected override bool CanSkipImport(BeatmapSetInfo existing, BeatmapSetInfo import)
- {
- if (!base.CanSkipImport(existing, import))
- return false;
-
- return existing.Beatmaps.Any(b => b.OnlineBeatmapID != null);
- }
-
- protected override bool CanReuseExisting(BeatmapSetInfo existing, BeatmapSetInfo import)
- {
- if (!base.CanReuseExisting(existing, import))
- return false;
-
- var existingIds = existing.Beatmaps.Select(b => b.OnlineBeatmapID).OrderBy(i => i);
- var importIds = import.Beatmaps.Select(b => b.OnlineBeatmapID).OrderBy(i => i);
-
- // force re-import if we are not in a sane state.
- return existing.OnlineBeatmapSetID == import.OnlineBeatmapSetID && existingIds.SequenceEqual(importIds);
- }
+ public virtual void Save(BeatmapInfo info, IBeatmap beatmapContent, ISkin beatmapSkin = null) => beatmapModelManager.Save(info, beatmapContent, beatmapSkin);
///
/// Returns a list of all usable s.
///
/// A list of available .
- public List GetAllUsableBeatmapSets(IncludedDetails includes = IncludedDetails.All, bool includeProtected = false) =>
- GetAllUsableBeatmapSetsEnumerable(includes, includeProtected).ToList();
+ public List GetAllUsableBeatmapSets(IncludedDetails includes = IncludedDetails.All, bool includeProtected = false) => beatmapModelManager.GetAllUsableBeatmapSets(includes, includeProtected);
///
/// Returns a list of all usable s. Note that files are not populated.
@@ -357,34 +126,7 @@ namespace osu.Game.Beatmaps
/// The level of detail to include in the returned objects.
/// Whether to include protected (system) beatmaps. These should not be included for gameplay playable use cases.
/// A list of available .
- public IEnumerable GetAllUsableBeatmapSetsEnumerable(IncludedDetails includes, bool includeProtected = false)
- {
- IQueryable queryable;
-
- switch (includes)
- {
- case IncludedDetails.Minimal:
- queryable = beatmaps.BeatmapSetsOverview;
- break;
-
- case IncludedDetails.AllButRuleset:
- queryable = beatmaps.BeatmapSetsWithoutRuleset;
- break;
-
- case IncludedDetails.AllButFiles:
- queryable = beatmaps.BeatmapSetsWithoutFiles;
- break;
-
- default:
- queryable = beatmaps.ConsumableItems;
- break;
- }
-
- // AsEnumerable used here to avoid applying the WHERE in sql. When done so, ef core 2.x uses an incorrect ORDER BY
- // clause which causes queries to take 5-10x longer.
- // TODO: remove if upgrading to EF core 3.x.
- return queryable.AsEnumerable().Where(s => !s.DeletePending && (includeProtected || !s.Protected));
- }
+ public IEnumerable GetAllUsableBeatmapSetsEnumerable(IncludedDetails includes, bool includeProtected = false) => beatmapModelManager.GetAllUsableBeatmapSetsEnumerable(includes, includeProtected);
///
/// Perform a lookup query on available s.
@@ -392,207 +134,204 @@ namespace osu.Game.Beatmaps
/// The query.
/// The level of detail to include in the returned objects.
/// Results from the provided query.
- public IEnumerable QueryBeatmapSets(Expression> query, IncludedDetails includes = IncludedDetails.All)
- {
- IQueryable queryable;
-
- switch (includes)
- {
- case IncludedDetails.Minimal:
- queryable = beatmaps.BeatmapSetsOverview;
- break;
-
- case IncludedDetails.AllButRuleset:
- queryable = beatmaps.BeatmapSetsWithoutRuleset;
- break;
-
- case IncludedDetails.AllButFiles:
- queryable = beatmaps.BeatmapSetsWithoutFiles;
- break;
-
- default:
- queryable = beatmaps.ConsumableItems;
- break;
- }
-
- return queryable.AsNoTracking().Where(query);
- }
+ public IEnumerable QueryBeatmapSets(Expression> query, IncludedDetails includes = IncludedDetails.All) => beatmapModelManager.QueryBeatmapSets(query, includes);
///
- /// Perform a lookup query on available s.
+ /// Perform a lookup query on available s.
///
/// The query.
/// The first result for the provided query, or null if no results were found.
- public BeatmapInfo QueryBeatmap(Expression> query) => beatmaps.Beatmaps.AsNoTracking().FirstOrDefault(query);
+ public BeatmapSetInfo QueryBeatmapSet(Expression> query) => beatmapModelManager.QueryBeatmapSet(query);
///
/// Perform a lookup query on available s.
///
/// The query.
/// Results from the provided query.
- public IQueryable QueryBeatmaps(Expression> query) => beatmaps.Beatmaps.AsNoTracking().Where(query);
+ public IQueryable QueryBeatmaps(Expression> query) => beatmapModelManager.QueryBeatmaps(query);
- protected override string HumanisedModelName => "beatmap";
+ ///
+ /// Perform a lookup query on available s.
+ ///
+ /// The query.
+ /// The first result for the provided query, or null if no results were found.
+ public BeatmapInfo QueryBeatmap(Expression> query) => beatmapModelManager.QueryBeatmap(query);
- protected override BeatmapSetInfo CreateModel(ArchiveReader reader)
+ ///
+ /// A default representation of a WorkingBeatmap to use when no beatmap is available.
+ ///
+ public WorkingBeatmap DefaultBeatmap => workingBeatmapCache.DefaultBeatmap;
+
+ ///
+ /// Fired when a notification should be presented to the user.
+ ///
+ public Action PostNotification
{
- // let's make sure there are actually .osu files to import.
- string mapName = reader.Filenames.FirstOrDefault(f => f.EndsWith(".osu", StringComparison.OrdinalIgnoreCase));
-
- if (string.IsNullOrEmpty(mapName))
+ set
{
- Logger.Log($"No beatmap files found in the beatmap archive ({reader.Name}).", LoggingTarget.Database);
- return null;
+ beatmapModelManager.PostNotification = value;
+ beatmapModelDownloader.PostNotification = value;
}
-
- Beatmap beatmap;
- using (var stream = new LineBufferedReader(reader.GetStream(mapName)))
- beatmap = Decoder.GetDecoder(stream).Decode(stream);
-
- return new BeatmapSetInfo
- {
- OnlineBeatmapSetID = beatmap.BeatmapInfo.BeatmapSet?.OnlineBeatmapSetID,
- Beatmaps = new List(),
- Metadata = beatmap.Metadata,
- DateAdded = DateTimeOffset.UtcNow
- };
}
///
- /// Create all required s for the provided archive.
+ /// Fired when the user requests to view the resulting import.
///
- private List createBeatmapDifficulties(List files)
- {
- var beatmapInfos = new List();
+ public Action> PresentImport { set => beatmapModelManager.PresentImport = value; }
- foreach (var file in files.Where(f => f.Filename.EndsWith(".osu", StringComparison.OrdinalIgnoreCase)))
- {
- using (var raw = Files.Store.GetStream(file.FileInfo.StoragePath))
- using (var ms = new MemoryStream()) // we need a memory stream so we can seek
- using (var sr = new LineBufferedReader(ms))
- {
- raw.CopyTo(ms);
- ms.Position = 0;
+ ///
+ /// Delete a beatmap difficulty.
+ ///
+ /// The beatmap difficulty to hide.
+ public void Hide(BeatmapInfo beatmap) => beatmapModelManager.Hide(beatmap);
- var decoder = Decoder.GetDecoder(sr);
- IBeatmap beatmap = decoder.Decode(sr);
-
- string hash = ms.ComputeSHA2Hash();
-
- if (beatmapInfos.Any(b => b.Hash == hash))
- continue;
-
- beatmap.BeatmapInfo.Path = file.Filename;
- beatmap.BeatmapInfo.Hash = hash;
- beatmap.BeatmapInfo.MD5Hash = ms.ComputeMD5Hash();
-
- var ruleset = rulesets.GetRuleset(beatmap.BeatmapInfo.RulesetID);
- beatmap.BeatmapInfo.Ruleset = ruleset;
-
- // TODO: this should be done in a better place once we actually need to dynamically update it.
- beatmap.BeatmapInfo.StarDifficulty = ruleset?.CreateInstance().CreateDifficultyCalculator(new DummyConversionBeatmap(beatmap)).Calculate().StarRating ?? 0;
- beatmap.BeatmapInfo.Length = calculateLength(beatmap);
- beatmap.BeatmapInfo.BPM = 60000 / beatmap.GetMostCommonBeatLength();
-
- beatmapInfos.Add(beatmap.BeatmapInfo);
- }
- }
-
- return beatmapInfos;
- }
-
- private double calculateLength(IBeatmap b)
- {
- if (!b.HitObjects.Any())
- return 0;
-
- var lastObject = b.HitObjects.Last();
-
- //TODO: this isn't always correct (consider mania where a non-last object may last for longer than the last in the list).
- double endTime = lastObject.GetEndTime();
- double startTime = b.HitObjects.First().StartTime;
-
- return endTime - startTime;
- }
-
- private void removeWorkingCache(BeatmapSetInfo info)
- {
- if (info.Beatmaps == null) return;
-
- foreach (var b in info.Beatmaps)
- removeWorkingCache(b);
- }
-
- private void removeWorkingCache(BeatmapInfo info)
- {
- lock (workingCache)
- {
- var working = workingCache.FirstOrDefault(w => w.BeatmapInfo?.ID == info.ID);
- if (working != null)
- workingCache.Remove(working);
- }
- }
-
- public void Dispose()
- {
- onlineLookupQueue?.Dispose();
- }
-
- #region IResourceStorageProvider
-
- TextureStore IBeatmapResourceProvider.LargeTextureStore => largeTextureStore;
- ITrackStore IBeatmapResourceProvider.Tracks => trackStore;
- AudioManager IStorageResourceProvider.AudioManager => audioManager;
- IResourceStore IStorageResourceProvider.Files => Files.Store;
- IResourceStore IStorageResourceProvider.Resources => resources;
- IResourceStore IStorageResourceProvider.CreateTextureLoaderStore(IResourceStore underlyingStore) => host?.CreateTextureLoaderStore(underlyingStore);
+ ///
+ /// Restore a beatmap difficulty.
+ ///
+ /// The beatmap difficulty to restore.
+ public void Restore(BeatmapInfo beatmap) => beatmapModelManager.Restore(beatmap);
#endregion
- ///
- /// A dummy WorkingBeatmap for the purpose of retrieving a beatmap for star difficulty calculation.
- ///
- private class DummyConversionBeatmap : WorkingBeatmap
+ #region Implementation of IModelManager
+
+ public bool IsAvailableLocally(BeatmapSetInfo model)
{
- private readonly IBeatmap beatmap;
-
- public DummyConversionBeatmap(IBeatmap beatmap)
- : base(beatmap.BeatmapInfo, null)
- {
- this.beatmap = beatmap;
- }
-
- protected override IBeatmap GetBeatmap() => beatmap;
- protected override Texture GetBackground() => null;
- protected override Track GetBeatmapTrack() => null;
- protected internal override ISkin GetSkin() => null;
- public override Stream GetStream(string storagePath) => null;
+ return beatmapModelManager.IsAvailableLocally(model);
}
- }
- ///
- /// The level of detail to include in database results.
- ///
- public enum IncludedDetails
- {
- ///
- /// Only include beatmap difficulties and set level metadata.
- ///
- Minimal,
+ public IBindable> ItemUpdated => beatmapModelManager.ItemUpdated;
- ///
- /// Include all difficulties, rulesets, difficulty metadata but no files.
- ///
- AllButFiles,
+ public IBindable> ItemRemoved => beatmapModelManager.ItemRemoved;
- ///
- /// Include everything except ruleset. Used for cases where we aren't sure the ruleset is present but still want to consume the beatmap.
- ///
- AllButRuleset,
+ public Task ImportFromStableAsync(StableStorage stableStorage)
+ {
+ return beatmapModelManager.ImportFromStableAsync(stableStorage);
+ }
- ///
- /// Include everything.
- ///
- All
+ public void Export(BeatmapSetInfo item)
+ {
+ beatmapModelManager.Export(item);
+ }
+
+ public void ExportModelTo(BeatmapSetInfo model, Stream outputStream)
+ {
+ beatmapModelManager.ExportModelTo(model, outputStream);
+ }
+
+ public void Update(BeatmapSetInfo item)
+ {
+ beatmapModelManager.Update(item);
+ }
+
+ public bool Delete(BeatmapSetInfo item)
+ {
+ return beatmapModelManager.Delete(item);
+ }
+
+ public void Delete(List items, bool silent = false)
+ {
+ beatmapModelManager.Delete(items, silent);
+ }
+
+ public void Undelete(List items, bool silent = false)
+ {
+ beatmapModelManager.Undelete(items, silent);
+ }
+
+ public void Undelete(BeatmapSetInfo item)
+ {
+ beatmapModelManager.Undelete(item);
+ }
+
+ #endregion
+
+ #region Implementation of IModelDownloader
+
+ public IBindable>> DownloadBegan => beatmapModelDownloader.DownloadBegan;
+
+ public IBindable>> DownloadFailed => beatmapModelDownloader.DownloadFailed;
+
+ public bool Download(BeatmapSetInfo model, bool minimiseDownloadSize = false)
+ {
+ return beatmapModelDownloader.Download(model, minimiseDownloadSize);
+ }
+
+ public ArchiveDownloadRequest GetExistingDownload(BeatmapSetInfo model)
+ {
+ return beatmapModelDownloader.GetExistingDownload(model);
+ }
+
+ #endregion
+
+ #region Implementation of ICanAcceptFiles
+
+ public Task Import(params string[] paths)
+ {
+ return beatmapModelManager.Import(paths);
+ }
+
+ public Task Import(params ImportTask[] tasks)
+ {
+ return beatmapModelManager.Import(tasks);
+ }
+
+ public Task> Import(ProgressNotification notification, params ImportTask[] tasks)
+ {
+ return beatmapModelManager.Import(notification, tasks);
+ }
+
+ public Task Import(ImportTask task, bool lowPriority = false, CancellationToken cancellationToken = default)
+ {
+ return beatmapModelManager.Import(task, lowPriority, cancellationToken);
+ }
+
+ public Task Import(ArchiveReader archive, bool lowPriority = false, CancellationToken cancellationToken = default)
+ {
+ return beatmapModelManager.Import(archive, lowPriority, cancellationToken);
+ }
+
+ public Task Import(BeatmapSetInfo item, ArchiveReader archive = null, bool lowPriority = false, CancellationToken cancellationToken = default)
+ {
+ return beatmapModelManager.Import(item, archive, lowPriority, cancellationToken);
+ }
+
+ public IEnumerable HandledExtensions => beatmapModelManager.HandledExtensions;
+
+ #endregion
+
+ #region Implementation of IWorkingBeatmapCache
+
+ public WorkingBeatmap GetWorkingBeatmap(BeatmapInfo importedBeatmap) => workingBeatmapCache.GetWorkingBeatmap(importedBeatmap);
+
+ #endregion
+
+ #region Implementation of IModelFileManager
+
+ public void ReplaceFile(BeatmapSetInfo model, BeatmapSetFileInfo file, Stream contents, string filename = null)
+ {
+ beatmapModelManager.ReplaceFile(model, file, contents, filename);
+ }
+
+ public void DeleteFile(BeatmapSetInfo model, BeatmapSetFileInfo file)
+ {
+ beatmapModelManager.DeleteFile(model, file);
+ }
+
+ public void AddFile(BeatmapSetInfo model, Stream contents, string filename)
+ {
+ beatmapModelManager.AddFile(model, contents, filename);
+ }
+
+ #endregion
+
+ #region Implementation of IDisposable
+
+ public void Dispose()
+ {
+ onlineBetamapLookupQueue?.Dispose();
+ }
+
+ #endregion
}
}
diff --git a/osu.Game/Beatmaps/BeatmapManager_BeatmapOnlineLookupQueue.cs b/osu.Game/Beatmaps/BeatmapManager_BeatmapOnlineLookupQueue.cs
deleted file mode 100644
index 6ae7f7481e..0000000000
--- a/osu.Game/Beatmaps/BeatmapManager_BeatmapOnlineLookupQueue.cs
+++ /dev/null
@@ -1,214 +0,0 @@
-// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
-// See the LICENCE file in the repository root for full licence text.
-
-using System;
-using System.IO;
-using System.Linq;
-using System.Threading;
-using System.Threading.Tasks;
-using Microsoft.Data.Sqlite;
-using osu.Framework.Development;
-using osu.Framework.IO.Network;
-using osu.Framework.Logging;
-using osu.Framework.Platform;
-using osu.Framework.Testing;
-using osu.Framework.Threading;
-using osu.Game.Online.API;
-using osu.Game.Online.API.Requests;
-using SharpCompress.Compressors;
-using SharpCompress.Compressors.BZip2;
-
-namespace osu.Game.Beatmaps
-{
- public partial class BeatmapManager
- {
- [ExcludeFromDynamicCompile]
- private class BeatmapOnlineLookupQueue : IDisposable
- {
- private readonly IAPIProvider api;
- private readonly Storage storage;
-
- private const int update_queue_request_concurrency = 4;
-
- private readonly ThreadedTaskScheduler updateScheduler = new ThreadedTaskScheduler(update_queue_request_concurrency, nameof(BeatmapOnlineLookupQueue));
-
- private FileWebRequest cacheDownloadRequest;
-
- private const string cache_database_name = "online.db";
-
- public BeatmapOnlineLookupQueue(IAPIProvider api, Storage storage)
- {
- this.api = api;
- this.storage = storage;
-
- // avoid downloading / using cache for unit tests.
- if (!DebugUtils.IsNUnitRunning && !storage.Exists(cache_database_name))
- prepareLocalCache();
- }
-
- public Task UpdateAsync(BeatmapSetInfo beatmapSet, CancellationToken cancellationToken)
- {
- return Task.WhenAll(beatmapSet.Beatmaps.Select(b => UpdateAsync(beatmapSet, b, cancellationToken)).ToArray());
- }
-
- // todo: expose this when we need to do individual difficulty lookups.
- protected Task UpdateAsync(BeatmapSetInfo beatmapSet, BeatmapInfo beatmap, CancellationToken cancellationToken)
- => Task.Factory.StartNew(() => lookup(beatmapSet, beatmap), cancellationToken, TaskCreationOptions.HideScheduler | TaskCreationOptions.RunContinuationsAsynchronously, updateScheduler);
-
- private void lookup(BeatmapSetInfo set, BeatmapInfo beatmap)
- {
- if (checkLocalCache(set, beatmap))
- return;
-
- if (api?.State.Value != APIState.Online)
- return;
-
- var req = new GetBeatmapRequest(beatmap);
-
- req.Failure += fail;
-
- try
- {
- // intentionally blocking to limit web request concurrency
- api.Perform(req);
-
- var res = req.Result;
-
- if (res != null)
- {
- beatmap.Status = res.Status;
- beatmap.BeatmapSet.Status = res.BeatmapSet.Status;
- beatmap.BeatmapSet.OnlineBeatmapSetID = res.OnlineBeatmapSetID;
- beatmap.OnlineBeatmapID = res.OnlineBeatmapID;
-
- if (beatmap.Metadata != null)
- beatmap.Metadata.AuthorID = res.AuthorID;
-
- if (beatmap.BeatmapSet.Metadata != null)
- beatmap.BeatmapSet.Metadata.AuthorID = res.AuthorID;
-
- LogForModel(set, $"Online retrieval mapped {beatmap} to {res.OnlineBeatmapSetID} / {res.OnlineBeatmapID}.");
- }
- }
- catch (Exception e)
- {
- fail(e);
- }
-
- void fail(Exception e)
- {
- beatmap.OnlineBeatmapID = null;
- LogForModel(set, $"Online retrieval failed for {beatmap} ({e.Message})");
- }
- }
-
- private void prepareLocalCache()
- {
- string cacheFilePath = storage.GetFullPath(cache_database_name);
- string compressedCacheFilePath = $"{cacheFilePath}.bz2";
-
- cacheDownloadRequest = new FileWebRequest(compressedCacheFilePath, $"https://assets.ppy.sh/client-resources/{cache_database_name}.bz2?{DateTimeOffset.UtcNow:yyyyMMdd}");
-
- cacheDownloadRequest.Failed += ex =>
- {
- File.Delete(compressedCacheFilePath);
- File.Delete(cacheFilePath);
-
- Logger.Log($"{nameof(BeatmapOnlineLookupQueue)}'s online cache download failed: {ex}", LoggingTarget.Database);
- };
-
- cacheDownloadRequest.Finished += () =>
- {
- try
- {
- using (var stream = File.OpenRead(cacheDownloadRequest.Filename))
- using (var outStream = File.OpenWrite(cacheFilePath))
- using (var bz2 = new BZip2Stream(stream, CompressionMode.Decompress, false))
- bz2.CopyTo(outStream);
-
- // set to null on completion to allow lookups to begin using the new source
- cacheDownloadRequest = null;
- }
- catch (Exception ex)
- {
- Logger.Log($"{nameof(BeatmapOnlineLookupQueue)}'s online cache extraction failed: {ex}", LoggingTarget.Database);
- File.Delete(cacheFilePath);
- }
- finally
- {
- File.Delete(compressedCacheFilePath);
- }
- };
-
- cacheDownloadRequest.PerformAsync();
- }
-
- private bool checkLocalCache(BeatmapSetInfo set, BeatmapInfo beatmap)
- {
- // download is in progress (or was, and failed).
- if (cacheDownloadRequest != null)
- return false;
-
- // database is unavailable.
- if (!storage.Exists(cache_database_name))
- return false;
-
- if (string.IsNullOrEmpty(beatmap.MD5Hash)
- && string.IsNullOrEmpty(beatmap.Path)
- && beatmap.OnlineBeatmapID == null)
- return false;
-
- try
- {
- using (var db = new SqliteConnection(storage.GetDatabaseConnectionString("online")))
- {
- db.Open();
-
- using (var cmd = db.CreateCommand())
- {
- cmd.CommandText = "SELECT beatmapset_id, beatmap_id, approved, user_id FROM osu_beatmaps WHERE checksum = @MD5Hash OR beatmap_id = @OnlineBeatmapID OR filename = @Path";
-
- cmd.Parameters.Add(new SqliteParameter("@MD5Hash", beatmap.MD5Hash));
- cmd.Parameters.Add(new SqliteParameter("@OnlineBeatmapID", beatmap.OnlineBeatmapID ?? (object)DBNull.Value));
- cmd.Parameters.Add(new SqliteParameter("@Path", beatmap.Path));
-
- using (var reader = cmd.ExecuteReader())
- {
- if (reader.Read())
- {
- var status = (BeatmapSetOnlineStatus)reader.GetByte(2);
-
- beatmap.Status = status;
- beatmap.BeatmapSet.Status = status;
- beatmap.BeatmapSet.OnlineBeatmapSetID = reader.GetInt32(0);
- beatmap.OnlineBeatmapID = reader.GetInt32(1);
-
- if (beatmap.Metadata != null)
- beatmap.Metadata.AuthorID = reader.GetInt32(3);
-
- if (beatmap.BeatmapSet.Metadata != null)
- beatmap.BeatmapSet.Metadata.AuthorID = reader.GetInt32(3);
-
- LogForModel(set, $"Cached local retrieval for {beatmap}.");
- return true;
- }
- }
- }
- }
- }
- catch (Exception ex)
- {
- LogForModel(set, $"Cached local retrieval for {beatmap} failed with {ex}.");
- }
-
- return false;
- }
-
- public void Dispose()
- {
- cacheDownloadRequest?.Dispose();
- updateScheduler?.Dispose();
- }
- }
- }
-}
diff --git a/osu.Game/Beatmaps/BeatmapModelDownloader.cs b/osu.Game/Beatmaps/BeatmapModelDownloader.cs
new file mode 100644
index 0000000000..ae482eeafd
--- /dev/null
+++ b/osu.Game/Beatmaps/BeatmapModelDownloader.cs
@@ -0,0 +1,21 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Framework.Platform;
+using osu.Game.Database;
+using osu.Game.Online.API;
+using osu.Game.Online.API.Requests;
+
+namespace osu.Game.Beatmaps
+{
+ public class BeatmapModelDownloader : ModelDownloader
+ {
+ protected override ArchiveDownloadRequest CreateDownloadRequest(BeatmapSetInfo set, bool minimiseDownloadSize) =>
+ new DownloadBeatmapSetRequest(set, minimiseDownloadSize);
+
+ public BeatmapModelDownloader(BeatmapModelManager beatmapModelManager, IAPIProvider api, GameHost host = null)
+ : base(beatmapModelManager, api, host)
+ {
+ }
+ }
+}
diff --git a/osu.Game/Beatmaps/BeatmapModelManager.cs b/osu.Game/Beatmaps/BeatmapModelManager.cs
new file mode 100644
index 0000000000..0beddc1e9b
--- /dev/null
+++ b/osu.Game/Beatmaps/BeatmapModelManager.cs
@@ -0,0 +1,473 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Linq.Expressions;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.EntityFrameworkCore;
+using osu.Framework.Audio.Track;
+using osu.Framework.Bindables;
+using osu.Framework.Extensions;
+using osu.Framework.Graphics.Textures;
+using osu.Framework.Logging;
+using osu.Framework.Platform;
+using osu.Framework.Testing;
+using osu.Game.Beatmaps.Formats;
+using osu.Game.Database;
+using osu.Game.IO;
+using osu.Game.IO.Archives;
+using osu.Game.Rulesets;
+using osu.Game.Rulesets.Objects;
+using osu.Game.Skinning;
+using Decoder = osu.Game.Beatmaps.Formats.Decoder;
+
+namespace osu.Game.Beatmaps
+{
+ ///
+ /// Handles ef-core storage of beatmaps.
+ ///
+ [ExcludeFromDynamicCompile]
+ public class BeatmapModelManager : ArchiveModelManager
+ {
+ ///
+ /// Fired when a single difficulty has been hidden.
+ ///
+ public IBindable> BeatmapHidden => beatmapHidden;
+
+ private readonly Bindable> beatmapHidden = new Bindable>();
+
+ ///
+ /// Fired when a single difficulty has been restored.
+ ///
+ public IBindable> BeatmapRestored => beatmapRestored;
+
+ ///
+ /// An online lookup queue component which handles populating online beatmap metadata.
+ ///
+ public BeatmapOnlineLookupQueue OnlineLookupQueue { private get; set; }
+
+ ///
+ /// The game working beatmap cache, used to invalidate entries on changes.
+ ///
+ public WorkingBeatmapCache WorkingBeatmapCache { private get; set; }
+
+ private readonly Bindable> beatmapRestored = new Bindable>();
+
+ public override IEnumerable HandledExtensions => new[] { ".osz" };
+
+ protected override string[] HashableFileTypes => new[] { ".osu" };
+
+ protected override string ImportFromStablePath => ".";
+
+ protected override Storage PrepareStableStorage(StableStorage stableStorage) => stableStorage.GetSongStorage();
+
+ private readonly BeatmapStore beatmaps;
+ private readonly RulesetStore rulesets;
+
+ public BeatmapModelManager(Storage storage, IDatabaseContextFactory contextFactory, RulesetStore rulesets, GameHost host = null)
+ : base(storage, contextFactory, new BeatmapStore(contextFactory), host)
+ {
+ this.rulesets = rulesets;
+
+ beatmaps = (BeatmapStore)ModelStore;
+ beatmaps.BeatmapHidden += b => beatmapHidden.Value = new WeakReference(b);
+ beatmaps.BeatmapRestored += b => beatmapRestored.Value = new WeakReference(b);
+ beatmaps.ItemRemoved += b => WorkingBeatmapCache?.Invalidate(b);
+ beatmaps.ItemUpdated += obj => WorkingBeatmapCache?.Invalidate(obj);
+ }
+
+ protected override bool ShouldDeleteArchive(string path) => Path.GetExtension(path)?.ToLowerInvariant() == ".osz";
+
+ protected override async Task Populate(BeatmapSetInfo beatmapSet, ArchiveReader archive, CancellationToken cancellationToken = default)
+ {
+ if (archive != null)
+ beatmapSet.Beatmaps = createBeatmapDifficulties(beatmapSet.Files);
+
+ foreach (BeatmapInfo b in beatmapSet.Beatmaps)
+ {
+ // remove metadata from difficulties where it matches the set
+ if (beatmapSet.Metadata.Equals(b.Metadata))
+ b.Metadata = null;
+
+ b.BeatmapSet = beatmapSet;
+ }
+
+ validateOnlineIds(beatmapSet);
+
+ bool hadOnlineBeatmapIDs = beatmapSet.Beatmaps.Any(b => b.OnlineBeatmapID > 0);
+
+ if (OnlineLookupQueue != null)
+ await OnlineLookupQueue.UpdateAsync(beatmapSet, cancellationToken).ConfigureAwait(false);
+
+ // ensure at least one beatmap was able to retrieve or keep an online ID, else drop the set ID.
+ if (hadOnlineBeatmapIDs && !beatmapSet.Beatmaps.Any(b => b.OnlineBeatmapID > 0))
+ {
+ if (beatmapSet.OnlineBeatmapSetID != null)
+ {
+ beatmapSet.OnlineBeatmapSetID = null;
+ LogForModel(beatmapSet, "Disassociating beatmap set ID due to loss of all beatmap IDs");
+ }
+ }
+ }
+
+ protected override void PreImport(BeatmapSetInfo beatmapSet)
+ {
+ if (beatmapSet.Beatmaps.Any(b => b.BaseDifficulty == null))
+ throw new InvalidOperationException($"Cannot import {nameof(BeatmapInfo)} with null {nameof(BeatmapInfo.BaseDifficulty)}.");
+
+ // check if a set already exists with the same online id, delete if it does.
+ if (beatmapSet.OnlineBeatmapSetID != null)
+ {
+ var existingOnlineId = beatmaps.ConsumableItems.FirstOrDefault(b => b.OnlineBeatmapSetID == beatmapSet.OnlineBeatmapSetID);
+
+ if (existingOnlineId != null)
+ {
+ Delete(existingOnlineId);
+
+ // in order to avoid a unique key constraint, immediately remove the online ID from the previous set.
+ existingOnlineId.OnlineBeatmapSetID = null;
+ foreach (var b in existingOnlineId.Beatmaps)
+ b.OnlineBeatmapID = null;
+
+ LogForModel(beatmapSet, $"Found existing beatmap set with same OnlineBeatmapSetID ({beatmapSet.OnlineBeatmapSetID}). It has been deleted.");
+ }
+ }
+ }
+
+ private void validateOnlineIds(BeatmapSetInfo beatmapSet)
+ {
+ var beatmapIds = beatmapSet.Beatmaps.Where(b => b.OnlineBeatmapID.HasValue).Select(b => b.OnlineBeatmapID).ToList();
+
+ // ensure all IDs are unique
+ if (beatmapIds.GroupBy(b => b).Any(g => g.Count() > 1))
+ {
+ LogForModel(beatmapSet, "Found non-unique IDs, resetting...");
+ resetIds();
+ return;
+ }
+
+ // find any existing beatmaps in the database that have matching online ids
+ var existingBeatmaps = QueryBeatmaps(b => beatmapIds.Contains(b.OnlineBeatmapID)).ToList();
+
+ if (existingBeatmaps.Count > 0)
+ {
+ // reset the import ids (to force a re-fetch) *unless* they match the candidate CheckForExisting set.
+ // we can ignore the case where the new ids are contained by the CheckForExisting set as it will either be used (import skipped) or deleted.
+ var existing = CheckForExisting(beatmapSet);
+
+ if (existing == null || existingBeatmaps.Any(b => !existing.Beatmaps.Contains(b)))
+ {
+ LogForModel(beatmapSet, "Found existing import with IDs already, resetting...");
+ resetIds();
+ }
+ }
+
+ void resetIds() => beatmapSet.Beatmaps.ForEach(b => b.OnlineBeatmapID = null);
+ }
+
+ ///
+ /// Delete a beatmap difficulty.
+ ///
+ /// The beatmap difficulty to hide.
+ public void Hide(BeatmapInfo beatmap) => beatmaps.Hide(beatmap);
+
+ ///
+ /// Restore a beatmap difficulty.
+ ///
+ /// The beatmap difficulty to restore.
+ public void Restore(BeatmapInfo beatmap) => beatmaps.Restore(beatmap);
+
+ ///
+ /// Saves an file against a given .
+ ///
+ /// The to save the content against. The file referenced by will be replaced.
+ /// The content to write.
+ /// The beatmap content to write, null if to be omitted.
+ public virtual void Save(BeatmapInfo info, IBeatmap beatmapContent, ISkin beatmapSkin = null)
+ {
+ var setInfo = info.BeatmapSet;
+
+ using (var stream = new MemoryStream())
+ {
+ using (var sw = new StreamWriter(stream, Encoding.UTF8, 1024, true))
+ new LegacyBeatmapEncoder(beatmapContent, beatmapSkin).Encode(sw);
+
+ stream.Seek(0, SeekOrigin.Begin);
+
+ using (ContextFactory.GetForWrite())
+ {
+ var beatmapInfo = setInfo.Beatmaps.Single(b => b.ID == info.ID);
+ var metadata = beatmapInfo.Metadata ?? setInfo.Metadata;
+
+ // grab the original file (or create a new one if not found).
+ var fileInfo = setInfo.Files.SingleOrDefault(f => string.Equals(f.Filename, beatmapInfo.Path, StringComparison.OrdinalIgnoreCase)) ?? new BeatmapSetFileInfo();
+
+ // metadata may have changed; update the path with the standard format.
+ beatmapInfo.Path = $"{metadata.Artist} - {metadata.Title} ({metadata.Author}) [{beatmapInfo.Version}].osu";
+ beatmapInfo.MD5Hash = stream.ComputeMD5Hash();
+
+ // update existing or populate new file's filename.
+ fileInfo.Filename = beatmapInfo.Path;
+
+ stream.Seek(0, SeekOrigin.Begin);
+ ReplaceFile(setInfo, fileInfo, stream);
+ }
+ }
+
+ WorkingBeatmapCache?.Invalidate(info);
+ }
+
+ ///
+ /// Perform a lookup query on available s.
+ ///
+ /// The query.
+ /// The first result for the provided query, or null if no results were found.
+ public BeatmapSetInfo QueryBeatmapSet(Expression> query) => beatmaps.ConsumableItems.AsNoTracking().FirstOrDefault(query);
+
+ protected override bool CanSkipImport(BeatmapSetInfo existing, BeatmapSetInfo import)
+ {
+ if (!base.CanSkipImport(existing, import))
+ return false;
+
+ return existing.Beatmaps.Any(b => b.OnlineBeatmapID != null);
+ }
+
+ protected override bool CanReuseExisting(BeatmapSetInfo existing, BeatmapSetInfo import)
+ {
+ if (!base.CanReuseExisting(existing, import))
+ return false;
+
+ var existingIds = existing.Beatmaps.Select(b => b.OnlineBeatmapID).OrderBy(i => i);
+ var importIds = import.Beatmaps.Select(b => b.OnlineBeatmapID).OrderBy(i => i);
+
+ // force re-import if we are not in a sane state.
+ return existing.OnlineBeatmapSetID == import.OnlineBeatmapSetID && existingIds.SequenceEqual(importIds);
+ }
+
+ ///
+ /// Returns a list of all usable s.
+ ///
+ /// A list of available .
+ public List GetAllUsableBeatmapSets(IncludedDetails includes = IncludedDetails.All, bool includeProtected = false) =>
+ GetAllUsableBeatmapSetsEnumerable(includes, includeProtected).ToList();
+
+ ///
+ /// Returns a list of all usable s. Note that files are not populated.
+ ///
+ /// The level of detail to include in the returned objects.
+ /// Whether to include protected (system) beatmaps. These should not be included for gameplay playable use cases.
+ /// A list of available .
+ public IEnumerable GetAllUsableBeatmapSetsEnumerable(IncludedDetails includes, bool includeProtected = false)
+ {
+ IQueryable queryable;
+
+ switch (includes)
+ {
+ case IncludedDetails.Minimal:
+ queryable = beatmaps.BeatmapSetsOverview;
+ break;
+
+ case IncludedDetails.AllButRuleset:
+ queryable = beatmaps.BeatmapSetsWithoutRuleset;
+ break;
+
+ case IncludedDetails.AllButFiles:
+ queryable = beatmaps.BeatmapSetsWithoutFiles;
+ break;
+
+ default:
+ queryable = beatmaps.ConsumableItems;
+ break;
+ }
+
+ // AsEnumerable used here to avoid applying the WHERE in sql. When done so, ef core 2.x uses an incorrect ORDER BY
+ // clause which causes queries to take 5-10x longer.
+ // TODO: remove if upgrading to EF core 3.x.
+ return queryable.AsEnumerable().Where(s => !s.DeletePending && (includeProtected || !s.Protected));
+ }
+
+ ///
+ /// Perform a lookup query on available s.
+ ///
+ /// The query.
+ /// The level of detail to include in the returned objects.
+ /// Results from the provided query.
+ public IEnumerable QueryBeatmapSets(Expression> query, IncludedDetails includes = IncludedDetails.All)
+ {
+ IQueryable queryable;
+
+ switch (includes)
+ {
+ case IncludedDetails.Minimal:
+ queryable = beatmaps.BeatmapSetsOverview;
+ break;
+
+ case IncludedDetails.AllButRuleset:
+ queryable = beatmaps.BeatmapSetsWithoutRuleset;
+ break;
+
+ case IncludedDetails.AllButFiles:
+ queryable = beatmaps.BeatmapSetsWithoutFiles;
+ break;
+
+ default:
+ queryable = beatmaps.ConsumableItems;
+ break;
+ }
+
+ return queryable.AsNoTracking().Where(query);
+ }
+
+ ///
+ /// Perform a lookup query on available s.
+ ///
+ /// The query.
+ /// The first result for the provided query, or null if no results were found.
+ public BeatmapInfo QueryBeatmap(Expression> query) => beatmaps.Beatmaps.AsNoTracking().FirstOrDefault(query);
+
+ ///
+ /// Perform a lookup query on available s.
+ ///
+ /// The query.
+ /// Results from the provided query.
+ public IQueryable QueryBeatmaps(Expression> query) => beatmaps.Beatmaps.AsNoTracking().Where(query);
+
+ public override string HumanisedModelName => "beatmap";
+
+ protected override bool CheckLocalAvailability(BeatmapSetInfo model, IQueryable items)
+ => base.CheckLocalAvailability(model, items)
+ || (model.OnlineBeatmapSetID != null && items.Any(b => b.OnlineBeatmapSetID == model.OnlineBeatmapSetID));
+
+ protected override BeatmapSetInfo CreateModel(ArchiveReader reader)
+ {
+ // let's make sure there are actually .osu files to import.
+ string mapName = reader.Filenames.FirstOrDefault(f => f.EndsWith(".osu", StringComparison.OrdinalIgnoreCase));
+
+ if (string.IsNullOrEmpty(mapName))
+ {
+ Logger.Log($"No beatmap files found in the beatmap archive ({reader.Name}).", LoggingTarget.Database);
+ return null;
+ }
+
+ Beatmap beatmap;
+ using (var stream = new LineBufferedReader(reader.GetStream(mapName)))
+ beatmap = Decoder.GetDecoder(stream).Decode(stream);
+
+ return new BeatmapSetInfo
+ {
+ OnlineBeatmapSetID = beatmap.BeatmapInfo.BeatmapSet?.OnlineBeatmapSetID,
+ Beatmaps = new List(),
+ Metadata = beatmap.Metadata,
+ DateAdded = DateTimeOffset.UtcNow
+ };
+ }
+
+ ///
+ /// Create all required s for the provided archive.
+ ///
+ private List createBeatmapDifficulties(List files)
+ {
+ var beatmapInfos = new List();
+
+ foreach (var file in files.Where(f => f.Filename.EndsWith(".osu", StringComparison.OrdinalIgnoreCase)))
+ {
+ using (var raw = Files.Store.GetStream(file.FileInfo.StoragePath))
+ using (var ms = new MemoryStream()) // we need a memory stream so we can seek
+ using (var sr = new LineBufferedReader(ms))
+ {
+ raw.CopyTo(ms);
+ ms.Position = 0;
+
+ var decoder = Decoder.GetDecoder(sr);
+ IBeatmap beatmap = decoder.Decode(sr);
+
+ string hash = ms.ComputeSHA2Hash();
+
+ if (beatmapInfos.Any(b => b.Hash == hash))
+ continue;
+
+ beatmap.BeatmapInfo.Path = file.Filename;
+ beatmap.BeatmapInfo.Hash = hash;
+ beatmap.BeatmapInfo.MD5Hash = ms.ComputeMD5Hash();
+
+ var ruleset = rulesets.GetRuleset(beatmap.BeatmapInfo.RulesetID);
+ beatmap.BeatmapInfo.Ruleset = ruleset;
+
+ // TODO: this should be done in a better place once we actually need to dynamically update it.
+ beatmap.BeatmapInfo.StarDifficulty = ruleset?.CreateInstance().CreateDifficultyCalculator(new DummyConversionBeatmap(beatmap)).Calculate().StarRating ?? 0;
+ beatmap.BeatmapInfo.Length = calculateLength(beatmap);
+ beatmap.BeatmapInfo.BPM = 60000 / beatmap.GetMostCommonBeatLength();
+
+ beatmapInfos.Add(beatmap.BeatmapInfo);
+ }
+ }
+
+ return beatmapInfos;
+ }
+
+ private double calculateLength(IBeatmap b)
+ {
+ if (!b.HitObjects.Any())
+ return 0;
+
+ var lastObject = b.HitObjects.Last();
+
+ //TODO: this isn't always correct (consider mania where a non-last object may last for longer than the last in the list).
+ double endTime = lastObject.GetEndTime();
+ double startTime = b.HitObjects.First().StartTime;
+
+ return endTime - startTime;
+ }
+
+ ///
+ /// A dummy WorkingBeatmap for the purpose of retrieving a beatmap for star difficulty calculation.
+ ///
+ private class DummyConversionBeatmap : WorkingBeatmap
+ {
+ private readonly IBeatmap beatmap;
+
+ public DummyConversionBeatmap(IBeatmap beatmap)
+ : base(beatmap.BeatmapInfo, null)
+ {
+ this.beatmap = beatmap;
+ }
+
+ protected override IBeatmap GetBeatmap() => beatmap;
+ protected override Texture GetBackground() => null;
+ protected override Track GetBeatmapTrack() => null;
+ protected internal override ISkin GetSkin() => null;
+ public override Stream GetStream(string storagePath) => null;
+ }
+ }
+
+ ///
+ /// The level of detail to include in database results.
+ ///
+ public enum IncludedDetails
+ {
+ ///
+ /// Only include beatmap difficulties and set level metadata.
+ ///
+ Minimal,
+
+ ///
+ /// Include all difficulties, rulesets, difficulty metadata but no files.
+ ///
+ AllButFiles,
+
+ ///
+ /// Include everything except ruleset. Used for cases where we aren't sure the ruleset is present but still want to consume the beatmap.
+ ///
+ AllButRuleset,
+
+ ///
+ /// Include everything.
+ ///
+ All
+ }
+}
diff --git a/osu.Game/Beatmaps/BeatmapOnlineLookupQueue.cs b/osu.Game/Beatmaps/BeatmapOnlineLookupQueue.cs
new file mode 100644
index 0000000000..55164e2442
--- /dev/null
+++ b/osu.Game/Beatmaps/BeatmapOnlineLookupQueue.cs
@@ -0,0 +1,222 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Data.Sqlite;
+using osu.Framework.Development;
+using osu.Framework.IO.Network;
+using osu.Framework.Logging;
+using osu.Framework.Platform;
+using osu.Framework.Testing;
+using osu.Framework.Threading;
+using osu.Game.Database;
+using osu.Game.Online.API;
+using osu.Game.Online.API.Requests;
+using SharpCompress.Compressors;
+using SharpCompress.Compressors.BZip2;
+
+namespace osu.Game.Beatmaps
+{
+ ///
+ /// A component which handles population of online IDs for beatmaps using a two part lookup procedure.
+ ///
+ ///
+ /// On creating the component, a copy of a database containing metadata for a large subset of beatmaps (stored to ) will be downloaded if not already present locally.
+ /// This will always be checked before doing a second online query to get required metadata.
+ ///
+ [ExcludeFromDynamicCompile]
+ public class BeatmapOnlineLookupQueue : IDisposable
+ {
+ private readonly IAPIProvider api;
+ private readonly Storage storage;
+
+ private const int update_queue_request_concurrency = 4;
+
+ private readonly ThreadedTaskScheduler updateScheduler = new ThreadedTaskScheduler(update_queue_request_concurrency, nameof(BeatmapOnlineLookupQueue));
+
+ private FileWebRequest cacheDownloadRequest;
+
+ private const string cache_database_name = "online.db";
+
+ public BeatmapOnlineLookupQueue(IAPIProvider api, Storage storage)
+ {
+ this.api = api;
+ this.storage = storage;
+
+ // avoid downloading / using cache for unit tests.
+ if (!DebugUtils.IsNUnitRunning && !storage.Exists(cache_database_name))
+ prepareLocalCache();
+ }
+
+ public Task UpdateAsync(BeatmapSetInfo beatmapSet, CancellationToken cancellationToken)
+ {
+ return Task.WhenAll(beatmapSet.Beatmaps.Select(b => UpdateAsync(beatmapSet, b, cancellationToken)).ToArray());
+ }
+
+ // todo: expose this when we need to do individual difficulty lookups.
+ protected Task UpdateAsync(BeatmapSetInfo beatmapSet, BeatmapInfo beatmap, CancellationToken cancellationToken)
+ => Task.Factory.StartNew(() => lookup(beatmapSet, beatmap), cancellationToken, TaskCreationOptions.HideScheduler | TaskCreationOptions.RunContinuationsAsynchronously, updateScheduler);
+
+ private void lookup(BeatmapSetInfo set, BeatmapInfo beatmap)
+ {
+ if (checkLocalCache(set, beatmap))
+ return;
+
+ if (api?.State.Value != APIState.Online)
+ return;
+
+ var req = new GetBeatmapRequest(beatmap);
+
+ req.Failure += fail;
+
+ try
+ {
+ // intentionally blocking to limit web request concurrency
+ api.Perform(req);
+
+ var res = req.Result;
+
+ if (res != null)
+ {
+ beatmap.Status = res.Status;
+ beatmap.BeatmapSet.Status = res.BeatmapSet.Status;
+ beatmap.BeatmapSet.OnlineBeatmapSetID = res.OnlineBeatmapSetID;
+ beatmap.OnlineBeatmapID = res.OnlineBeatmapID;
+
+ if (beatmap.Metadata != null)
+ beatmap.Metadata.AuthorID = res.AuthorID;
+
+ if (beatmap.BeatmapSet.Metadata != null)
+ beatmap.BeatmapSet.Metadata.AuthorID = res.AuthorID;
+
+ logForModel(set, $"Online retrieval mapped {beatmap} to {res.OnlineBeatmapSetID} / {res.OnlineBeatmapID}.");
+ }
+ }
+ catch (Exception e)
+ {
+ fail(e);
+ }
+
+ void fail(Exception e)
+ {
+ beatmap.OnlineBeatmapID = null;
+ logForModel(set, $"Online retrieval failed for {beatmap} ({e.Message})");
+ }
+ }
+
+ private void prepareLocalCache()
+ {
+ string cacheFilePath = storage.GetFullPath(cache_database_name);
+ string compressedCacheFilePath = $"{cacheFilePath}.bz2";
+
+ cacheDownloadRequest = new FileWebRequest(compressedCacheFilePath, $"https://assets.ppy.sh/client-resources/{cache_database_name}.bz2?{DateTimeOffset.UtcNow:yyyyMMdd}");
+
+ cacheDownloadRequest.Failed += ex =>
+ {
+ File.Delete(compressedCacheFilePath);
+ File.Delete(cacheFilePath);
+
+ Logger.Log($"{nameof(BeatmapOnlineLookupQueue)}'s online cache download failed: {ex}", LoggingTarget.Database);
+ };
+
+ cacheDownloadRequest.Finished += () =>
+ {
+ try
+ {
+ using (var stream = File.OpenRead(cacheDownloadRequest.Filename))
+ using (var outStream = File.OpenWrite(cacheFilePath))
+ using (var bz2 = new BZip2Stream(stream, CompressionMode.Decompress, false))
+ bz2.CopyTo(outStream);
+
+ // set to null on completion to allow lookups to begin using the new source
+ cacheDownloadRequest = null;
+ }
+ catch (Exception ex)
+ {
+ Logger.Log($"{nameof(BeatmapOnlineLookupQueue)}'s online cache extraction failed: {ex}", LoggingTarget.Database);
+ File.Delete(cacheFilePath);
+ }
+ finally
+ {
+ File.Delete(compressedCacheFilePath);
+ }
+ };
+
+ cacheDownloadRequest.PerformAsync();
+ }
+
+ private bool checkLocalCache(BeatmapSetInfo set, BeatmapInfo beatmap)
+ {
+ // download is in progress (or was, and failed).
+ if (cacheDownloadRequest != null)
+ return false;
+
+ // database is unavailable.
+ if (!storage.Exists(cache_database_name))
+ return false;
+
+ if (string.IsNullOrEmpty(beatmap.MD5Hash)
+ && string.IsNullOrEmpty(beatmap.Path)
+ && beatmap.OnlineBeatmapID == null)
+ return false;
+
+ try
+ {
+ using (var db = new SqliteConnection(DatabaseContextFactory.CreateDatabaseConnectionString("online.db", storage)))
+ {
+ db.Open();
+
+ using (var cmd = db.CreateCommand())
+ {
+ cmd.CommandText = "SELECT beatmapset_id, beatmap_id, approved, user_id FROM osu_beatmaps WHERE checksum = @MD5Hash OR beatmap_id = @OnlineBeatmapID OR filename = @Path";
+
+ cmd.Parameters.Add(new SqliteParameter("@MD5Hash", beatmap.MD5Hash));
+ cmd.Parameters.Add(new SqliteParameter("@OnlineBeatmapID", beatmap.OnlineBeatmapID ?? (object)DBNull.Value));
+ cmd.Parameters.Add(new SqliteParameter("@Path", beatmap.Path));
+
+ using (var reader = cmd.ExecuteReader())
+ {
+ if (reader.Read())
+ {
+ var status = (BeatmapSetOnlineStatus)reader.GetByte(2);
+
+ beatmap.Status = status;
+ beatmap.BeatmapSet.Status = status;
+ beatmap.BeatmapSet.OnlineBeatmapSetID = reader.GetInt32(0);
+ beatmap.OnlineBeatmapID = reader.GetInt32(1);
+
+ if (beatmap.Metadata != null)
+ beatmap.Metadata.AuthorID = reader.GetInt32(3);
+
+ if (beatmap.BeatmapSet.Metadata != null)
+ beatmap.BeatmapSet.Metadata.AuthorID = reader.GetInt32(3);
+
+ logForModel(set, $"Cached local retrieval for {beatmap}.");
+ return true;
+ }
+ }
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ logForModel(set, $"Cached local retrieval for {beatmap} failed with {ex}.");
+ }
+
+ return false;
+ }
+
+ private void logForModel(BeatmapSetInfo set, string message) =>
+ ArchiveModelManager.LogForModel(set, $"[{nameof(BeatmapOnlineLookupQueue)}] {message}");
+
+ public void Dispose()
+ {
+ cacheDownloadRequest?.Dispose();
+ updateScheduler?.Dispose();
+ }
+ }
+}
diff --git a/osu.Game/Beatmaps/IWorkingBeatmapCache.cs b/osu.Game/Beatmaps/IWorkingBeatmapCache.cs
new file mode 100644
index 0000000000..881e734292
--- /dev/null
+++ b/osu.Game/Beatmaps/IWorkingBeatmapCache.cs
@@ -0,0 +1,15 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+namespace osu.Game.Beatmaps
+{
+ public interface IWorkingBeatmapCache
+ {
+ ///
+ /// Retrieve a instance for the provided
+ ///
+ /// The beatmap to lookup.
+ /// A instance correlating to the provided .
+ WorkingBeatmap GetWorkingBeatmap(BeatmapInfo beatmapInfo);
+ }
+}
diff --git a/osu.Game/Beatmaps/BeatmapManager_WorkingBeatmap.cs b/osu.Game/Beatmaps/WorkingBeatmapCache.cs
similarity index 55%
rename from osu.Game/Beatmaps/BeatmapManager_WorkingBeatmap.cs
rename to osu.Game/Beatmaps/WorkingBeatmapCache.cs
index 45112ae74c..e117f1b82f 100644
--- a/osu.Game/Beatmaps/BeatmapManager_WorkingBeatmap.cs
+++ b/osu.Game/Beatmaps/WorkingBeatmapCache.cs
@@ -1,12 +1,18 @@
-// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
-using System.Diagnostics.CodeAnalysis;
using System.IO;
+using System.Linq;
+using JetBrains.Annotations;
+using osu.Framework.Audio;
using osu.Framework.Audio.Track;
using osu.Framework.Graphics.Textures;
+using osu.Framework.IO.Stores;
+using osu.Framework.Lists;
using osu.Framework.Logging;
+using osu.Framework.Platform;
+using osu.Framework.Statistics;
using osu.Framework.Testing;
using osu.Game.Beatmaps.Formats;
using osu.Game.IO;
@@ -15,8 +21,96 @@ using osu.Game.Storyboards;
namespace osu.Game.Beatmaps
{
- public partial class BeatmapManager
+ public class WorkingBeatmapCache : IBeatmapResourceProvider, IWorkingBeatmapCache
{
+ private readonly WeakList workingCache = new WeakList();
+
+ ///
+ /// A default representation of a WorkingBeatmap to use when no beatmap is available.
+ ///
+ public readonly WorkingBeatmap DefaultBeatmap;
+
+ public BeatmapModelManager BeatmapManager { private get; set; }
+
+ private readonly AudioManager audioManager;
+ private readonly IResourceStore resources;
+ private readonly LargeTextureStore largeTextureStore;
+ private readonly ITrackStore trackStore;
+ private readonly IResourceStore files;
+
+ [CanBeNull]
+ private readonly GameHost host;
+
+ public WorkingBeatmapCache([NotNull] AudioManager audioManager, IResourceStore resources, IResourceStore files, WorkingBeatmap defaultBeatmap = null, GameHost host = null)
+ {
+ DefaultBeatmap = defaultBeatmap;
+
+ this.audioManager = audioManager;
+ this.resources = resources;
+ this.host = host;
+ this.files = files;
+ largeTextureStore = new LargeTextureStore(host?.CreateTextureLoaderStore(files));
+ trackStore = audioManager.GetTrackStore(files);
+ }
+
+ public void Invalidate(BeatmapSetInfo info)
+ {
+ if (info.Beatmaps == null) return;
+
+ foreach (var b in info.Beatmaps)
+ Invalidate(b);
+ }
+
+ public void Invalidate(BeatmapInfo info)
+ {
+ lock (workingCache)
+ {
+ var working = workingCache.FirstOrDefault(w => w.BeatmapInfo?.ID == info.ID);
+ if (working != null)
+ workingCache.Remove(working);
+ }
+ }
+
+ public virtual WorkingBeatmap GetWorkingBeatmap(BeatmapInfo beatmapInfo)
+ {
+ // if there are no files, presume the full beatmap info has not yet been fetched from the database.
+ if (beatmapInfo?.BeatmapSet?.Files.Count == 0)
+ {
+ int lookupId = beatmapInfo.ID;
+ beatmapInfo = BeatmapManager.QueryBeatmap(b => b.ID == lookupId);
+ }
+
+ if (beatmapInfo?.BeatmapSet == null)
+ return DefaultBeatmap;
+
+ lock (workingCache)
+ {
+ var working = workingCache.FirstOrDefault(w => w.BeatmapInfo?.ID == beatmapInfo.ID);
+ if (working != null)
+ return working;
+
+ beatmapInfo.Metadata ??= beatmapInfo.BeatmapSet.Metadata;
+
+ workingCache.Add(working = new BeatmapManagerWorkingBeatmap(beatmapInfo, this));
+
+ // best effort; may be higher than expected.
+ GlobalStatistics.Get(nameof(Beatmaps), $"Cached {nameof(WorkingBeatmap)}s").Value = workingCache.Count();
+
+ return working;
+ }
+ }
+
+ #region IResourceStorageProvider
+
+ TextureStore IBeatmapResourceProvider.LargeTextureStore => largeTextureStore;
+ ITrackStore IBeatmapResourceProvider.Tracks => trackStore;
+ AudioManager IStorageResourceProvider.AudioManager => audioManager;
+ IResourceStore IStorageResourceProvider.Files => files;
+ IResourceStore IStorageResourceProvider.Resources => resources;
+ IResourceStore IStorageResourceProvider.CreateTextureLoaderStore(IResourceStore underlyingStore) => host?.CreateTextureLoaderStore(underlyingStore);
+
+ #endregion
+
[ExcludeFromDynamicCompile]
private class BeatmapManagerWorkingBeatmap : WorkingBeatmap
{
diff --git a/osu.Game/Collections/CollectionManager.cs b/osu.Game/Collections/CollectionManager.cs
index fe04c70d62..6f9d9cd8a8 100644
--- a/osu.Game/Collections/CollectionManager.cs
+++ b/osu.Game/Collections/CollectionManager.cs
@@ -14,6 +14,7 @@ using osu.Framework.Graphics;
using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Game.Beatmaps;
+using osu.Game.Database;
using osu.Game.IO;
using osu.Game.IO.Legacy;
using osu.Game.Overlays.Notifications;
@@ -27,7 +28,7 @@ namespace osu.Game.Collections
/// This is currently reading and writing from the osu-stable file format. This is a temporary arrangement until we refactor the
/// database backing the game. Going forward writing should be done in a similar way to other model stores.
///
- public class CollectionManager : Component
+ public class CollectionManager : Component, IPostNotifications
{
///
/// Database version in stable-compatible YYYYMMDD format.
@@ -106,9 +107,6 @@ namespace osu.Game.Collections
backgroundSave();
});
- ///
- /// Set an endpoint for notifications to be posted to.
- ///
public Action PostNotification { protected get; set; }
///
diff --git a/osu.Game/Database/ArchiveModelManager.cs b/osu.Game/Database/ArchiveModelManager.cs
index ddd2bc5d1e..0c309bbddb 100644
--- a/osu.Game/Database/ArchiveModelManager.cs
+++ b/osu.Game/Database/ArchiveModelManager.cs
@@ -30,7 +30,7 @@ namespace osu.Game.Database
///
/// The model type.
/// The associated file join type.
- public abstract class ArchiveModelManager : ICanAcceptFiles, IModelManager
+ public abstract class ArchiveModelManager : ICanAcceptFiles, IModelManager, IModelFileManager, IPresentImports
where TModel : class, IHasFiles, IHasPrimaryKey, ISoftDelete
where TFileModel : class, INamedFileInfo, new()
{
@@ -57,9 +57,6 @@ namespace osu.Game.Database
///
private static readonly ThreadedTaskScheduler import_scheduler_low_priority = new ThreadedTaskScheduler(import_queue_request_concurrency, nameof(ArchiveModelManager));
- ///
- /// Set an endpoint for notifications to be posted to.
- ///
public Action PostNotification { protected get; set; }
///
@@ -135,7 +132,7 @@ namespace osu.Game.Database
return Import(notification, tasks);
}
- protected async Task> Import(ProgressNotification notification, params ImportTask[] tasks)
+ public async Task> Import(ProgressNotification notification, params ImportTask[] tasks)
{
if (tasks.Length == 0)
{
@@ -227,7 +224,7 @@ namespace osu.Game.Database
/// Whether this is a low priority import.
/// An optional cancellation token.
/// The imported model, if successful.
- internal async Task Import(ImportTask task, bool lowPriority = false, CancellationToken cancellationToken = default)
+ public async Task Import(ImportTask task, bool lowPriority = false, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
@@ -252,10 +249,7 @@ namespace osu.Game.Database
return import;
}
- ///
- /// Fired when the user requests to view the resulting import.
- ///
- public Action> PresentImport;
+ public Action> PresentImport { protected get; set; }
///
/// Silently import an item from an .
@@ -479,7 +473,7 @@ namespace osu.Game.Database
///
/// The item to export.
/// The output stream to export to.
- protected virtual void ExportModelTo(TModel model, Stream outputStream)
+ public virtual void ExportModelTo(TModel model, Stream outputStream)
{
using (var archive = ZipArchive.Create())
{
@@ -745,9 +739,6 @@ namespace osu.Game.Database
/// Whether to perform deletion.
protected virtual bool ShouldDeleteArchive(string path) => false;
- ///
- /// This is a temporary method and will likely be replaced by a full-fledged (and more correctly placed) migration process in the future.
- ///
public Task ImportFromStableAsync(StableStorage stableStorage)
{
var storage = PrepareStableStorage(stableStorage);
@@ -805,6 +796,17 @@ namespace osu.Game.Database
/// An existing model which matches the criteria to skip importing, else null.
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));
+
+ ///
+ /// Performs implementation specific comparisons to determine whether a given model is present in the local store.
+ ///
+ /// The whose existence needs to be checked.
+ /// The usable items present in the store.
+ /// Whether the exists.
+ protected virtual bool CheckLocalAvailability(TModel model, IQueryable items)
+ => model.ID > 0 && items.Any(i => i.ID == model.ID && i.Files.Any());
+
///
/// Whether import can be skipped after finding an existing import early in the process.
/// Only valid when is not overridden.
@@ -841,7 +843,7 @@ namespace osu.Game.Database
private DbSet queryModel() => ContextFactory.Get().Set();
- protected virtual string HumanisedModelName => $"{typeof(TModel).Name.Replace(@"Info", "").ToLower()}";
+ public virtual string HumanisedModelName => $"{typeof(TModel).Name.Replace(@"Info", "").ToLower()}";
#region Event handling / delaying
diff --git a/osu.Game/Database/DatabaseContextFactory.cs b/osu.Game/Database/DatabaseContextFactory.cs
index d402195f29..94fa967d72 100644
--- a/osu.Game/Database/DatabaseContextFactory.cs
+++ b/osu.Game/Database/DatabaseContextFactory.cs
@@ -13,7 +13,7 @@ namespace osu.Game.Database
{
private readonly Storage storage;
- private const string database_name = @"client";
+ private const string database_name = @"client.db";
private ThreadLocal threadContexts;
@@ -139,7 +139,7 @@ namespace osu.Game.Database
threadContexts = new ThreadLocal(CreateContext, true);
}
- protected virtual OsuDbContext CreateContext() => new OsuDbContext(storage.GetDatabaseConnectionString(database_name))
+ protected virtual OsuDbContext CreateContext() => new OsuDbContext(CreateDatabaseConnectionString(database_name, storage))
{
Database = { AutoTransactionsEnabled = false }
};
@@ -152,7 +152,7 @@ namespace osu.Game.Database
try
{
- storage.DeleteDatabase(database_name);
+ storage.Delete(database_name);
}
catch
{
@@ -171,5 +171,7 @@ namespace osu.Game.Database
recycleThreadContexts();
}
+
+ public static string CreateDatabaseConnectionString(string filename, Storage storage) => string.Concat("Data Source=", storage.GetFullPath($@"{filename}", true));
}
}
diff --git a/osu.Game/Database/IModelDownloader.cs b/osu.Game/Database/IModelDownloader.cs
index 0cb633280e..a5573b2190 100644
--- a/osu.Game/Database/IModelDownloader.cs
+++ b/osu.Game/Database/IModelDownloader.cs
@@ -11,7 +11,7 @@ namespace osu.Game.Database
/// Represents a that can download new models from an external source.
///
/// The model type.
- public interface IModelDownloader : IModelManager
+ public interface IModelDownloader : IPostNotifications
where TModel : class
{
///
@@ -26,13 +26,6 @@ namespace osu.Game.Database
///
IBindable>> DownloadFailed { get; }
- ///
- /// Checks whether a given is already available in the local store.
- ///
- /// The whose existence needs to be checked.
- /// Whether the exists.
- bool IsAvailableLocally(TModel model);
-
///
/// Begin a download for the requested .
///
diff --git a/osu.Game/Database/IModelFileManager.cs b/osu.Game/Database/IModelFileManager.cs
new file mode 100644
index 0000000000..c74b945eb7
--- /dev/null
+++ b/osu.Game/Database/IModelFileManager.cs
@@ -0,0 +1,36 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.IO;
+
+namespace osu.Game.Database
+{
+ public interface IModelFileManager
+ where TModel : class
+ where TFileModel : class
+ {
+ ///
+ /// Replace an existing file with a new version.
+ ///
+ /// The item to operate on.
+ /// The existing file to be replaced.
+ /// The new file contents.
+ /// An optional filename for the new file. Will use the previous filename if not specified.
+ void ReplaceFile(TModel model, TFileModel file, Stream contents, string filename = null);
+
+ ///
+ /// Delete an existing file.
+ ///
+ /// The item to operate on.
+ /// The existing file to be deleted.
+ void DeleteFile(TModel model, TFileModel file);
+
+ ///
+ /// Add a new file.
+ ///
+ /// The item to operate on.
+ /// The new file contents.
+ /// The filename for the new file.
+ void AddFile(TModel model, Stream contents, string filename);
+ }
+}
diff --git a/osu.Game/Database/IModelManager.cs b/osu.Game/Database/IModelManager.cs
index 8c314f1617..7bfc8dbee3 100644
--- a/osu.Game/Database/IModelManager.cs
+++ b/osu.Game/Database/IModelManager.cs
@@ -1,8 +1,15 @@
-// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
using osu.Framework.Bindables;
+using osu.Game.IO;
+using osu.Game.IO.Archives;
+using osu.Game.Overlays.Notifications;
namespace osu.Game.Database
{
@@ -10,7 +17,7 @@ namespace osu.Game.Database
/// Represents a model manager that publishes events when s are added or removed.
///
/// The model type.
- public interface IModelManager
+ public interface IModelManager : IPostNotifications
where TModel : class
{
///
@@ -24,5 +31,109 @@ namespace osu.Game.Database
/// This is not thread-safe and should be scheduled locally if consumed from a drawable component.
///
IBindable> ItemRemoved { get; }
+
+ ///
+ /// This is a temporary method and will likely be replaced by a full-fledged (and more correctly placed) migration process in the future.
+ ///
+ Task ImportFromStableAsync(StableStorage stableStorage);
+
+ ///
+ /// Exports an item to a legacy (.zip based) package.
+ ///
+ /// The item to export.
+ void Export(TModel item);
+
+ ///
+ /// Exports an item to the given output stream.
+ ///
+ /// The item to export.
+ /// The output stream to export to.
+ void ExportModelTo(TModel model, Stream outputStream);
+
+ ///
+ /// Perform an update of the specified item.
+ /// TODO: Support file additions/removals.
+ ///
+ /// The item to update.
+ void Update(TModel item);
+
+ ///
+ /// Delete an item from the manager.
+ /// Is a no-op for already deleted items.
+ ///
+ /// The item to delete.
+ /// false if no operation was performed
+ bool Delete(TModel item);
+
+ ///
+ /// Delete multiple items.
+ /// This will post notifications tracking progress.
+ ///
+ void Delete(List items, bool silent = false);
+
+ ///
+ /// Restore multiple items that were previously deleted.
+ /// This will post notifications tracking progress.
+ ///
+ void Undelete(List items, bool silent = false);
+
+ ///
+ /// 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.
+ ///
+ /// The item to restore
+ void Undelete(TModel item);
+
+ ///
+ /// Import one or more items from filesystem .
+ ///
+ ///
+ /// This will be treated as a low priority import if more than one path is specified; use to always import at standard priority.
+ /// This will post notifications tracking progress.
+ ///
+ /// One or more archive locations on disk.
+ Task Import(params string[] paths);
+
+ Task Import(params ImportTask[] tasks);
+
+ Task> Import(ProgressNotification notification, params ImportTask[] tasks);
+
+ ///
+ /// Import one 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.
+ ///
+ /// The containing data about the to import.
+ /// Whether this is a low priority import.
+ /// An optional cancellation token.
+ /// The imported model, if successful.
+ Task Import(ImportTask task, bool lowPriority = false, CancellationToken cancellationToken = default);
+
+ ///
+ /// Silently import an item from an .
+ ///
+ /// The archive to be imported.
+ /// Whether this is a low priority import.
+ /// An optional cancellation token.
+ Task Import(ArchiveReader archive, bool lowPriority = false, CancellationToken cancellationToken = default);
+
+ ///
+ /// Silently import an item from a .
+ ///
+ /// The model to be imported.
+ /// An optional archive to use for model population.
+ /// Whether this is a low priority import.
+ /// An optional cancellation token.
+ Task Import(TModel item, ArchiveReader archive = null, bool lowPriority = false, CancellationToken cancellationToken = default);
+
+ ///
+ /// Checks whether a given is already available in the local store.
+ ///
+ /// The whose existence needs to be checked.
+ /// Whether the exists.
+ bool IsAvailableLocally(TModel model);
+
+ ///
+ /// A user displayable name for the model type associated with this manager.
+ ///
+ string HumanisedModelName => $"{typeof(TModel).Name.Replace(@"Info", "").ToLower()}";
}
}
diff --git a/osu.Game/Database/IPostNotifications.cs b/osu.Game/Database/IPostNotifications.cs
new file mode 100644
index 0000000000..d4fd64e79e
--- /dev/null
+++ b/osu.Game/Database/IPostNotifications.cs
@@ -0,0 +1,16 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+using osu.Game.Overlays.Notifications;
+
+namespace osu.Game.Database
+{
+ public interface IPostNotifications
+ {
+ ///
+ /// And action which will be fired when a notification should be presented to the user.
+ ///
+ public Action PostNotification { set; }
+ }
+}
diff --git a/osu.Game/Database/IPresentImports.cs b/osu.Game/Database/IPresentImports.cs
new file mode 100644
index 0000000000..39b495ebd5
--- /dev/null
+++ b/osu.Game/Database/IPresentImports.cs
@@ -0,0 +1,17 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+using System.Collections.Generic;
+
+namespace osu.Game.Database
+{
+ public interface IPresentImports
+ where TModel : class
+ {
+ ///
+ /// Fired when the user requests to view the resulting import.
+ ///
+ public Action> PresentImport { set; }
+ }
+}
diff --git a/osu.Game/Database/IRealmFactory.cs b/osu.Game/Database/IRealmFactory.cs
index 0e93e5bf4f..a957424584 100644
--- a/osu.Game/Database/IRealmFactory.cs
+++ b/osu.Game/Database/IRealmFactory.cs
@@ -9,20 +9,12 @@ namespace osu.Game.Database
{
///
/// The main realm context, bound to the update thread.
- /// If querying from a non-update thread is needed, use or to receive a context instead.
///
Realm Context { get; }
///
- /// Get a fresh context for read usage.
+ /// Create a new realm context for use on the current thread.
///
- RealmContextFactory.RealmUsage GetForRead();
-
- ///
- /// Request a context for write usage.
- /// This method may block if a write is already active on a different thread.
- ///
- /// A usage containing a usable context.
- RealmContextFactory.RealmWriteUsage GetForWrite();
+ Realm CreateContext();
}
}
diff --git a/osu.Game/Database/DownloadableArchiveModelManager.cs b/osu.Game/Database/ModelDownloader.cs
similarity index 64%
rename from osu.Game/Database/DownloadableArchiveModelManager.cs
rename to osu.Game/Database/ModelDownloader.cs
index da3144e8d0..e613b39b6b 100644
--- a/osu.Game/Database/DownloadableArchiveModelManager.cs
+++ b/osu.Game/Database/ModelDownloader.cs
@@ -1,29 +1,24 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
-using Humanizer;
-using osu.Framework.Logging;
-using osu.Framework.Platform;
-using osu.Game.Online.API;
-using osu.Game.Overlays.Notifications;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
+using Humanizer;
using osu.Framework.Bindables;
+using osu.Framework.Logging;
+using osu.Framework.Platform;
+using osu.Game.Online.API;
+using osu.Game.Overlays.Notifications;
namespace osu.Game.Database
{
- ///
- /// An that has the ability to download models using an and
- /// import them into the store.
- ///
- /// The model type.
- /// The associated file join type.
- public abstract class DownloadableArchiveModelManager : ArchiveModelManager, IModelDownloader
- where TModel : class, IHasFiles, IHasPrimaryKey, ISoftDelete, IEquatable
- where TFileModel : class, INamedFileInfo, new()
+ public abstract class ModelDownloader : IModelDownloader
+ where TModel : class, IHasPrimaryKey, ISoftDelete, IEquatable
{
+ public Action PostNotification { protected get; set; }
+
public IBindable>> DownloadBegan => downloadBegan;
private readonly Bindable>> downloadBegan = new Bindable>>();
@@ -32,18 +27,15 @@ namespace osu.Game.Database
private readonly Bindable>> downloadFailed = new Bindable>>();
+ private readonly IModelManager modelManager;
private readonly IAPIProvider api;
private readonly List> currentDownloads = new List>();
- private readonly MutableDatabaseBackedStoreWithFileIncludes modelStore;
-
- protected DownloadableArchiveModelManager(Storage storage, IDatabaseContextFactory contextFactory, IAPIProvider api, MutableDatabaseBackedStoreWithFileIncludes modelStore,
- IIpcHost importHost = null)
- : base(storage, contextFactory, modelStore, importHost)
+ protected ModelDownloader(IModelManager modelManager, IAPIProvider api, IIpcHost importHost = null)
{
+ this.modelManager = modelManager;
this.api = api;
- this.modelStore = modelStore;
}
///
@@ -54,12 +46,6 @@ namespace osu.Game.Database
/// The request object.
protected abstract ArchiveDownloadRequest CreateDownloadRequest(TModel model, bool minimiseDownloadSize);
- ///
- /// Begin a download for the requested .
- ///
- /// The to be downloaded.
- /// Whether this download should be optimised for slow connections. Generally means extras are not included in the download bundle.
- /// Whether the download was started.
public bool Download(TModel model, bool minimiseDownloadSize = false)
{
if (!canDownload(model)) return false;
@@ -82,7 +68,7 @@ namespace osu.Game.Database
Task.Factory.StartNew(async () =>
{
// This gets scheduled back to the update thread, but we want the import to run in the background.
- var imported = await Import(notification, new ImportTask(filename)).ConfigureAwait(false);
+ var imported = await modelManager.Import(notification, new ImportTask(filename)).ConfigureAwait(false);
// for now a failed import will be marked as a failed download for simplicity.
if (!imported.Any())
@@ -117,21 +103,10 @@ namespace osu.Game.Database
notification.State = ProgressNotificationState.Cancelled;
if (!(error is OperationCanceledException))
- Logger.Error(error, $"{HumanisedModelName.Titleize()} download failed!");
+ Logger.Error(error, $"{modelManager.HumanisedModelName.Titleize()} download failed!");
}
}
- public bool IsAvailableLocally(TModel model) => CheckLocalAvailability(model, modelStore.ConsumableItems.Where(m => !m.DeletePending));
-
- ///
- /// Performs implementation specific comparisons to determine whether a given model is present in the local store.
- ///
- /// The whose existence needs to be checked.
- /// The usable items present in the store.
- /// Whether the exists.
- protected virtual bool CheckLocalAvailability(TModel model, IQueryable items)
- => model.ID > 0 && items.Any(i => i.ID == model.ID && i.Files.Any());
-
public ArchiveDownloadRequest GetExistingDownload(TModel model) => currentDownloads.Find(r => r.Model.Equals(model));
private bool canDownload(TModel model) => GetExistingDownload(model) == null && api != null;
diff --git a/osu.Game/Database/RealmContextFactory.cs b/osu.Game/Database/RealmContextFactory.cs
index ed3dc01f15..bf7feebdbf 100644
--- a/osu.Game/Database/RealmContextFactory.cs
+++ b/osu.Game/Database/RealmContextFactory.cs
@@ -2,7 +2,6 @@
// See the LICENCE file in the repository root for full licence text.
using System;
-using System.Diagnostics;
using System.Threading;
using osu.Framework.Allocation;
using osu.Framework.Development;
@@ -10,80 +9,117 @@ using osu.Framework.Graphics;
using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Framework.Statistics;
-using osu.Game.Input.Bindings;
using Realms;
+#nullable enable
+
namespace osu.Game.Database
{
+ ///
+ /// A factory which provides both the main (update thread bound) realm context and creates contexts for async usage.
+ ///
public class RealmContextFactory : Component, IRealmFactory
{
private readonly Storage storage;
- private const string database_name = @"client";
+ ///
+ /// The filename of this realm.
+ ///
+ public readonly string Filename;
private const int schema_version = 6;
///
- /// Lock object which is held for the duration of a write operation (via ).
+ /// Lock object which is held during sections, blocking context creation during blocking periods.
///
- private readonly object writeLock = new object();
+ private readonly SemaphoreSlim contextCreationLock = new SemaphoreSlim(1);
- ///
- /// Lock object which is held during sections.
- ///
- private readonly SemaphoreSlim blockingLock = new SemaphoreSlim(1);
-
- private static readonly GlobalStatistic reads = GlobalStatistics.Get("Realm", "Get (Read)");
- private static readonly GlobalStatistic writes = GlobalStatistics.Get("Realm", "Get (Write)");
private static readonly GlobalStatistic refreshes = GlobalStatistics.Get