From e7e04733234cf2de40abf9241548c786f64d8ed6 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Thu, 30 Sep 2021 15:40:41 +0900
Subject: [PATCH 1/7] Split out `WorkingBeatmapCache` from `BeatmapManager`

---
 osu.Game/Beatmaps/BeatmapManager.cs           | 134 +--------
 .../Beatmaps/BeatmapManager_WorkingBeatmap.cs | 147 ---------
 osu.Game/Beatmaps/WorkingBeatmapCache.cs      | 279 ++++++++++++++++++
 3 files changed, 289 insertions(+), 271 deletions(-)
 delete mode 100644 osu.Game/Beatmaps/BeatmapManager_WorkingBeatmap.cs
 create mode 100644 osu.Game/Beatmaps/WorkingBeatmapCache.cs

diff --git a/osu.Game/Beatmaps/BeatmapManager.cs b/osu.Game/Beatmaps/BeatmapManager.cs
index a2f9740779..1c0e7dc319 100644
--- a/osu.Game/Beatmaps/BeatmapManager.cs
+++ b/osu.Game/Beatmaps/BeatmapManager.cs
@@ -9,18 +9,13 @@ 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;
@@ -31,7 +26,6 @@ using osu.Game.Online.API.Requests;
 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
@@ -40,7 +34,7 @@ namespace osu.Game.Beatmaps
     /// Handles the storage and retrieval of Beatmaps/WorkingBeatmaps.
     /// </summary>
     [ExcludeFromDynamicCompile]
-    public partial class BeatmapManager : DownloadableArchiveModelManager<BeatmapSetInfo, BeatmapSetFileInfo>, IBeatmapResourceProvider
+    public class BeatmapManager : DownloadableArchiveModelManager<BeatmapSetInfo, BeatmapSetFileInfo>
     {
         /// <summary>
         /// Fired when a single difficulty has been hidden.
@@ -60,12 +54,12 @@ namespace osu.Game.Beatmaps
         /// </summary>
         public Func<BeatmapSetInfo, CancellationToken, Task> PopulateOnlineInformation;
 
-        private readonly Bindable<WeakReference<BeatmapInfo>> beatmapRestored = new Bindable<WeakReference<BeatmapInfo>>();
-
         /// <summary>
-        /// A default representation of a WorkingBeatmap to use when no beatmap is available.
+        /// The game working beatmap cache, used to invalidate entries on changes.
         /// </summary>
-        public readonly WorkingBeatmap DefaultBeatmap;
+        public WorkingBeatmapCache WorkingBeatmapCache { private get; set; }
+
+        private readonly Bindable<WeakReference<BeatmapInfo>> beatmapRestored = new Bindable<WeakReference<BeatmapInfo>>();
 
         public override IEnumerable<string> HandledExtensions => new[] { ".osz" };
 
@@ -75,35 +69,19 @@ namespace osu.Game.Beatmaps
 
         protected override Storage PrepareStableStorage(StableStorage stableStorage) => stableStorage.GetSongStorage();
 
-        private readonly RulesetStore rulesets;
         private readonly BeatmapStore beatmaps;
-        private readonly AudioManager audioManager;
-        private readonly IResourceStore<byte[]> resources;
-        private readonly LargeTextureStore largeTextureStore;
-        private readonly ITrackStore trackStore;
+        private readonly RulesetStore rulesets;
 
-        [CanBeNull]
-        private readonly GameHost host;
-
-        public BeatmapManager(Storage storage, IDatabaseContextFactory contextFactory, RulesetStore rulesets, IAPIProvider api, [NotNull] AudioManager audioManager, IResourceStore<byte[]> resources, GameHost host = null,
-                              WorkingBeatmap defaultBeatmap = null)
+        public BeatmapManager(Storage storage, IDatabaseContextFactory contextFactory, RulesetStore rulesets, IAPIProvider api, GameHost host = null)
             : base(storage, contextFactory, api, new BeatmapStore(contextFactory), host)
         {
             this.rulesets = rulesets;
-            this.audioManager = audioManager;
-            this.resources = resources;
-            this.host = host;
-
-            DefaultBeatmap = defaultBeatmap;
 
             beatmaps = (BeatmapStore)ModelStore;
             beatmaps.BeatmapHidden += b => beatmapHidden.Value = new WeakReference<BeatmapInfo>(b);
             beatmaps.BeatmapRestored += b => beatmapRestored.Value = new WeakReference<BeatmapInfo>(b);
-            beatmaps.ItemRemoved += removeWorkingCache;
-            beatmaps.ItemUpdated += removeWorkingCache;
-
-            largeTextureStore = new LargeTextureStore(host?.CreateTextureLoaderStore(Files.Store));
-            trackStore = audioManager.GetTrackStore(Files.Store);
+            beatmaps.ItemRemoved += b => WorkingBeatmapCache?.Invalidate(b);
+            beatmaps.ItemUpdated += obj => WorkingBeatmapCache?.Invalidate(obj);
         }
 
         protected override ArchiveDownloadRequest<BeatmapSetInfo> CreateDownloadRequest(BeatmapSetInfo set, bool minimiseDownloadSize) =>
@@ -111,33 +89,6 @@ namespace osu.Game.Beatmaps
 
         protected override bool ShouldDeleteArchive(string path) => Path.GetExtension(path)?.ToLowerInvariant() == ".osz";
 
-        public WorkingBeatmap CreateNew(RulesetInfo ruleset, User user)
-        {
-            var metadata = new BeatmapMetadata
-            {
-                Author = user,
-            };
-
-            var set = new BeatmapSetInfo
-            {
-                Metadata = metadata,
-                Beatmaps = new List<BeatmapInfo>
-                {
-                    new BeatmapInfo
-                    {
-                        BaseDifficulty = new BeatmapDifficulty(),
-                        Ruleset = ruleset,
-                        Metadata = metadata,
-                        WidescreenStoryboard = true,
-                        SamplesMatchPlaybackRate = true,
-                    }
-                }
-            };
-
-            var working = Import(set).Result;
-            return GetWorkingBeatmap(working.Beatmaps.First());
-        }
-
         protected override async Task Populate(BeatmapSetInfo beatmapSet, ArchiveReader archive, CancellationToken cancellationToken = default)
         {
             if (archive != null)
@@ -278,43 +229,7 @@ namespace osu.Game.Beatmaps
                 }
             }
 
-            removeWorkingCache(info);
-        }
-
-        private readonly WeakList<BeatmapManagerWorkingBeatmap> workingCache = new WeakList<BeatmapManagerWorkingBeatmap>();
-
-        /// <summary>
-        /// Retrieve a <see cref="WorkingBeatmap"/> instance for the provided <see cref="BeatmapInfo"/>
-        /// </summary>
-        /// <param name="beatmapInfo">The beatmap to lookup.</param>
-        /// <returns>A <see cref="WorkingBeatmap"/> instance correlating to the provided <see cref="BeatmapInfo"/>.</returns>
-        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<int>(nameof(Beatmaps), $"Cached {nameof(WorkingBeatmap)}s").Value = workingCache.Count();
-
-                return working;
-            }
+            WorkingBeatmapCache?.Invalidate(info);
         }
 
         /// <summary>
@@ -515,35 +430,6 @@ namespace osu.Game.Beatmaps
             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);
-            }
-        }
-
-        #region IResourceStorageProvider
-
-        TextureStore IBeatmapResourceProvider.LargeTextureStore => largeTextureStore;
-        ITrackStore IBeatmapResourceProvider.Tracks => trackStore;
-        AudioManager IStorageResourceProvider.AudioManager => audioManager;
-        IResourceStore<byte[]> IStorageResourceProvider.Files => Files.Store;
-        IResourceStore<byte[]> IStorageResourceProvider.Resources => resources;
-        IResourceStore<TextureUpload> IStorageResourceProvider.CreateTextureLoaderStore(IResourceStore<byte[]> underlyingStore) => host?.CreateTextureLoaderStore(underlyingStore);
-
-        #endregion
-
         /// <summary>
         /// A dummy WorkingBeatmap for the purpose of retrieving a beatmap for star difficulty calculation.
         /// </summary>
