diff --git a/osu.Game.Tests/Visual/Online/TestSceneReplayMissingBeatmap.cs b/osu.Game.Tests/Visual/Online/TestSceneReplayMissingBeatmap.cs new file mode 100644 index 0000000000..a260cc8593 --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneReplayMissingBeatmap.cs @@ -0,0 +1,96 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Net; +using NUnit.Framework; +using osu.Game.Database; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Screens.Import; +using osu.Game.Tests.Resources; + +namespace osu.Game.Tests.Visual.Online +{ + public partial class TestSceneReplayMissingBeatmap : OsuGameTestScene + { + private DummyAPIAccess dummyAPI => (DummyAPIAccess)API; + + [Test] + public void TestSceneMissingBeatmapWithOnlineAvailable() + { + var beatmap = new APIBeatmap + { + OnlineBeatmapSetID = 173612 + }; + + var beatmapset = new APIBeatmapSet + { + OnlineID = 173612, + }; + + setupBeatmapResponse(beatmap, beatmapset); + + AddStep("import score", () => + { + using (var resourceStream = TestResources.OpenResource("Replays/mania-replay.osr")) + { + var importTask = new ImportTask(resourceStream, "replay.osr"); + + Game.ScoreManager.Import(new[] { importTask }); + } + }); + + AddUntilStep("Replay missing screen show", () => Game.ScreenStack.CurrentScreen.GetType() == typeof(ReplayMissingBeatmapScreen)); + } + + [Test] + public void TestSceneMissingBeatmapWithOnlineUnavailable() + { + setupFailedResponse(); + + AddStep("import score", () => + { + using (var resourceStream = TestResources.OpenResource("Replays/mania-replay.osr")) + { + var importTask = new ImportTask(resourceStream, "replay.osr"); + + Game.ScoreManager.Import(new[] { importTask }); + } + }); + + AddUntilStep("Replay missing screen not show", () => Game.ScreenStack.CurrentScreen.GetType() != typeof(ReplayMissingBeatmapScreen)); + } + + private void setupBeatmapResponse(APIBeatmap b, APIBeatmapSet s) + => AddStep("setup response", () => + { + dummyAPI.HandleRequest = request => + { + if (request is GetBeatmapRequest getBeatmapRequest) + { + getBeatmapRequest.TriggerSuccess(b); + return true; + } + + if (request is GetBeatmapSetRequest getBeatmapSetRequest) + { + getBeatmapSetRequest.TriggerSuccess(s); + return true; + } + + return false; + }; + }); + + private void setupFailedResponse() + => AddStep("setup failed response", () => + { + dummyAPI.HandleRequest = request => + { + request.TriggerFailure(new WebException()); + return true; + }; + }); + } +} diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index 1a40bb8e3d..50b4e4f125 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -854,6 +854,8 @@ namespace osu.Game MultiplayerClient.PostNotification = n => Notifications.Post(n); + ScoreManager.Performer = this; + // make config aware of how to lookup skins for on-screen display purposes. // if this becomes a more common thing, tracked settings should be reconsidered to allow local DI. LocalConfig.LookupSkinName = id => SkinManager.Query(s => s.ID == id)?.ToString() ?? "Unknown"; diff --git a/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs b/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs index 8c00110909..a191220b37 100644 --- a/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs +++ b/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs @@ -7,6 +7,7 @@ using System; using System.Diagnostics; using System.IO; using System.Linq; +using JetBrains.Annotations; using Newtonsoft.Json; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Formats; @@ -61,7 +62,7 @@ namespace osu.Game.Scoring.Legacy workingBeatmap = GetBeatmap(beatmapHash); if (workingBeatmap is DummyWorkingBeatmap) - throw new BeatmapNotFoundException(beatmapHash); + throw new BeatmapNotFoundException(beatmapHash, stream); scoreInfo.User = new APIUser { Username = sr.ReadString() }; @@ -349,9 +350,19 @@ namespace osu.Game.Scoring.Legacy { public string Hash { get; } - public BeatmapNotFoundException(string hash) + [CanBeNull] + public MemoryStream ScoreStream { get; } + + public BeatmapNotFoundException(string hash, [CanBeNull] Stream scoreStream) { Hash = hash; + + if (scoreStream != null) + { + ScoreStream = new MemoryStream(); + scoreStream.Position = 0; + scoreStream.CopyTo(ScoreStream); + } } } } diff --git a/osu.Game/Scoring/ScoreImporter.cs b/osu.Game/Scoring/ScoreImporter.cs index 81b9f57bbc..408c83a5b8 100644 --- a/osu.Game/Scoring/ScoreImporter.cs +++ b/osu.Game/Scoring/ScoreImporter.cs @@ -9,6 +9,7 @@ using System.Threading; using Newtonsoft.Json; using osu.Framework.Logging; using osu.Framework.Platform; +using osu.Framework.Screens; using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.IO.Archives; @@ -19,6 +20,8 @@ using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; +using osu.Game.Screens; +using osu.Game.Screens.Import; using Realms; namespace osu.Game.Scoring @@ -27,6 +30,8 @@ namespace osu.Game.Scoring { public override IEnumerable HandledExtensions => new[] { ".osr" }; + public IPerformFromScreenRunner? Performer { get; set; } + protected override string[] HashableFileTypes => new[] { ".osr" }; private readonly RulesetStore rulesets; @@ -54,12 +59,28 @@ namespace osu.Game.Scoring } catch (LegacyScoreDecoder.BeatmapNotFoundException e) { + onMissingBeatmap(e); Logger.Log($@"Score '{name}' failed to import: no corresponding beatmap with the hash '{e.Hash}' could be found.", LoggingTarget.Database); return null; } } } + private void onMissingBeatmap(LegacyScoreDecoder.BeatmapNotFoundException e) + { + var req = new GetBeatmapRequest(new BeatmapInfo + { + MD5Hash = e.Hash + }); + + req.Success += res => + { + Performer?.PerformFromScreen(screen => screen.Push(new ReplayMissingBeatmapScreen(res, e.ScoreStream))); + }; + + api.Queue(req); + } + public Score GetScore(ScoreInfo score) => new LegacyDatabasedScore(score, rulesets, beatmaps(), Files.Store); protected override void Populate(ScoreInfo model, ArchiveReader? archive, Realm realm, CancellationToken cancellationToken = default) diff --git a/osu.Game/Scoring/ScoreManager.cs b/osu.Game/Scoring/ScoreManager.cs index 31b5bd8365..9331168ab0 100644 --- a/osu.Game/Scoring/ScoreManager.cs +++ b/osu.Game/Scoring/ScoreManager.cs @@ -21,6 +21,7 @@ using osu.Game.Rulesets; using osu.Game.Rulesets.Scoring; using osu.Game.Online.API; using osu.Game.Scoring.Legacy; +using osu.Game.Screens; namespace osu.Game.Scoring { @@ -30,6 +31,12 @@ namespace osu.Game.Scoring private readonly ScoreImporter scoreImporter; private readonly LegacyScoreExporter scoreExporter; + [CanBeNull] + public IPerformFromScreenRunner Performer + { + set => scoreImporter.Performer = value; + } + public override bool PauseImports { get => base.PauseImports; diff --git a/osu.Game/Screens/Import/ReplayMissingBeatmapScreen.cs b/osu.Game/Screens/Import/ReplayMissingBeatmapScreen.cs new file mode 100644 index 0000000000..d7decc0e4e --- /dev/null +++ b/osu.Game/Screens/Import/ReplayMissingBeatmapScreen.cs @@ -0,0 +1,210 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Audio; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Drawables.Cards; +using osu.Game.Configuration; +using osu.Game.Database; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays; +using osu.Game.Overlays.Settings; +using osu.Game.Scoring; +using osu.Game.Screens.Ranking; +using osuTK; +using Realms; + +namespace osu.Game.Screens.Import +{ + [Cached(typeof(IPreviewTrackOwner))] + public partial class ReplayMissingBeatmapScreen : OsuScreen, IPreviewTrackOwner + { + [Resolved] + private BeatmapModelDownloader beatmapDownloader { get; set; } = null!; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [Resolved] + private ScoreManager scoreManager { get; set; } = null!; + + [Resolved] + private RealmAccess realm { get; set; } = null!; + + private IDisposable? realmSubscription; + + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); + + [Resolved] + private INotificationOverlay? notificationOverlay { get; set; } + + private Container beatmapPanelContainer = null!; + private ReplayDownloadButton replayDownloadButton = null!; + private SettingsCheckbox automaticDownload = null!; + + private readonly MemoryStream? scoreStream; + private readonly APIBeatmap beatmap; + + private APIBeatmapSet? beatmapSetInfo; + + public ReplayMissingBeatmapScreen(APIBeatmap beatmap, MemoryStream? scoreStream = null) + { + this.beatmap = beatmap; + this.scoreStream = scoreStream; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours, OsuConfigManager config) + { + InternalChildren = new Drawable[] + { + new Container + { + Masking = true, + CornerRadius = 20, + AutoSizeAxes = Axes.Both, + AutoSizeDuration = 500, + AutoSizeEasing = Easing.OutQuint, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new Drawable[] + { + new Box + { + Colour = colours.Gray5, + RelativeSizeAxes = Axes.Both, + }, + new FillFlowContainer + { + Margin = new MarginPadding(20), + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Spacing = new Vector2(15), + Children = new Drawable[] + { + new OsuSpriteText + { + Text = "Beatmap info", + Font = OsuFont.Default.With(size: 30), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Spacing = new Vector2(15), + Children = new Drawable[] + { + beatmapPanelContainer = new Container + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + } + }, + automaticDownload = new SettingsCheckbox + { + LabelText = "Automatically download beatmaps", + Current = config.GetBindable(OsuSetting.AutomaticallyDownloadWhenSpectating), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + replayDownloadButton = new ReplayDownloadButton(new ScoreInfo()) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + } + } + } + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + var onlineBeatmapRequest = new GetBeatmapSetRequest(beatmap.OnlineBeatmapSetID); + + onlineBeatmapRequest.Success += res => + { + beatmapSetInfo = res; + beatmapPanelContainer.Child = new BeatmapCardNormal(res, allowExpansion: false); + checkForAutomaticDownload(res); + }; + + api.Queue(onlineBeatmapRequest); + + realmSubscription = realm.RegisterForNotifications( + realm => realm.All().Where(s => !s.DeletePending), beatmapsChanged); + } + + private void checkForAutomaticDownload(APIBeatmapSet beatmap) + { + if (!automaticDownload.Current.Value) + return; + + beatmapDownloader.Download(beatmap); + } + + private void beatmapsChanged(IRealmCollection sender, ChangeSet? changes) + { + if (changes?.InsertedIndices == null) return; + + if (beatmapSetInfo == null) return; + + if (scoreStream == null) return; + + if (sender.Any(b => b.OnlineID == beatmapSetInfo.OnlineID)) + { + var progressNotification = new ImportProgressNotification(); + var importTask = new ImportTask(scoreStream, "score.osr"); + + scoreManager.Import(progressNotification, new[] { importTask }) + .ContinueWith(s => + { + s.GetResultSafely>>().FirstOrDefault()?.PerformRead(score => + { + Guid scoreid = score.ID; + Scheduler.Add(() => + { + replayDownloadButton.Score.Value = realm.Realm.Find(scoreid) ?? new ScoreInfo(); + }); + }); + }); + + notificationOverlay?.Post(progressNotification); + + realmSubscription?.Dispose(); + } + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + realmSubscription?.Dispose(); + } + } +}