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))