diff --git a/osu.Game/Beatmaps/BeatmapManager_WorkingBeatmap.cs b/osu.Game/Beatmaps/BeatmapManager_WorkingBeatmap.cs
deleted file mode 100644
index 45112ae74c..0000000000
--- a/osu.Game/Beatmaps/BeatmapManager_WorkingBeatmap.cs
+++ /dev/null
@@ -1,147 +0,0 @@
-// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
-// See the LICENCE file in the repository root for full licence text.
-
-using System;
-using System.Diagnostics.CodeAnalysis;
-using System.IO;
-using osu.Framework.Audio.Track;
-using osu.Framework.Graphics.Textures;
-using osu.Framework.Logging;
-using osu.Framework.Testing;
-using osu.Game.Beatmaps.Formats;
-using osu.Game.IO;
-using osu.Game.Skinning;
-using osu.Game.Storyboards;
-
-namespace osu.Game.Beatmaps
-{
-    public partial class BeatmapManager
-    {
-        [ExcludeFromDynamicCompile]
-        private class BeatmapManagerWorkingBeatmap : WorkingBeatmap
-        {
-            [NotNull]
-            private readonly IBeatmapResourceProvider resources;
-
-            public BeatmapManagerWorkingBeatmap(BeatmapInfo beatmapInfo, [NotNull] IBeatmapResourceProvider resources)
-                : base(beatmapInfo, resources.AudioManager)
-            {
-                this.resources = resources;
-            }
-
-            protected override IBeatmap GetBeatmap()
-            {
-                if (BeatmapInfo.Path == null)
-                    return new Beatmap { BeatmapInfo = BeatmapInfo };
-
-                try
-                {
-                    using (var stream = new LineBufferedReader(GetStream(BeatmapSetInfo.GetPathForFile(BeatmapInfo.Path))))
-                        return Decoder.GetDecoder<Beatmap>(stream).Decode(stream);
-                }
-                catch (Exception e)
-                {
-                    Logger.Error(e, "Beatmap failed to load");
-                    return null;
-                }
-            }
-
-            protected override bool BackgroundStillValid(Texture b) => false; // bypass lazy logic. we want to return a new background each time for refcounting purposes.
-
-            protected override Texture GetBackground()
-            {
-                if (Metadata?.BackgroundFile == null)
-                    return null;
-
-                try
-                {
-                    return resources.LargeTextureStore.Get(BeatmapSetInfo.GetPathForFile(Metadata.BackgroundFile));
-                }
-                catch (Exception e)
-                {
-                    Logger.Error(e, "Background failed to load");
-                    return null;
-                }
-            }
-
-            protected override Track GetBeatmapTrack()
-            {
-                if (Metadata?.AudioFile == null)
-                    return null;
-
-                try
-                {
-                    return resources.Tracks.Get(BeatmapSetInfo.GetPathForFile(Metadata.AudioFile));
-                }
-                catch (Exception e)
-                {
-                    Logger.Error(e, "Track failed to load");
-                    return null;
-                }
-            }
-
-            protected override Waveform GetWaveform()
-            {
-                if (Metadata?.AudioFile == null)
-                    return null;
-
-                try
-                {
-                    var trackData = GetStream(BeatmapSetInfo.GetPathForFile(Metadata.AudioFile));
-                    return trackData == null ? null : new Waveform(trackData);
-                }
-                catch (Exception e)
-                {
-                    Logger.Error(e, "Waveform failed to load");
-                    return null;
-                }
-            }
-
-            protected override Storyboard GetStoryboard()
-            {
-                Storyboard storyboard;
-
-                try
-                {
-                    using (var stream = new LineBufferedReader(GetStream(BeatmapSetInfo.GetPathForFile(BeatmapInfo.Path))))
-                    {
-                        var decoder = Decoder.GetDecoder<Storyboard>(stream);
-
-                        // todo: support loading from both set-wide storyboard *and* beatmap specific.
-                        if (BeatmapSetInfo?.StoryboardFile == null)
-                            storyboard = decoder.Decode(stream);
-                        else
-                        {
-                            using (var secondaryStream = new LineBufferedReader(GetStream(BeatmapSetInfo.GetPathForFile(BeatmapSetInfo.StoryboardFile))))
-                                storyboard = decoder.Decode(stream, secondaryStream);
-                        }
-                    }
-                }
-                catch (Exception e)
-                {
-                    Logger.Error(e, "Storyboard failed to load");
-                    storyboard = new Storyboard();
-                }
-
-                storyboard.BeatmapInfo = BeatmapInfo;
-
-                return storyboard;
-            }
-
-            protected internal override ISkin GetSkin()
-            {
-                try
-                {
-                    return new LegacyBeatmapSkin(BeatmapInfo, resources.Files, resources);
-                }
-                catch (Exception e)
-                {
-                    Logger.Error(e, "Skin failed to load");
-                    return null;
-                }
-            }
-
-            public override Stream GetStream(string storagePath) => resources.Files.GetStream(storagePath);
-        }
-    }
-}
diff --git a/osu.Game/Beatmaps/WorkingBeatmapCache.cs b/osu.Game/Beatmaps/WorkingBeatmapCache.cs
new file mode 100644
index 0000000000..9f40eb4898
--- /dev/null
+++ b/osu.Game/Beatmaps/WorkingBeatmapCache.cs
@@ -0,0 +1,279 @@
+// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+using System.Collections.Generic;
+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;
+using osu.Game.Rulesets;
+using osu.Game.Skinning;
+using osu.Game.Storyboards;
+using osu.Game.Users;
+
+namespace osu.Game.Beatmaps
+{
+    public class WorkingBeatmapCache : IBeatmapResourceProvider
+    {
+        private readonly WeakList<BeatmapManagerWorkingBeatmap> workingCache = new WeakList<BeatmapManagerWorkingBeatmap>();
+
+        /// <summary>
+        /// A default representation of a WorkingBeatmap to use when no beatmap is available.
+        /// </summary>
+        public readonly WorkingBeatmap DefaultBeatmap;
+
+        public BeatmapManager BeatmapManager { private get; set; }
+
+        private readonly AudioManager audioManager;
+        private readonly IResourceStore<byte[]> resources;
+        private readonly LargeTextureStore largeTextureStore;
+        private readonly ITrackStore trackStore;
+        private readonly IResourceStore<byte[]> files;
+
+        [CanBeNull]
+        private readonly GameHost host;
+
+        public WorkingBeatmapCache([NotNull] AudioManager audioManager, IResourceStore<byte[]> resources, IResourceStore<byte[]> 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);
+            }
+        }
+
+        /// <summary>
+        /// Create a new <see cref="WorkingBeatmap"/>.
+        /// </summary>
+        public WorkingBeatmap CreateNew(RulesetInfo ruleset, User user)
+        {
+            var metadata = new BeatmapMetadata
+            {
+                Author = user,
+            };
+
+            var set = new BeatmapSetInfo
+            {
+                Metadata = metadata,
+                Beatmaps = new List<BeatmapInfo>
+                {
+                    new BeatmapInfo
+                    {
+                        BaseDifficulty = new BeatmapDifficulty(),
+                        Ruleset = ruleset,
+                        Metadata = metadata,
+                        WidescreenStoryboard = true,
+                        SamplesMatchPlaybackRate = true,
+                    }
+                }
+            };
+
+            var working = BeatmapManager.Import(set).Result;
+            return GetWorkingBeatmap(working.Beatmaps.First());
+        }
+
+        /// <summary>
+        /// Retrieve a <see cref="WorkingBeatmap"/> instance for the provided <see cref="BeatmapInfo"/>
+        /// </summary>
+        /// <param name="beatmapInfo">The beatmap to lookup.</param>
+        /// <returns>A <see cref="WorkingBeatmap"/> instance correlating to the provided <see cref="BeatmapInfo"/>.</returns>
+        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<int>(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<byte[]> IStorageResourceProvider.Files => files;
+        IResourceStore<byte[]> IStorageResourceProvider.Resources => resources;
+        IResourceStore<TextureUpload> IStorageResourceProvider.CreateTextureLoaderStore(IResourceStore<byte[]> underlyingStore) => host?.CreateTextureLoaderStore(underlyingStore);
+
+        #endregion
+
+        [ExcludeFromDynamicCompile]
+        private class BeatmapManagerWorkingBeatmap : WorkingBeatmap
+        {
+            [NotNull]
+            private readonly IBeatmapResourceProvider resources;
+
+            public BeatmapManagerWorkingBeatmap(BeatmapInfo beatmapInfo, [NotNull] IBeatmapResourceProvider resources)
+                : base(beatmapInfo, resources.AudioManager)
+            {
+                this.resources = resources;
+            }
+
+            protected override IBeatmap GetBeatmap()
+            {
+                if (BeatmapInfo.Path == null)
+                    return new Beatmap { BeatmapInfo = BeatmapInfo };
+
+                try
+                {
+                    using (var stream = new LineBufferedReader(GetStream(BeatmapSetInfo.GetPathForFile(BeatmapInfo.Path))))
+                        return Decoder.GetDecoder<Beatmap>(stream).Decode(stream);
+                }
+                catch (Exception e)
+                {
+                    Logger.Error(e, "Beatmap failed to load");
+                    return null;
+                }
+            }
+
+            protected override bool BackgroundStillValid(Texture b) => false; // bypass lazy logic. we want to return a new background each time for refcounting purposes.
+
+            protected override Texture GetBackground()
+            {
+                if (Metadata?.BackgroundFile == null)
+                    return null;
+
+                try
+                {
+                    return resources.LargeTextureStore.Get(BeatmapSetInfo.GetPathForFile(Metadata.BackgroundFile));
+                }
+                catch (Exception e)
+                {
+                    Logger.Error(e, "Background failed to load");
+                    return null;
+                }
+            }
+
+            protected override Track GetBeatmapTrack()
+            {
+                if (Metadata?.AudioFile == null)
+                    return null;
+
+                try
+                {
+                    return resources.Tracks.Get(BeatmapSetInfo.GetPathForFile(Metadata.AudioFile));
+                }
+                catch (Exception e)
+                {
+                    Logger.Error(e, "Track failed to load");
+                    return null;
+                }
+            }
+
+            protected override Waveform GetWaveform()
+            {
+                if (Metadata?.AudioFile == null)
+                    return null;
+
+                try
+                {
+                    var trackData = GetStream(BeatmapSetInfo.GetPathForFile(Metadata.AudioFile));
+                    return trackData == null ? null : new Waveform(trackData);
+                }
+                catch (Exception e)
+                {
+                    Logger.Error(e, "Waveform failed to load");
+                    return null;
+                }
+            }
+
+            protected override Storyboard GetStoryboard()
+            {
+                Storyboard storyboard;
+
+                try
+                {
+                    using (var stream = new LineBufferedReader(GetStream(BeatmapSetInfo.GetPathForFile(BeatmapInfo.Path))))
+                    {
+                        var decoder = Decoder.GetDecoder<Storyboard>(stream);
+
+                        // todo: support loading from both set-wide storyboard *and* beatmap specific.
+                        if (BeatmapSetInfo?.StoryboardFile == null)
+                            storyboard = decoder.Decode(stream);
+                        else
+                        {
+                            using (var secondaryStream = new LineBufferedReader(GetStream(BeatmapSetInfo.GetPathForFile(BeatmapSetInfo.StoryboardFile))))
+                                storyboard = decoder.Decode(stream, secondaryStream);
+                        }
+                    }
+                }
+                catch (Exception e)
+                {
+                    Logger.Error(e, "Storyboard failed to load");
+                    storyboard = new Storyboard();
+                }
+
+                storyboard.BeatmapInfo = BeatmapInfo;
+
+                return storyboard;
+            }
+
+            protected internal override ISkin GetSkin()
+            {
+                try
+                {
+                    return new LegacyBeatmapSkin(BeatmapInfo, resources.Files, resources);
+                }
+                catch (Exception e)
+                {
+                    Logger.Error(e, "Skin failed to load");
+                    return null;
+                }
+            }
+
+            public override Stream GetStream(string storagePath) => resources.Files.GetStream(storagePath);
+        }
+    }
+}

From d21139b03efb77e7f5aeeea2d8236320d0e0d693 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Thu, 30 Sep 2021 15:43:49 +0900
Subject: [PATCH 2/7] Split out database portion from `BeatmapManager`

---
 osu.Game/Beatmaps/BeatmapManager.cs      | 466 +---------------------
 osu.Game/Beatmaps/BeatmapModelManager.cs | 479 +++++++++++++++++++++++
 2 files changed, 483 insertions(+), 462 deletions(-)
 create mode 100644 osu.Game/Beatmaps/BeatmapModelManager.cs

diff --git a/osu.Game/Beatmaps/BeatmapManager.cs b/osu.Game/Beatmaps/BeatmapManager.cs
index 1c0e7dc319..c445925a90 100644
--- a/osu.Game/Beatmaps/BeatmapManager.cs
+++ b/osu.Game/Beatmaps/BeatmapManager.cs
@@ -1,479 +1,21 @@
 // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
 // See the LICENCE file in the repository root for full licence text.
 
-using System;
-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.Online.API;
-using osu.Game.Online.API.Requests;
-using osu.Game.Rulesets;
-using osu.Game.Rulesets.Objects;
-using osu.Game.Skinning;
-using Decoder = osu.Game.Beatmaps.Formats.Decoder;
 
 namespace osu.Game.Beatmaps
 {
     /// <summary>
-    /// Handles the storage and retrieval of Beatmaps/WorkingBeatmaps.
+    /// Handles general operations related to global beatmap management.
     /// </summary>
     [ExcludeFromDynamicCompile]
-    public class BeatmapManager : DownloadableArchiveModelManager<BeatmapSetInfo, BeatmapSetFileInfo>
+    public class BeatmapManager
     {
-        /// <summary>
-        /// Fired when a single difficulty has been hidden.
-        /// </summary>
-        public IBindable<WeakReference<BeatmapInfo>> BeatmapHidden => beatmapHidden;
-
-        private readonly Bindable<WeakReference<BeatmapInfo>> beatmapHidden = new Bindable<WeakReference<BeatmapInfo>>();
-
-        /// <summary>
-        /// Fired when a single difficulty has been restored.
-        /// </summary>
-        public IBindable<WeakReference<BeatmapInfo>> BeatmapRestored => beatmapRestored;
-
-        /// <summary>
-        /// A function which populates online information during the import process.
-        /// It is run as the final step of import.
-        /// </summary>
-        public Func<BeatmapSetInfo, CancellationToken, Task> PopulateOnlineInformation;
-
-        /// <summary>
-        /// The game working beatmap cache, used to invalidate entries on changes.
-        /// </summary>
-        public WorkingBeatmapCache WorkingBeatmapCache { private get; set; }
-
-        private readonly Bindable<WeakReference<BeatmapInfo>> beatmapRestored = new Bindable<WeakReference<BeatmapInfo>>();
-
-        public override IEnumerable<string> 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 BeatmapManager(Storage storage, IDatabaseContextFactory contextFactory, RulesetStore rulesets, IAPIProvider api, GameHost host = null)
-            : base(storage, contextFactory, api, new BeatmapStore(contextFactory), host)
+        public BeatmapManager()
         {
-            this.rulesets = rulesets;
-
-            beatmaps = (BeatmapStore)ModelStore;
-            beatmaps.BeatmapHidden += b => beatmapHidden.Value = new WeakReference<BeatmapInfo>(b);
-            beatmaps.BeatmapRestored += b => beatmapRestored.Value = new WeakReference<BeatmapInfo>(b);
-            beatmaps.ItemRemoved += b => WorkingBeatmapCache?.Invalidate(b);
-            beatmaps.ItemUpdated += obj => WorkingBeatmapCache?.Invalidate(obj);
+            beatmapModelManager = new BeatmapModelManager()
         }
 
-        protected override ArchiveDownloadRequest<BeatmapSetInfo> CreateDownloadRequest(BeatmapSetInfo set, bool minimiseDownloadSize) =>
-            new DownloadBeatmapSetRequest(set, minimiseDownloadSize);
-
-        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 (PopulateOnlineInformation != null)
-                await PopulateOnlineInformation(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<BeatmapSetInfo> items)
-            => base.CheckLocalAvailability(model, items)
-               || (model.OnlineBeatmapSetID != null && items.Any(b => b.OnlineBeatmapSetID == model.OnlineBeatmapSetID));
-
-        /// <summary>
-        /// Delete a beatmap difficulty.
-        /// </summary>
-        /// <param name="beatmap">The beatmap difficulty to hide.</param>
-        public void Hide(BeatmapInfo beatmap) => beatmaps.Hide(beatmap);
-
-        /// <summary>
-        /// Restore a beatmap difficulty.
-        /// </summary>
-        /// <param name="beatmap">The beatmap difficulty to restore.</param>
-        public void Restore(BeatmapInfo beatmap) => beatmaps.Restore(beatmap);
-
-        /// <summary>
-        /// Saves an <see cref="IBeatmap"/> file against a given <see cref="BeatmapInfo"/>.
-        /// </summary>
-        /// <param name="info">The <see cref="BeatmapInfo"/> to save the content against. The file referenced by <see cref="BeatmapInfo.Path"/> will be replaced.</param>
-        /// <param name="beatmapContent">The <see cref="IBeatmap"/> content to write.</param>
-        /// <param name="beatmapSkin">The beatmap <see cref="ISkin"/> content to write, null if to be omitted.</param>
-        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);
-        }
-
-        /// <summary>
-        /// Perform a lookup query on available <see cref="BeatmapSetInfo"/>s.
-        /// </summary>
-        /// <param name="query">The query.</param>
-        /// <returns>The first result for the provided query, or null if no results were found.</returns>
-        public BeatmapSetInfo QueryBeatmapSet(Expression<Func<BeatmapSetInfo, bool>> 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);
-        }
-
-        /// <summary>
-        /// Returns a list of all usable <see cref="BeatmapSetInfo"/>s.
-        /// </summary>
-        /// <returns>A list of available <see cref="BeatmapSetInfo"/>.</returns>
-        public List<BeatmapSetInfo> GetAllUsableBeatmapSets(IncludedDetails includes = IncludedDetails.All, bool includeProtected = false) =>
-            GetAllUsableBeatmapSetsEnumerable(includes, includeProtected).ToList();
-
-        /// <summary>
-        /// Returns a list of all usable <see cref="BeatmapSetInfo"/>s. Note that files are not populated.
-        /// </summary>
-        /// <param name="includes">The level of detail to include in the returned objects.</param>
-        /// <param name="includeProtected">Whether to include protected (system) beatmaps. These should not be included for gameplay playable use cases.</param>
-        /// <returns>A list of available <see cref="BeatmapSetInfo"/>.</returns>
-        public IEnumerable<BeatmapSetInfo> GetAllUsableBeatmapSetsEnumerable(IncludedDetails includes, bool includeProtected = false)
-        {
-            IQueryable<BeatmapSetInfo> 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));
-        }
-
-        /// <summary>
-        /// Perform a lookup query on available <see cref="BeatmapSetInfo"/>s.
-        /// </summary>
-        /// <param name="query">The query.</param>
-        /// <param name="includes">The level of detail to include in the returned objects.</param>
-        /// <returns>Results from the provided query.</returns>
-        public IEnumerable<BeatmapSetInfo> QueryBeatmapSets(Expression<Func<BeatmapSetInfo, bool>> query, IncludedDetails includes = IncludedDetails.All)
-        {
-            IQueryable<BeatmapSetInfo> 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);
-        }
-
-        /// <summary>
-        /// Perform a lookup query on available <see cref="BeatmapInfo"/>s.
-        /// </summary>
-        /// <param name="query">The query.</param>
-        /// <returns>The first result for the provided query, or null if no results were found.</returns>
-        public BeatmapInfo QueryBeatmap(Expression<Func<BeatmapInfo, bool>> query) => beatmaps.Beatmaps.AsNoTracking().FirstOrDefault(query);
-
-        /// <summary>
-        /// Perform a lookup query on available <see cref="BeatmapInfo"/>s.
-        /// </summary>
-        /// <param name="query">The query.</param>
-        /// <returns>Results from the provided query.</returns>
-        public IQueryable<BeatmapInfo> QueryBeatmaps(Expression<Func<BeatmapInfo, bool>> query) => beatmaps.Beatmaps.AsNoTracking().Where(query);
-
-        protected override string HumanisedModelName => "beatmap";
-
-        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<Beatmap>(stream).Decode(stream);
-
-            return new BeatmapSetInfo
-            {
-                OnlineBeatmapSetID = beatmap.BeatmapInfo.BeatmapSet?.OnlineBeatmapSetID,
-                Beatmaps = new List<BeatmapInfo>(),
-                Metadata = beatmap.Metadata,
-                DateAdded = DateTimeOffset.UtcNow
-            };
-        }
-
-        /// <summary>
-        /// Create all required <see cref="BeatmapInfo"/>s for the provided archive.
-        /// </summary>
-        private List<BeatmapInfo> createBeatmapDifficulties(List<BeatmapSetFileInfo> files)
-        {
-            var beatmapInfos = new List<BeatmapInfo>();
-
-            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<Beatmap>(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;
-        }
-
-        /// <summary>
-        /// A dummy WorkingBeatmap for the purpose of retrieving a beatmap for star difficulty calculation.
-        /// </summary>
-        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;
-        }
     }
 
-    /// <summary>
-    /// The level of detail to include in database results.
-    /// </summary>
-    public enum IncludedDetails
-    {
-        /// <summary>
-        /// Only include beatmap difficulties and set level metadata.
-        /// </summary>
-        Minimal,
-
-        /// <summary>
-        /// Include all difficulties, rulesets, difficulty metadata but no files.
-        /// </summary>
-        AllButFiles,
-
-        /// <summary>
-        /// Include everything except ruleset. Used for cases where we aren't sure the ruleset is present but still want to consume the beatmap.
-        /// </summary>
-        AllButRuleset,
-
-        /// <summary>
-        /// Include everything.
-        /// </summary>
-        All
-    }
 }
diff --git a/osu.Game/Beatmaps/BeatmapModelManager.cs b/osu.Game/Beatmaps/BeatmapModelManager.cs
new file mode 100644
index 0000000000..be3adc412c
--- /dev/null
+++ b/osu.Game/Beatmaps/BeatmapModelManager.cs
@@ -0,0 +1,479 @@
+// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+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.Online.API;
+using osu.Game.Online.API.Requests;
+using osu.Game.Rulesets;
+using osu.Game.Rulesets.Objects;
+using osu.Game.Skinning;
+using Decoder = osu.Game.Beatmaps.Formats.Decoder;
+
+namespace osu.Game.Beatmaps
+{
+    /// <summary>
+    /// Handles ef-core storage of beatmaps.
+    /// </summary>
+    [ExcludeFromDynamicCompile]
+    public class BeatmapModelManager : DownloadableArchiveModelManager<BeatmapSetInfo, BeatmapSetFileInfo>
+    {
+        /// <summary>
+        /// Fired when a single difficulty has been hidden.
+        /// </summary>
+        public IBindable<WeakReference<BeatmapInfo>> BeatmapHidden => beatmapHidden;
+
+        private readonly Bindable<WeakReference<BeatmapInfo>> beatmapHidden = new Bindable<WeakReference<BeatmapInfo>>();
+
+        /// <summary>
+        /// Fired when a single difficulty has been restored.
+        /// </summary>
+        public IBindable<WeakReference<BeatmapInfo>> BeatmapRestored => beatmapRestored;
+
+        /// <summary>
+        /// A function which populates online information during the import process.
+        /// It is run as the final step of import.
+        /// </summary>
+        public Func<BeatmapSetInfo, CancellationToken, Task> PopulateOnlineInformation;
+
+        /// <summary>
+        /// The game working beatmap cache, used to invalidate entries on changes.
+        /// </summary>
+        public WorkingBeatmapCache WorkingBeatmapCache { private get; set; }
+
+        private readonly Bindable<WeakReference<BeatmapInfo>> beatmapRestored = new Bindable<WeakReference<BeatmapInfo>>();
+
+        public override IEnumerable<string> 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, IAPIProvider api, GameHost host = null)
+            : base(storage, contextFactory, api, new BeatmapStore(contextFactory), host)
+        {
+            this.rulesets = rulesets;
+
+            beatmaps = (BeatmapStore)ModelStore;
+            beatmaps.BeatmapHidden += b => beatmapHidden.Value = new WeakReference<BeatmapInfo>(b);
+            beatmaps.BeatmapRestored += b => beatmapRestored.Value = new WeakReference<BeatmapInfo>(b);
+            beatmaps.ItemRemoved += b => WorkingBeatmapCache?.Invalidate(b);
+            beatmaps.ItemUpdated += obj => WorkingBeatmapCache?.Invalidate(obj);
+        }
+
+        protected override ArchiveDownloadRequest<BeatmapSetInfo> CreateDownloadRequest(BeatmapSetInfo set, bool minimiseDownloadSize) =>
+            new DownloadBeatmapSetRequest(set, minimiseDownloadSize);
+
+        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 (PopulateOnlineInformation != null)
+                await PopulateOnlineInformation(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<BeatmapSetInfo> items)
+            => base.CheckLocalAvailability(model, items)
+               || (model.OnlineBeatmapSetID != null && items.Any(b => b.OnlineBeatmapSetID == model.OnlineBeatmapSetID));
+
+        /// <summary>
+        /// Delete a beatmap difficulty.
+        /// </summary>
+        /// <param name="beatmap">The beatmap difficulty to hide.</param>
+        public void Hide(BeatmapInfo beatmap) => beatmaps.Hide(beatmap);
+
+        /// <summary>
+        /// Restore a beatmap difficulty.
+        /// </summary>
+        /// <param name="beatmap">The beatmap difficulty to restore.</param>
+        public void Restore(BeatmapInfo beatmap) => beatmaps.Restore(beatmap);
+
+        /// <summary>
+        /// Saves an <see cref="IBeatmap"/> file against a given <see cref="BeatmapInfo"/>.
+        /// </summary>
+        /// <param name="info">The <see cref="BeatmapInfo"/> to save the content against. The file referenced by <see cref="BeatmapInfo.Path"/> will be replaced.</param>
+        /// <param name="beatmapContent">The <see cref="IBeatmap"/> content to write.</param>
+        /// <param name="beatmapSkin">The beatmap <see cref="ISkin"/> content to write, null if to be omitted.</param>
+        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);
+        }
+
+        /// <summary>
+        /// Perform a lookup query on available <see cref="BeatmapSetInfo"/>s.
+        /// </summary>
+        /// <param name="query">The query.</param>
+        /// <returns>The first result for the provided query, or null if no results were found.</returns>
+        public BeatmapSetInfo QueryBeatmapSet(Expression<Func<BeatmapSetInfo, bool>> 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);
+        }
+
+        /// <summary>
+        /// Returns a list of all usable <see cref="BeatmapSetInfo"/>s.
+        /// </summary>
+        /// <returns>A list of available <see cref="BeatmapSetInfo"/>.</returns>
+        public List<BeatmapSetInfo> GetAllUsableBeatmapSets(IncludedDetails includes = IncludedDetails.All, bool includeProtected = false) =>
+            GetAllUsableBeatmapSetsEnumerable(includes, includeProtected).ToList();
+
+        /// <summary>
+        /// Returns a list of all usable <see cref="BeatmapSetInfo"/>s. Note that files are not populated.
+        /// </summary>
+        /// <param name="includes">The level of detail to include in the returned objects.</param>
+        /// <param name="includeProtected">Whether to include protected (system) beatmaps. These should not be included for gameplay playable use cases.</param>
+        /// <returns>A list of available <see cref="BeatmapSetInfo"/>.</returns>
+        public IEnumerable<BeatmapSetInfo> GetAllUsableBeatmapSetsEnumerable(IncludedDetails includes, bool includeProtected = false)
+        {
+            IQueryable<BeatmapSetInfo> 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));
+        }
+
+        /// <summary>
+        /// Perform a lookup query on available <see cref="BeatmapSetInfo"/>s.
+        /// </summary>
+        /// <param name="query">The query.</param>
+        /// <param name="includes">The level of detail to include in the returned objects.</param>
+        /// <returns>Results from the provided query.</returns>
+        public IEnumerable<BeatmapSetInfo> QueryBeatmapSets(Expression<Func<BeatmapSetInfo, bool>> query, IncludedDetails includes = IncludedDetails.All)
+        {
+            IQueryable<BeatmapSetInfo> 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);
+        }
+
+        /// <summary>
+        /// Perform a lookup query on available <see cref="BeatmapInfo"/>s.
+        /// </summary>
+        /// <param name="query">The query.</param>
+        /// <returns>The first result for the provided query, or null if no results were found.</returns>
+        public BeatmapInfo QueryBeatmap(Expression<Func<BeatmapInfo, bool>> query) => beatmaps.Beatmaps.AsNoTracking().FirstOrDefault(query);
+
+        /// <summary>
+        /// Perform a lookup query on available <see cref="BeatmapInfo"/>s.
+        /// </summary>
+        /// <param name="query">The query.</param>
+        /// <returns>Results from the provided query.</returns>
+        public IQueryable<BeatmapInfo> QueryBeatmaps(Expression<Func<BeatmapInfo, bool>> query) => beatmaps.Beatmaps.AsNoTracking().Where(query);
+
+        protected override string HumanisedModelName => "beatmap";
+
+        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<Beatmap>(stream).Decode(stream);
+
+            return new BeatmapSetInfo
+            {
+                OnlineBeatmapSetID = beatmap.BeatmapInfo.BeatmapSet?.OnlineBeatmapSetID,
+                Beatmaps = new List<BeatmapInfo>(),
+                Metadata = beatmap.Metadata,
+                DateAdded = DateTimeOffset.UtcNow
+            };
+        }
+
+        /// <summary>
+        /// Create all required <see cref="BeatmapInfo"/>s for the provided archive.
+        /// </summary>
+        private List<BeatmapInfo> createBeatmapDifficulties(List<BeatmapSetFileInfo> files)
+        {
+            var beatmapInfos = new List<BeatmapInfo>();
+
+            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<Beatmap>(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;
+        }
+
+        /// <summary>
+        /// A dummy WorkingBeatmap for the purpose of retrieving a beatmap for star difficulty calculation.
+        /// </summary>
+        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;
+        }
+    }
+
+    /// <summary>
+    /// The level of detail to include in database results.
+    /// </summary>
+    public enum IncludedDetails
+    {
+        /// <summary>
+        /// Only include beatmap difficulties and set level metadata.
+        /// </summary>
+        Minimal,
+
+        /// <summary>
+        /// Include all difficulties, rulesets, difficulty metadata but no files.
+        /// </summary>
+        AllButFiles,
+
+        /// <summary>
+        /// Include everything except ruleset. Used for cases where we aren't sure the ruleset is present but still want to consume the beatmap.
+        /// </summary>
+        AllButRuleset,
+
+        /// <summary>
+        /// Include everything.
+        /// </summary>
+        All
+    }
+}

From 5618c9933bfd61b5587160a1821248bf7b1fb214 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Thu, 30 Sep 2021 16:44:39 +0900
Subject: [PATCH 3/7] Expose more pieces of `ArchiveModelManager` via
 interfaces

---
 osu.Game/Database/ArchiveModelManager.cs      |  11 +-
 .../DownloadableArchiveModelManager.cs        |   6 --
 osu.Game/Database/IModelFileManager.cs        |  36 +++++++
 osu.Game/Database/IModelManager.cs            | 101 +++++++++++++++++-
 osu.Game/Scoring/ScoreManager.cs              |   2 +-
 5 files changed, 141 insertions(+), 15 deletions(-)
 create mode 100644 osu.Game/Database/IModelFileManager.cs

diff --git a/osu.Game/Database/ArchiveModelManager.cs b/osu.Game/Database/ArchiveModelManager.cs
index ddd2bc5d1e..fc217d3058 100644
--- a/osu.Game/Database/ArchiveModelManager.cs
+++ b/osu.Game/Database/ArchiveModelManager.cs
@@ -30,7 +30,7 @@ namespace osu.Game.Database
     /// </summary>
     /// <typeparam name="TModel">The model type.</typeparam>
     /// <typeparam name="TFileModel">The associated file join type.</typeparam>
-    public abstract class ArchiveModelManager<TModel, TFileModel> : ICanAcceptFiles, IModelManager<TModel>
+    public abstract class ArchiveModelManager<TModel, TFileModel> : ICanAcceptFiles, IModelManager<TModel>, IModelFileManager<TModel, TFileModel>
         where TModel : class, IHasFiles<TFileModel>, IHasPrimaryKey, ISoftDelete
         where TFileModel : class, INamedFileInfo, new()
     {
@@ -135,7 +135,7 @@ namespace osu.Game.Database
             return Import(notification, tasks);
         }
 
-        protected async Task<IEnumerable<TModel>> Import(ProgressNotification notification, params ImportTask[] tasks)
+        public async Task<IEnumerable<TModel>> Import(ProgressNotification notification, params ImportTask[] tasks)
         {
             if (tasks.Length == 0)
             {
@@ -227,7 +227,7 @@ namespace osu.Game.Database
         /// <param name="lowPriority">Whether this is a low priority import.</param>
         /// <param name="cancellationToken">An optional cancellation token.</param>
         /// <returns>The imported model, if successful.</returns>
-        internal async Task<TModel> Import(ImportTask task, bool lowPriority = false, CancellationToken cancellationToken = default)
+        public async Task<TModel> Import(ImportTask task, bool lowPriority = false, CancellationToken cancellationToken = default)
         {
             cancellationToken.ThrowIfCancellationRequested();
 
@@ -479,7 +479,7 @@ namespace osu.Game.Database
         /// </summary>
         /// <param name="model">The item to export.</param>
         /// <param name="outputStream">The output stream to export to.</param>
-        protected virtual void ExportModelTo(TModel model, Stream outputStream)
+        public virtual void ExportModelTo(TModel model, Stream outputStream)
         {
             using (var archive = ZipArchive.Create())
             {
@@ -745,9 +745,6 @@ namespace osu.Game.Database
         /// <returns>Whether to perform deletion.</returns>
         protected virtual bool ShouldDeleteArchive(string path) => false;
 
-        /// <summary>
-        /// This is a temporary method and will likely be replaced by a full-fledged (and more correctly placed) migration process in the future.
-        /// </summary>
         public Task ImportFromStableAsync(StableStorage stableStorage)
         {
             var storage = PrepareStableStorage(stableStorage);
diff --git a/osu.Game/Database/DownloadableArchiveModelManager.cs b/osu.Game/Database/DownloadableArchiveModelManager.cs
index da3144e8d0..e6d5b44b65 100644
--- a/osu.Game/Database/DownloadableArchiveModelManager.cs
+++ b/osu.Game/Database/DownloadableArchiveModelManager.cs
@@ -54,12 +54,6 @@ namespace osu.Game.Database
         /// <returns>The request object.</returns>
         protected abstract ArchiveDownloadRequest<TModel> CreateDownloadRequest(TModel model, bool minimiseDownloadSize);
 
-        /// <summary>
-        /// Begin a download for the requested <typeparamref name="TModel"/>.
-        /// </summary>
-        /// <param name="model">The <typeparamref name="TModel"/> to be downloaded.</param>
-        /// <param name="minimiseDownloadSize">Whether this download should be optimised for slow connections. Generally means extras are not included in the download bundle.</param>
-        /// <returns>Whether the download was started.</returns>
         public bool Download(TModel model, bool minimiseDownloadSize = false)
         {
             if (!canDownload(model)) return false;
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 <contact@ppy.sh>. 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<in TModel, in TFileModel>
+        where TModel : class
+        where TFileModel : class
+    {
+        /// <summary>
+        /// Replace an existing file with a new version.
+        /// </summary>
+        /// <param name="model">The item to operate on.</param>
+        /// <param name="file">The existing file to be replaced.</param>
+        /// <param name="contents">The new file contents.</param>
+        /// <param name="filename">An optional filename for the new file. Will use the previous filename if not specified.</param>
+        void ReplaceFile(TModel model, TFileModel file, Stream contents, string filename = null);
+
+        /// <summary>
+        /// Delete an existing file.
+        /// </summary>
+        /// <param name="model">The item to operate on.</param>
+        /// <param name="file">The existing file to be deleted.</param>
+        void DeleteFile(TModel model, TFileModel file);
+
+        /// <summary>
+        /// Add a new file.
+        /// </summary>
+        /// <param name="model">The item to operate on.</param>
+        /// <param name="contents">The new file contents.</param>
+        /// <param name="filename">The filename for the new file.</param>
+        void AddFile(TModel model, Stream contents, string filename);
+    }
+}
diff --git a/osu.Game/Database/IModelManager.cs b/osu.Game/Database/IModelManager.cs
index 8c314f1617..8f0c6e1561 100644
--- a/osu.Game/Database/IModelManager.cs
+++ b/osu.Game/Database/IModelManager.cs
@@ -1,8 +1,15 @@
-// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
+// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
 // See the LICENCE file in the repository root for full licence text.
 
 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
 {
@@ -24,5 +31,97 @@ namespace osu.Game.Database
         /// This is not thread-safe and should be scheduled locally if consumed from a drawable component.
         /// </summary>
         IBindable<WeakReference<TModel>> ItemRemoved { get; }
+
+        /// <summary>
+        /// This is a temporary method and will likely be replaced by a full-fledged (and more correctly placed) migration process in the future.
+        /// </summary>
+        Task ImportFromStableAsync(StableStorage stableStorage);
+
+        /// <summary>
+        /// Exports an item to a legacy (.zip based) package.
+        /// </summary>
+        /// <param name="item">The item to export.</param>
+        void Export(TModel item);
+
+        /// <summary>
+        /// Exports an item to the given output stream.
+        /// </summary>
+        /// <param name="model">The item to export.</param>
+        /// <param name="outputStream">The output stream to export to.</param>
+        void ExportModelTo(TModel model, Stream outputStream);
+
+        /// <summary>
+        /// Perform an update of the specified item.
+        /// TODO: Support file additions/removals.
+        /// </summary>
+        /// <param name="item">The item to update.</param>
+        void Update(TModel item);
+
+        /// <summary>
+        /// Delete an item from the manager.
+        /// Is a no-op for already deleted items.
+        /// </summary>
+        /// <param name="item">The item to delete.</param>
+        /// <returns>false if no operation was performed</returns>
+        bool Delete(TModel item);
+
+        /// <summary>
+        /// Delete multiple items.
+        /// This will post notifications tracking progress.
+        /// </summary>
+        void Delete(List<TModel> items, bool silent = false);
+
+        /// <summary>
+        /// Restore multiple items that were previously deleted.
+        /// This will post notifications tracking progress.
+        /// </summary>
+        void Undelete(List<TModel> items, bool silent = false);
+
+        /// <summary>
+        /// Restore an item that was previously deleted. Is a no-op if the item is not in a deleted state, or has its protected flag set.
+        /// </summary>
+        /// <param name="item">The item to restore</param>
+        void Undelete(TModel item);
+
+        /// <summary>
+        /// Import one or more <typeparamref name="TModel"/> items from filesystem <paramref name="paths"/>.
+        /// </summary>
+        /// <remarks>
+        /// This will be treated as a low priority import if more than one path is specified; use <see cref="ArchiveModelManager{TModel,TFileModel}.Import(osu.Game.Database.ImportTask[])"/> to always import at standard priority.
+        /// This will post notifications tracking progress.
+        /// </remarks>
+        /// <param name="paths">One or more archive locations on disk.</param>
+        Task Import(params string[] paths);
+
+        Task Import(params ImportTask[] tasks);
+
+        Task<IEnumerable<TModel>> Import(ProgressNotification notification, params ImportTask[] tasks);
+
+        /// <summary>
+        /// Import one <typeparamref name="TModel"/> from the filesystem and delete the file on success.
+        /// Note that this bypasses the UI flow and should only be used for special cases or testing.
+        /// </summary>
+        /// <param name="task">The <see cref="ImportTask"/> containing data about the <typeparamref name="TModel"/> to import.</param>
+        /// <param name="lowPriority">Whether this is a low priority import.</param>
+        /// <param name="cancellationToken">An optional cancellation token.</param>
+        /// <returns>The imported model, if successful.</returns>
+        Task<TModel> Import(ImportTask task, bool lowPriority = false, CancellationToken cancellationToken = default);
+
+        /// <summary>
+        /// Silently import an item from an <see cref="ArchiveReader"/>.
+        /// </summary>
+        /// <param name="archive">The archive to be imported.</param>
+        /// <param name="lowPriority">Whether this is a low priority import.</param>
+        /// <param name="cancellationToken">An optional cancellation token.</param>
+        Task<TModel> Import(ArchiveReader archive, bool lowPriority = false, CancellationToken cancellationToken = default);
+
+        /// <summary>
+        /// Silently import an item from a <typeparamref name="TModel"/>.
+        /// </summary>
+        /// <param name="item">The model to be imported.</param>
+        /// <param name="archive">An optional archive to use for model population.</param>
+        /// <param name="lowPriority">Whether this is a low priority import.</param>
+        /// <param name="cancellationToken">An optional cancellation token.</param>
+        Task<TModel> Import(TModel item, ArchiveReader archive = null, bool lowPriority = false, CancellationToken cancellationToken = default);
     }
 }
diff --git a/osu.Game/Scoring/ScoreManager.cs b/osu.Game/Scoring/ScoreManager.cs
index 81e701f001..56c346d177 100644
--- a/osu.Game/Scoring/ScoreManager.cs
+++ b/osu.Game/Scoring/ScoreManager.cs
@@ -78,7 +78,7 @@ namespace osu.Game.Scoring
         protected override Task Populate(ScoreInfo model, ArchiveReader archive, CancellationToken cancellationToken = default)
             => Task.CompletedTask;
 
-        protected override void ExportModelTo(ScoreInfo model, Stream outputStream)
+        public override void ExportModelTo(ScoreInfo model, Stream outputStream)
         {
             var file = model.Files.SingleOrDefault();
             if (file == null)

From 90225f20820ed74fe6269a7c1c4105d2c3e4866a Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Thu, 30 Sep 2021 16:45:32 +0900
Subject: [PATCH 4/7] Hook up all required interfaces to new `BeatmapManager`

---
 ...eneOnlinePlayBeatmapAvailabilityTracker.cs |  26 +-
 osu.Game/Beatmaps/BeatmapManager.cs           | 297 +++++++++++++++++-
 osu.Game/Beatmaps/IWorkingBeatmapCache.cs     |  15 +
 osu.Game/Beatmaps/WorkingBeatmapCache.cs      |  42 +--
 osu.Game/OsuGameBase.cs                       |   6 -
 osu.Game/Tests/Visual/EditorTestScene.cs      |  37 ++-
 6 files changed, 364 insertions(+), 59 deletions(-)
 create mode 100644 osu.Game/Beatmaps/IWorkingBeatmapCache.cs

diff --git a/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs b/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs
index a7d34fadbe..1a3f9e414d 100644
--- a/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs
+++ b/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs
@@ -158,18 +158,34 @@ namespace osu.Game.Tests.Online
 
             public Task<BeatmapSetInfo> CurrentImportTask { get; private set; }
 
-            protected override ArchiveDownloadRequest<BeatmapSetInfo> CreateDownloadRequest(BeatmapSetInfo set, bool minimiseDownloadSize)
-                => new TestDownloadRequest(set);
+            protected override BeatmapModelManager CreateBeatmapModelManager(Storage storage, IDatabaseContextFactory contextFactory, RulesetStore rulesets, IAPIProvider api, GameHost host)
+            {
+                return new TestBeatmapModelManager(this, storage, contextFactory, rulesets, api, host);
+            }
 
             public TestBeatmapManager(Storage storage, IDatabaseContextFactory contextFactory, RulesetStore rulesets, IAPIProvider api, [NotNull] AudioManager audioManager, IResourceStore<byte[]> resources, GameHost host = null, WorkingBeatmap defaultBeatmap = null)
                 : base(storage, contextFactory, rulesets, api, audioManager, resources, host, defaultBeatmap)
             {
             }
 
-            public override async Task<BeatmapSetInfo> Import(BeatmapSetInfo item, ArchiveReader archive = null, bool lowPriority = false, CancellationToken cancellationToken = default)
+            internal class TestBeatmapModelManager : BeatmapModelManager
             {
-                await AllowImport.Task.ConfigureAwait(false);
-                return await (CurrentImportTask = base.Import(item, archive, lowPriority, cancellationToken)).ConfigureAwait(false);
+                private readonly TestBeatmapManager testBeatmapManager;
+
+                public TestBeatmapModelManager(TestBeatmapManager testBeatmapManager, Storage storage, IDatabaseContextFactory databaseContextFactory, RulesetStore rulesetStore, IAPIProvider apiProvider, GameHost gameHost)
+                    : base(storage, databaseContextFactory, rulesetStore, apiProvider, gameHost)
+                {
+                    this.testBeatmapManager = testBeatmapManager;
+                }
+
+                protected override ArchiveDownloadRequest<BeatmapSetInfo> CreateDownloadRequest(BeatmapSetInfo set, bool minimiseDownloadSize)
+                    => new TestDownloadRequest(set);
+
+                public override async Task<BeatmapSetInfo> 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/Beatmaps/BeatmapManager.cs b/osu.Game/Beatmaps/BeatmapManager.cs
index c445925a90..18513945e5 100644
--- a/osu.Game/Beatmaps/BeatmapManager.cs
+++ b/osu.Game/Beatmaps/BeatmapManager.cs
@@ -1,7 +1,27 @@
 // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
 // See the LICENCE file in the repository root for full licence text.
 
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Linq.Expressions;
+using System.Threading;
+using System.Threading.Tasks;
+using JetBrains.Annotations;
+using osu.Framework.Audio;
+using osu.Framework.Bindables;
+using osu.Framework.IO.Stores;
+using osu.Framework.Platform;
 using osu.Framework.Testing;
+using osu.Game.Database;
+using osu.Game.IO;
+using osu.Game.IO.Archives;
+using osu.Game.Online.API;
+using osu.Game.Overlays.Notifications;
+using osu.Game.Rulesets;
+using osu.Game.Skinning;
+using osu.Game.Users;
 
 namespace osu.Game.Beatmaps
 {
@@ -9,13 +29,282 @@ namespace osu.Game.Beatmaps
     /// Handles general operations related to global beatmap management.
     /// </summary>
     [ExcludeFromDynamicCompile]
-    public class BeatmapManager
+    public class BeatmapManager : IModelDownloader<BeatmapSetInfo>, IModelFileManager<BeatmapSetInfo, BeatmapSetFileInfo>, ICanAcceptFiles, IWorkingBeatmapCache
     {
-        public BeatmapManager()
+        private readonly BeatmapModelManager beatmapModelManager;
+        private readonly WorkingBeatmapCache workingBeatmapCache;
+
+        public BeatmapManager(Storage storage, IDatabaseContextFactory contextFactory, RulesetStore rulesets, IAPIProvider api, [NotNull] AudioManager audioManager, IResourceStore<byte[]> resources, GameHost host = null,
+                              WorkingBeatmap defaultBeatmap = null, bool performOnlineLookups = false)
         {
-            beatmapModelManager = new BeatmapModelManager()
+            beatmapModelManager = CreateBeatmapModelManager(storage, contextFactory, rulesets, api, host);
+            workingBeatmapCache = CreateWorkingBeatmapCache(audioManager, resources, new FileStore(contextFactory, storage).Store, defaultBeatmap, host);
+
+            workingBeatmapCache.BeatmapManager = beatmapModelManager;
+
+            var onlineBeatmapLookupCache = new BeatmapOnlineLookupQueue(api, storage);
+
+            beatmapModelManager.PopulateOnlineInformation = onlineBeatmapLookupCache.UpdateAsync;
         }
 
-    }
+        protected virtual WorkingBeatmapCache CreateWorkingBeatmapCache(AudioManager audioManager, IResourceStore<byte[]> resources, IResourceStore<byte[]> 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, api, host);
+
+        /// <summary>
+        /// Create a new <see cref="WorkingBeatmap"/>.
+        /// </summary>
+        public WorkingBeatmap CreateNew(RulesetInfo ruleset, User user)
+        {
+            var metadata = new BeatmapMetadata
+            {
+                Author = user,
+            };
+
+            var set = new BeatmapSetInfo
+            {
+                Metadata = metadata,
+                Beatmaps = new List<BeatmapInfo>
+                {
+                    new BeatmapInfo
+                    {
+                        BaseDifficulty = new BeatmapDifficulty(),
+                        Ruleset = ruleset,
+                        Metadata = metadata,
+                        WidescreenStoryboard = true,
+                        SamplesMatchPlaybackRate = true,
+                    }
+                }
+            };
+
+            var working = beatmapModelManager.Import(set).Result;
+            return GetWorkingBeatmap(working.Beatmaps.First());
+        }
+
+        #region Delegation to BeatmapModelManager (methods which previously existed locally).
+
+        /// <summary>
+        /// Fired when a single difficulty has been hidden.
+        /// </summary>
+        public IBindable<WeakReference<BeatmapInfo>> BeatmapHidden => beatmapModelManager.BeatmapHidden;
+
+        /// <summary>
+        /// Fired when a single difficulty has been restored.
+        /// </summary>
+        public IBindable<WeakReference<BeatmapInfo>> BeatmapRestored => beatmapModelManager.BeatmapRestored;
+
+        /// <summary>
+        /// Saves an <see cref="IBeatmap"/> file against a given <see cref="BeatmapInfo"/>.
+        /// </summary>
+        /// <param name="info">The <see cref="BeatmapInfo"/> to save the content against. The file referenced by <see cref="BeatmapInfo.Path"/> will be replaced.</param>
+        /// <param name="beatmapContent">The <see cref="IBeatmap"/> content to write.</param>
+        /// <param name="beatmapSkin">The beatmap <see cref="ISkin"/> content to write, null if to be omitted.</param>
+        public virtual void Save(BeatmapInfo info, IBeatmap beatmapContent, ISkin beatmapSkin = null) => beatmapModelManager.Save(info, beatmapContent, beatmapSkin);
+
+        /// <summary>
+        /// Returns a list of all usable <see cref="BeatmapSetInfo"/>s.
+        /// </summary>
+        /// <returns>A list of available <see cref="BeatmapSetInfo"/>.</returns>
+        public List<BeatmapSetInfo> GetAllUsableBeatmapSets(IncludedDetails includes = IncludedDetails.All, bool includeProtected = false) => beatmapModelManager.GetAllUsableBeatmapSets(includes, includeProtected);
+
+        /// <summary>
+        /// Returns a list of all usable <see cref="BeatmapSetInfo"/>s. Note that files are not populated.
+        /// </summary>
+        /// <param name="includes">The level of detail to include in the returned objects.</param>
+        /// <param name="includeProtected">Whether to include protected (system) beatmaps. These should not be included for gameplay playable use cases.</param>
+        /// <returns>A list of available <see cref="BeatmapSetInfo"/>.</returns>
+        public IEnumerable<BeatmapSetInfo> GetAllUsableBeatmapSetsEnumerable(IncludedDetails includes, bool includeProtected = false) => beatmapModelManager.GetAllUsableBeatmapSetsEnumerable(includes, includeProtected);
+
+        /// <summary>
+        /// Perform a lookup query on available <see cref="BeatmapSetInfo"/>s.
+        /// </summary>
+        /// <param name="query">The query.</param>
+        /// <param name="includes">The level of detail to include in the returned objects.</param>
+        /// <returns>Results from the provided query.</returns>
+        public IEnumerable<BeatmapSetInfo> QueryBeatmapSets(Expression<Func<BeatmapSetInfo, bool>> query, IncludedDetails includes = IncludedDetails.All) => beatmapModelManager.QueryBeatmapSets(query, includes);
+
+        /// <summary>
+        /// Perform a lookup query on available <see cref="BeatmapSetInfo"/>s.
+        /// </summary>
+        /// <param name="query">The query.</param>
+        /// <returns>The first result for the provided query, or null if no results were found.</returns>
+        public BeatmapSetInfo QueryBeatmapSet(Expression<Func<BeatmapSetInfo, bool>> query) => beatmapModelManager.QueryBeatmapSet(query);
+
+        /// <summary>
+        /// Perform a lookup query on available <see cref="BeatmapInfo"/>s.
+        /// </summary>
+        /// <param name="query">The query.</param>
+        /// <returns>Results from the provided query.</returns>
+        public IQueryable<BeatmapInfo> QueryBeatmaps(Expression<Func<BeatmapInfo, bool>> query) => beatmapModelManager.QueryBeatmaps(query);
+
+        /// <summary>
+        /// Perform a lookup query on available <see cref="BeatmapInfo"/>s.
+        /// </summary>
+        /// <param name="query">The query.</param>
+        /// <returns>The first result for the provided query, or null if no results were found.</returns>
+        public BeatmapInfo QueryBeatmap(Expression<Func<BeatmapInfo, bool>> query) => beatmapModelManager.QueryBeatmap(query);
+
+        /// <summary>
+        /// A default representation of a WorkingBeatmap to use when no beatmap is available.
+        /// </summary>
+        public WorkingBeatmap DefaultBeatmap => workingBeatmapCache.DefaultBeatmap;
+
+        /// <summary>
+        /// Fired when a notification should be presented to the user.
+        /// </summary>
+        public Action<Notification> PostNotification { set => beatmapModelManager.PostNotification = value; }
+
+        /// <summary>
+        /// Fired when the user requests to view the resulting import.
+        /// </summary>
+        public Action<IEnumerable<BeatmapSetInfo>> PresentImport { set => beatmapModelManager.PresentImport = value; }
+
+        /// <summary>
+        /// Delete a beatmap difficulty.
+        /// </summary>
+        /// <param name="beatmap">The beatmap difficulty to hide.</param>
+        public void Hide(BeatmapInfo beatmap) => beatmapModelManager.Hide(beatmap);
+
+        /// <summary>
+        /// Restore a beatmap difficulty.
+        /// </summary>
+        /// <param name="beatmap">The beatmap difficulty to restore.</param>
+        public void Restore(BeatmapInfo beatmap) => beatmapModelManager.Restore(beatmap);
+
+        #endregion
+
+        #region Implementation of IModelManager<BeatmapSetInfo>
+
+        public IBindable<WeakReference<BeatmapSetInfo>> ItemUpdated => beatmapModelManager.ItemUpdated;
+
+        public IBindable<WeakReference<BeatmapSetInfo>> ItemRemoved => beatmapModelManager.ItemRemoved;
+
+        public Task ImportFromStableAsync(StableStorage stableStorage)
+        {
+            return beatmapModelManager.ImportFromStableAsync(stableStorage);
+        }
+
+        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<BeatmapSetInfo> items, bool silent = false)
+        {
+            beatmapModelManager.Delete(items, silent);
+        }
+
+        public void Undelete(List<BeatmapSetInfo> items, bool silent = false)
+        {
+            beatmapModelManager.Undelete(items, silent);
+        }
+
+        public void Undelete(BeatmapSetInfo item)
+        {
+            beatmapModelManager.Undelete(item);
+        }
+
+        #endregion
+
+        #region Implementation of IModelDownloader<BeatmapSetInfo>
+
+        public IBindable<WeakReference<ArchiveDownloadRequest<BeatmapSetInfo>>> DownloadBegan => beatmapModelManager.DownloadBegan;
+
+        public IBindable<WeakReference<ArchiveDownloadRequest<BeatmapSetInfo>>> DownloadFailed => beatmapModelManager.DownloadFailed;
+
+        public bool IsAvailableLocally(BeatmapSetInfo model)
+        {
+            return beatmapModelManager.IsAvailableLocally(model);
+        }
+
+        public bool Download(BeatmapSetInfo model, bool minimiseDownloadSize = false)
+        {
+            return beatmapModelManager.Download(model, minimiseDownloadSize);
+        }
+
+        public ArchiveDownloadRequest<BeatmapSetInfo> GetExistingDownload(BeatmapSetInfo model)
+        {
+            return beatmapModelManager.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<IEnumerable<BeatmapSetInfo>> Import(ProgressNotification notification, params ImportTask[] tasks)
+        {
+            return beatmapModelManager.Import(notification, tasks);
+        }
+
+        public Task<BeatmapSetInfo> Import(ImportTask task, bool lowPriority = false, CancellationToken cancellationToken = default)
+        {
+            return beatmapModelManager.Import(task, lowPriority, cancellationToken);
+        }
+
+        public Task<BeatmapSetInfo> Import(ArchiveReader archive, bool lowPriority = false, CancellationToken cancellationToken = default)
+        {
+            return beatmapModelManager.Import(archive, lowPriority, cancellationToken);
+        }
+
+        public Task<BeatmapSetInfo> Import(BeatmapSetInfo item, ArchiveReader archive = null, bool lowPriority = false, CancellationToken cancellationToken = default)
+        {
+            return beatmapModelManager.Import(item, archive, lowPriority, cancellationToken);
+        }
+
+        public IEnumerable<string> HandledExtensions => beatmapModelManager.HandledExtensions;
+
+        #endregion
+
+        #region Implementation of IWorkingBeatmapCache
+
+        public WorkingBeatmap GetWorkingBeatmap(BeatmapInfo importedBeatmap) => workingBeatmapCache.GetWorkingBeatmap(importedBeatmap);
+
+        #endregion
+
+        #region Implementation of IModelFileManager<in BeatmapSetInfo,in BeatmapSetFileInfo>
+
+        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
+    }
 }
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 <contact@ppy.sh>. Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+namespace osu.Game.Beatmaps
+{
+    public interface IWorkingBeatmapCache
+    {
+        /// <summary>
+        /// Retrieve a <see cref="WorkingBeatmap"/> instance for the provided <see cref="BeatmapInfo"/>
+        /// </summary>
+        /// <param name="beatmapInfo">The beatmap to lookup.</param>
+        /// <returns>A <see cref="WorkingBeatmap"/> instance correlating to the provided <see cref="BeatmapInfo"/>.</returns>
+        WorkingBeatmap GetWorkingBeatmap(BeatmapInfo beatmapInfo);
+    }
+}
diff --git a/osu.Game/Beatmaps/WorkingBeatmapCache.cs b/osu.Game/Beatmaps/WorkingBeatmapCache.cs
index 9f40eb4898..e117f1b82f 100644
--- a/osu.Game/Beatmaps/WorkingBeatmapCache.cs
+++ b/osu.Game/Beatmaps/WorkingBeatmapCache.cs
@@ -2,7 +2,6 @@
 // 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 JetBrains.Annotations;
@@ -17,14 +16,12 @@ using osu.Framework.Statistics;
 using osu.Framework.Testing;
 using osu.Game.Beatmaps.Formats;
 using osu.Game.IO;
-using osu.Game.Rulesets;
 using osu.Game.Skinning;
 using osu.Game.Storyboards;
-using osu.Game.Users;
 
 namespace osu.Game.Beatmaps
 {
-    public class WorkingBeatmapCache : IBeatmapResourceProvider
+    public class WorkingBeatmapCache : IBeatmapResourceProvider, IWorkingBeatmapCache
     {
         private readonly WeakList<BeatmapManagerWorkingBeatmap> workingCache = new WeakList<BeatmapManagerWorkingBeatmap>();
 
@@ -33,7 +30,7 @@ namespace osu.Game.Beatmaps
         /// </summary>
         public readonly WorkingBeatmap DefaultBeatmap;
 
-        public BeatmapManager BeatmapManager { private get; set; }
+        public BeatmapModelManager BeatmapManager { private get; set; }
 
         private readonly AudioManager audioManager;
         private readonly IResourceStore<byte[]> resources;
@@ -74,41 +71,6 @@ namespace osu.Game.Beatmaps
             }
         }
 
-        /// <summary>
-        /// Create a new <see cref="WorkingBeatmap"/>.
-        /// </summary>
-        public WorkingBeatmap CreateNew(RulesetInfo ruleset, User user)
-        {
-            var metadata = new BeatmapMetadata
-            {
-                Author = user,
-            };
-
-            var set = new BeatmapSetInfo
-            {
-                Metadata = metadata,
-                Beatmaps = new List<BeatmapInfo>
-                {
-                    new BeatmapInfo
-                    {
-                        BaseDifficulty = new BeatmapDifficulty(),
-                        Ruleset = ruleset,
-                        Metadata = metadata,
-                        WidescreenStoryboard = true,
-                        SamplesMatchPlaybackRate = true,
-                    }
-                }
-            };
-
-            var working = BeatmapManager.Import(set).Result;
-            return GetWorkingBeatmap(working.Beatmaps.First());
-        }
-
-        /// <summary>
-        /// Retrieve a <see cref="WorkingBeatmap"/> instance for the provided <see cref="BeatmapInfo"/>
-        /// </summary>
-        /// <param name="beatmapInfo">The beatmap to lookup.</param>
-        /// <returns>A <see cref="WorkingBeatmap"/> instance correlating to the provided <see cref="BeatmapInfo"/>.</returns>
         public virtual WorkingBeatmap GetWorkingBeatmap(BeatmapInfo beatmapInfo)
         {
             // if there are no files, presume the full beatmap info has not yet been fetched from the database.
diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs
index 8263e26dec..dc1cb7a850 100644
--- a/osu.Game/OsuGameBase.cs
+++ b/osu.Game/OsuGameBase.cs
@@ -138,8 +138,6 @@ namespace osu.Game
 
         private UserLookupCache userCache;
 
-        private BeatmapOnlineLookupQueue onlineBeatmapLookupCache;
-
         private FileStore fileStore;
 
         private RulesetConfigCache rulesetConfigCache;
@@ -246,10 +244,6 @@ namespace osu.Game
             dependencies.Cache(ScoreManager = new ScoreManager(RulesetStore, () => BeatmapManager, Storage, API, contextFactory, Scheduler, Host, () => difficultyCache, LocalConfig));
             dependencies.Cache(BeatmapManager = new BeatmapManager(Storage, contextFactory, RulesetStore, API, Audio, Resources, Host, defaultBeatmap));
 
-            onlineBeatmapLookupCache = new BeatmapOnlineLookupQueue(API, Storage);
-
-            BeatmapManager.PopulateOnlineInformation = onlineBeatmapLookupCache.UpdateAsync;
-
             // this should likely be moved to ArchiveModelManager when another case appears where it is necessary
             // to have inter-dependent model managers. this could be obtained with an IHasForeign<T> interface to
             // allow lookups to be done on the child (ScoreManager in this case) to perform the cascading delete.
diff --git a/osu.Game/Tests/Visual/EditorTestScene.cs b/osu.Game/Tests/Visual/EditorTestScene.cs
index 1e26036116..ac8773a840 100644
--- a/osu.Game/Tests/Visual/EditorTestScene.cs
+++ b/osu.Game/Tests/Visual/EditorTestScene.cs
@@ -123,11 +123,40 @@ namespace osu.Game.Tests.Visual
                 this.testBeatmap = testBeatmap;
             }
 
-            protected override string ComputeHash(BeatmapSetInfo item, ArchiveReader reader = null)
-                => string.Empty;
+            protected override BeatmapModelManager CreateBeatmapModelManager(Storage storage, IDatabaseContextFactory contextFactory, RulesetStore rulesets, IAPIProvider api, GameHost host)
+            {
+                return new TestBeatmapModelManager(storage, contextFactory, rulesets, api, host);
+            }
 
-            public override WorkingBeatmap GetWorkingBeatmap(BeatmapInfo beatmapInfo)
-                => testBeatmap;
+            protected override WorkingBeatmapCache CreateWorkingBeatmapCache(AudioManager audioManager, IResourceStore<byte[]> resources, IResourceStore<byte[]> storage, WorkingBeatmap defaultBeatmap, GameHost host)
+            {
+                return new TestWorkingBeatmapCache(this, audioManager, resources, storage, defaultBeatmap, host);
+            }
+
+            private class TestWorkingBeatmapCache : WorkingBeatmapCache
+            {
+                private readonly TestBeatmapManager testBeatmapManager;
+
+                public TestWorkingBeatmapCache(TestBeatmapManager testBeatmapManager, AudioManager audioManager, IResourceStore<byte[]> resourceStore, IResourceStore<byte[]> storage, WorkingBeatmap defaultBeatmap, GameHost gameHost)
+                    : base(audioManager, resourceStore, storage, defaultBeatmap, gameHost)
+                {
+                    this.testBeatmapManager = testBeatmapManager;
+                }
+
+                public override WorkingBeatmap GetWorkingBeatmap(BeatmapInfo beatmapInfo)
+                    => testBeatmapManager.testBeatmap;
+            }
+
+            internal class TestBeatmapModelManager : BeatmapModelManager
+            {
+                public TestBeatmapModelManager(Storage storage, IDatabaseContextFactory databaseContextFactory, RulesetStore rulesetStore, IAPIProvider apiProvider, GameHost gameHost)
+                    : base(storage, databaseContextFactory, rulesetStore, apiProvider, gameHost)
+                {
+                }
+
+                protected override string ComputeHash(BeatmapSetInfo item, ArchiveReader reader = null)
+                    => string.Empty;
+            }
 
             public override void Save(BeatmapInfo info, IBeatmap beatmapContent, ISkin beatmapSkin = null)
             {

From 7a72747d886cc95e70c4abc94867527f2a6002e8 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Thu, 30 Sep 2021 17:14:35 +0900
Subject: [PATCH 5/7] Add back optional online lookups

---
 osu.Game/Beatmaps/BeatmapManager.cs | 8 +++++---
 osu.Game/OsuGameBase.cs             | 2 +-
 2 files changed, 6 insertions(+), 4 deletions(-)

diff --git a/osu.Game/Beatmaps/BeatmapManager.cs b/osu.Game/Beatmaps/BeatmapManager.cs
index 18513945e5..6ffdfa24b5 100644
--- a/osu.Game/Beatmaps/BeatmapManager.cs
+++ b/osu.Game/Beatmaps/BeatmapManager.cs
@@ -42,9 +42,11 @@ namespace osu.Game.Beatmaps
 
             workingBeatmapCache.BeatmapManager = beatmapModelManager;
 
-            var onlineBeatmapLookupCache = new BeatmapOnlineLookupQueue(api, storage);
-
-            beatmapModelManager.PopulateOnlineInformation = onlineBeatmapLookupCache.UpdateAsync;
+            if (performOnlineLookups)
+            {
+                var onlineBeatmapLookupCache = new BeatmapOnlineLookupQueue(api, storage);
+                beatmapModelManager.PopulateOnlineInformation = onlineBeatmapLookupCache.UpdateAsync;
+            }
         }
 
         protected virtual WorkingBeatmapCache CreateWorkingBeatmapCache(AudioManager audioManager, IResourceStore<byte[]> resources, IResourceStore<byte[]> storage, WorkingBeatmap defaultBeatmap, GameHost host) =>
diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs
index dc1cb7a850..e76436a75b 100644
--- a/osu.Game/OsuGameBase.cs
+++ b/osu.Game/OsuGameBase.cs
@@ -242,7 +242,7 @@ namespace osu.Game
 
             // ordering is important here to ensure foreign keys rules are not broken in ModelStore.Cleanup()
             dependencies.Cache(ScoreManager = new ScoreManager(RulesetStore, () => BeatmapManager, Storage, API, contextFactory, Scheduler, Host, () => difficultyCache, LocalConfig));
-            dependencies.Cache(BeatmapManager = new BeatmapManager(Storage, contextFactory, RulesetStore, API, Audio, Resources, Host, defaultBeatmap));
+            dependencies.Cache(BeatmapManager = new BeatmapManager(Storage, contextFactory, RulesetStore, API, Audio, Resources, Host, defaultBeatmap, performOnlineLookups: true));
 
             // this should likely be moved to ArchiveModelManager when another case appears where it is necessary
             // to have inter-dependent model managers. this could be obtained with an IHasForeign<T> interface to

From fd13142a158b50f89caab9a516617137970e3388 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Thu, 30 Sep 2021 18:20:20 +0900
Subject: [PATCH 6/7] Add missing interface to `BeatmapManager`

---
 osu.Game/Beatmaps/BeatmapManager.cs | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/osu.Game/Beatmaps/BeatmapManager.cs b/osu.Game/Beatmaps/BeatmapManager.cs
index 6ffdfa24b5..c72d1e8dec 100644
--- a/osu.Game/Beatmaps/BeatmapManager.cs
+++ b/osu.Game/Beatmaps/BeatmapManager.cs
@@ -29,7 +29,7 @@ namespace osu.Game.Beatmaps
     /// Handles general operations related to global beatmap management.
     /// </summary>
     [ExcludeFromDynamicCompile]
-    public class BeatmapManager : IModelDownloader<BeatmapSetInfo>, IModelFileManager<BeatmapSetInfo, BeatmapSetFileInfo>, ICanAcceptFiles, IWorkingBeatmapCache
+    public class BeatmapManager : IModelDownloader<BeatmapSetInfo>, IModelManager<BeatmapSetInfo>, IModelFileManager<BeatmapSetInfo, BeatmapSetFileInfo>, ICanAcceptFiles, IWorkingBeatmapCache
     {
         private readonly BeatmapModelManager beatmapModelManager;
         private readonly WorkingBeatmapCache workingBeatmapCache;

From 428c7830d958d731398bcb18193160461418a670 Mon Sep 17 00:00:00 2001
From: Dean Herbert <pe@ppy.sh>
Date: Fri, 1 Oct 2021 01:43:57 +0900
Subject: [PATCH 7/7] Pass online lookup queue in as a whole, rather than
 function

---
 osu.Game/Beatmaps/BeatmapManager.cs      | 16 +++++++++++++---
 osu.Game/Beatmaps/BeatmapModelManager.cs |  9 ++++-----
 2 files changed, 17 insertions(+), 8 deletions(-)

diff --git a/osu.Game/Beatmaps/BeatmapManager.cs b/osu.Game/Beatmaps/BeatmapManager.cs
index c72d1e8dec..1946e3f93f 100644
--- a/osu.Game/Beatmaps/BeatmapManager.cs
+++ b/osu.Game/Beatmaps/BeatmapManager.cs
@@ -29,10 +29,11 @@ namespace osu.Game.Beatmaps
     /// Handles general operations related to global beatmap management.
     /// </summary>
     [ExcludeFromDynamicCompile]
-    public class BeatmapManager : IModelDownloader<BeatmapSetInfo>, IModelManager<BeatmapSetInfo>, IModelFileManager<BeatmapSetInfo, BeatmapSetFileInfo>, ICanAcceptFiles, IWorkingBeatmapCache
+    public class BeatmapManager : IModelDownloader<BeatmapSetInfo>, IModelManager<BeatmapSetInfo>, IModelFileManager<BeatmapSetInfo, BeatmapSetFileInfo>, ICanAcceptFiles, IWorkingBeatmapCache, IDisposable
     {
         private readonly BeatmapModelManager beatmapModelManager;
         private readonly WorkingBeatmapCache workingBeatmapCache;
+        private readonly BeatmapOnlineLookupQueue onlineBetamapLookupQueue;
 
         public BeatmapManager(Storage storage, IDatabaseContextFactory contextFactory, RulesetStore rulesets, IAPIProvider api, [NotNull] AudioManager audioManager, IResourceStore<byte[]> resources, GameHost host = null,
                               WorkingBeatmap defaultBeatmap = null, bool performOnlineLookups = false)
@@ -44,8 +45,8 @@ namespace osu.Game.Beatmaps
 
             if (performOnlineLookups)
             {
-                var onlineBeatmapLookupCache = new BeatmapOnlineLookupQueue(api, storage);
-                beatmapModelManager.PopulateOnlineInformation = onlineBeatmapLookupCache.UpdateAsync;
+                onlineBetamapLookupQueue = new BeatmapOnlineLookupQueue(api, storage);
+                beatmapModelManager.OnlineLookupQueue = onlineBetamapLookupQueue;
             }
         }
 
@@ -308,5 +309,14 @@ namespace osu.Game.Beatmaps
         }
 
         #endregion
+
+        #region Implementation of IDisposable
+
+        public void Dispose()
+        {
+            onlineBetamapLookupQueue?.Dispose();
+        }
+
+        #endregion
     }
 }
diff --git a/osu.Game/Beatmaps/BeatmapModelManager.cs b/osu.Game/Beatmaps/BeatmapModelManager.cs
index be3adc412c..72df1f37ee 100644
--- a/osu.Game/Beatmaps/BeatmapModelManager.cs
+++ b/osu.Game/Beatmaps/BeatmapModelManager.cs
@@ -49,10 +49,9 @@ namespace osu.Game.Beatmaps
         public IBindable<WeakReference<BeatmapInfo>> BeatmapRestored => beatmapRestored;
 
         /// <summary>
-        /// A function which populates online information during the import process.
-        /// It is run as the final step of import.
+        /// An online lookup queue component which handles populating online beatmap metadata.
         /// </summary>
-        public Func<BeatmapSetInfo, CancellationToken, Task> PopulateOnlineInformation;
+        public BeatmapOnlineLookupQueue OnlineLookupQueue { private get; set; }
 
         /// <summary>
         /// The game working beatmap cache, used to invalidate entries on changes.
@@ -107,8 +106,8 @@ namespace osu.Game.Beatmaps
 
             bool hadOnlineBeatmapIDs = beatmapSet.Beatmaps.Any(b => b.OnlineBeatmapID > 0);
 
-            if (PopulateOnlineInformation != null)
-                await PopulateOnlineInformation(beatmapSet, cancellationToken).ConfigureAwait(false);
+            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))