1
0
mirror of https://github.com/ppy/osu.git synced 2025-02-23 18:23:17 +08:00

Add beatmap submission support

This commit is contained in:
Bartłomiej Dach 2025-02-05 12:22:33 +01:00
parent fff99a8b40
commit 78e85dc2c7
No known key found for this signature in database
6 changed files with 544 additions and 11 deletions

View File

@ -39,6 +39,31 @@ namespace osu.Game.Localisation
/// </summary>
public static LocalisableString SubmissionSettings => new TranslatableString(getKey(@"submission_settings"), @"Submission settings");
/// <summary>
/// "Submit beatmap!"
/// </summary>
public static LocalisableString ConfirmSubmission => new TranslatableString(getKey(@"confirm_submission"), @"Submit beatmap!");
/// <summary>
/// "Exporting beatmap set in compatibility mode..."
/// </summary>
public static LocalisableString ExportingBeatmapSet => new TranslatableString(getKey(@"exporting_beatmap_set"), @"Exporting beatmap set in compatibility mode...");
/// <summary>
/// "Preparing beatmap set online..."
/// </summary>
public static LocalisableString PreparingBeatmapSet => new TranslatableString(getKey(@"preparing_beatmap_set"), @"Preparing beatmap set online...");
/// <summary>
/// "Uploading beatmap set contents..."
/// </summary>
public static LocalisableString UploadingBeatmapSetContents => new TranslatableString(getKey(@"uploading_beatmap_set_contents"), @"Uploading beatmap set contents...");
/// <summary>
/// "Updating local beatmap with relevant changes..."
/// </summary>
public static LocalisableString UpdatingLocalBeatmap => new TranslatableString(getKey(@"updating_local_beatmap"), @"Updating local beatmap with relevant changes...");
/// <summary>
/// "Before you continue, we ask you to check whether the content you are uploading has been cleared for upload. Please understand that you are responsible for the content you upload to the platform and if in doubt, should ask permission from the creators before uploading!"
/// </summary>
@ -119,6 +144,21 @@ namespace osu.Game.Localisation
/// </summary>
public static LocalisableString LegacyExportDisclaimer => new TranslatableString(getKey(@"legacy_export_disclaimer"), @"Note: In order to make it possible for users of all osu! versions to enjoy your beatmap, it will be exported in a backwards-compatible format. While we have made efforts to ensure that that process keeps the beatmap playable in its intended form, some data related to features that previous versions of osu! do not support may be lost.");
/// <summary>
/// "Empty beatmaps cannot be submitted."
/// </summary>
public static LocalisableString EmptyBeatmapsCannotBeSubmitted => new TranslatableString(getKey(@"empty_beatmaps_cannot_be_submitted"), @"Empty beatmaps cannot be submitted.");
/// <summary>
/// "Update beatmap!"
/// </summary>
public static LocalisableString UpdateBeatmap => new TranslatableString(getKey(@"update_beatmap"), @"Update beatmap!");
/// <summary>
/// "Upload NEW beatmap!"
/// </summary>
public static LocalisableString UploadNewBeatmap => new TranslatableString(getKey(@"upload_new_beatmap"), @"Upload NEW beatmap!");
private static string getKey(string key) => $@"{prefix}:{key}";
}
}

View File

@ -69,6 +69,16 @@ namespace osu.Game.Localisation
/// </summary>
public static LocalisableString DeleteDifficulty => new TranslatableString(getKey(@"delete_difficulty"), @"Delete difficulty");
/// <summary>
/// "Edit externally"
/// </summary>
public static LocalisableString EditExternally => new TranslatableString(getKey(@"edit_externally"), @"Edit externally");
/// <summary>
/// "Submit beatmap"
/// </summary>
public static LocalisableString SubmitBeatmap => new TranslatableString(getKey(@"submit_beatmap"), @"Submit beatmap");
/// <summary>
/// "setup"
/// </summary>

View File

@ -32,6 +32,7 @@ using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Configuration;
using osu.Game.Database;
using osu.Game.Extensions;
using osu.Game.Graphics.Cursor;
using osu.Game.Graphics.UserInterface;
using osu.Game.Input.Bindings;
@ -52,6 +53,7 @@ using osu.Game.Screens.Edit.Compose.Components.Timeline;
using osu.Game.Screens.Edit.Design;
using osu.Game.Screens.Edit.GameplayTest;
using osu.Game.Screens.Edit.Setup;
using osu.Game.Screens.Edit.Submission;
using osu.Game.Screens.Edit.Timing;
using osu.Game.Screens.Edit.Verify;
using osu.Game.Screens.OnlinePlay;
@ -111,6 +113,10 @@ namespace osu.Game.Screens.Edit
[Resolved(canBeNull: true)]
private INotificationOverlay notifications { get; set; }
[Resolved(canBeNull: true)]
[CanBeNull]
private LoginOverlay loginOverlay { get; set; }
[Resolved]
private RealmAccess realm { get; set; }
@ -1309,11 +1315,22 @@ namespace osu.Game.Screens.Edit
if (RuntimeInfo.IsDesktop)
{
var externalEdit = new EditorMenuItem("Edit externally", MenuItemType.Standard, editExternally);
var externalEdit = new EditorMenuItem(EditorStrings.EditExternally, MenuItemType.Standard, editExternally);
saveRelatedMenuItems.Add(externalEdit);
yield return externalEdit;
}
bool isSetMadeOfLegacyRulesetBeatmaps = (isNewBeatmap && Ruleset.Value.IsLegacyRuleset())
|| (!isNewBeatmap && Beatmap.Value.BeatmapSetInfo.Beatmaps.All(b => b.Ruleset.IsLegacyRuleset()));
bool submissionAvailable = api.EndpointConfiguration.BeatmapSubmissionServiceUrl != null;
if (isSetMadeOfLegacyRulesetBeatmaps && submissionAvailable)
{
var upload = new EditorMenuItem(EditorStrings.SubmitBeatmap, MenuItemType.Standard, submitBeatmap);
saveRelatedMenuItems.Add(upload);
yield return upload;
}
yield return new OsuMenuItemSpacer();
yield return new EditorMenuItem(CommonStrings.Exit, MenuItemType.Standard, this.Exit);
}
@ -1353,6 +1370,42 @@ namespace osu.Game.Screens.Edit
}
}
private void submitBeatmap()
{
if (api.State.Value != APIState.Online)
{
loginOverlay?.Show();
return;
}
if (!editorBeatmap.HitObjects.Any())
{
notifications?.Post(new SimpleNotification
{
Text = BeatmapSubmissionStrings.EmptyBeatmapsCannotBeSubmitted,
});
return;
}
if (HasUnsavedChanges)
{
dialogOverlay.Push(new SaveRequiredPopupDialog(() => attemptMutationOperation(() =>
{
if (!Save())
return false;
startSubmission();
return true;
})));
}
else
{
startSubmission();
}
void startSubmission() => this.Push(new BeatmapSubmissionScreen());
}
private void exportBeatmap(bool legacy)
{
if (HasUnsavedChanges)

View File

@ -0,0 +1,422 @@
// 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.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using osu.Framework.Allocation;
using osu.Framework.Development;
using osu.Framework.Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Framework.Screens;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.Drawables.Cards;
using osu.Game.Configuration;
using osu.Game.Database;
using osu.Game.Extensions;
using osu.Game.Graphics.UserInterfaceV2;
using osu.Game.IO.Archives;
using osu.Game.Localisation;
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.Notifications;
using osu.Game.Screens.Menu;
using osuTK;
namespace osu.Game.Screens.Edit.Submission
{
public partial class BeatmapSubmissionScreen : OsuScreen
{
private BeatmapSubmissionOverlay overlay = null!;
public override bool AllowUserExit => false;
[Cached]
private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine);
[Resolved]
private RealmAccess realmAccess { get; set; } = null!;
[Resolved]
private Storage storage { get; set; } = null!;
[Resolved]
private IAPIProvider api { get; set; } = null!;
[Resolved]
private OsuConfigManager configManager { get; set; } = null!;
[Resolved]
private OsuGame? game { get; set; }
[Resolved]
private BeatmapManager beatmaps { get; set; } = null!;
[Cached]
private BeatmapSubmissionSettings settings { get; } = new BeatmapSubmissionSettings();
private Container submissionProgress = null!;
private SubmissionStageProgress exportStep = null!;
private SubmissionStageProgress createSetStep = null!;
private SubmissionStageProgress uploadStep = null!;
private SubmissionStageProgress updateStep = null!;
private Container successContainer = null!;
private Container flashLayer = null!;
private RoundedButton backButton = null!;
private uint? beatmapSetId;
private SubmissionBeatmapExporter legacyBeatmapExporter = null!;
private ProgressNotification? exportProgressNotification;
private MemoryStream beatmapPackageStream = null!;
private ProgressNotification? updateProgressNotification;
[BackgroundDependencyLoader]
private void load()
{
AddRangeInternal(new Drawable[]
{
overlay = new BeatmapSubmissionOverlay(),
submissionProgress = new Container
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Alpha = 0,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Width = 0.6f,
Masking = true,
CornerRadius = 10,
Children = new Drawable[]
{
new Box
{
RelativeSizeAxes = Axes.Both,
Colour = colourProvider.Background5,
},
new FillFlowContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Direction = FillDirection.Vertical,
Padding = new MarginPadding(20),
Spacing = new Vector2(5),
Children = new Drawable[]
{
createSetStep = new SubmissionStageProgress
{
StageDescription = BeatmapSubmissionStrings.PreparingBeatmapSet,
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
},
exportStep = new SubmissionStageProgress
{
StageDescription = BeatmapSubmissionStrings.ExportingBeatmapSet,
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
},
uploadStep = new SubmissionStageProgress
{
StageDescription = BeatmapSubmissionStrings.UploadingBeatmapSetContents,
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
},
updateStep = new SubmissionStageProgress
{
StageDescription = BeatmapSubmissionStrings.UpdatingLocalBeatmap,
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
},
successContainer = new Container
{
Padding = new MarginPadding(20),
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
AutoSizeAxes = Axes.Both,
AutoSizeDuration = 500,
AutoSizeEasing = Easing.OutQuint,
Masking = true,
CornerRadius = BeatmapCard.CORNER_RADIUS,
Child = flashLayer = new Container
{
RelativeSizeAxes = Axes.Both,
Masking = true,
CornerRadius = BeatmapCard.CORNER_RADIUS,
Depth = float.MinValue,
Alpha = 0,
Child = new Box
{
RelativeSizeAxes = Axes.Both,
}
}
},
backButton = new RoundedButton
{
Text = CommonStrings.Back,
Width = 150,
Action = this.Exit,
Enabled = { Value = false },
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
}
}
}
}
}
});
overlay.State.BindValueChanged(_ =>
{
if (overlay.State.Value == Visibility.Hidden)
{
if (!overlay.Completed)
this.Exit();
else
{
submissionProgress.FadeIn(200, Easing.OutQuint);
createBeatmapSet();
}
}
});
beatmapPackageStream = new MemoryStream();
}
private void createBeatmapSet()
{
bool beatmapHasOnlineId = Beatmap.Value.BeatmapSetInfo.OnlineID > 0;
var createRequest = beatmapHasOnlineId
? PutBeatmapSetRequest.UpdateExisting(
(uint)Beatmap.Value.BeatmapSetInfo.OnlineID,
Beatmap.Value.BeatmapSetInfo.Beatmaps.Where(b => b.OnlineID > 0).Select(b => (uint)b.OnlineID).ToArray(),
(uint)Beatmap.Value.BeatmapSetInfo.Beatmaps.Count(b => b.OnlineID <= 0),
settings.Target.Value)
: PutBeatmapSetRequest.CreateNew((uint)Beatmap.Value.BeatmapSetInfo.Beatmaps.Count, settings.Target.Value);
createRequest.Success += async response =>
{
createSetStep.SetCompleted();
beatmapSetId = response.BeatmapSetId;
// at this point the set has an assigned online ID.
// it's important to proactively store it to the realm database,
// so that in the event in further failures in the process, the online ID is not lost.
// losing it can incur creation of redundant new sets server-side, or even cause online ID confusion.
if (!beatmapHasOnlineId)
{
await realmAccess.WriteAsync(r =>
{
var refetchedSet = r.Find<BeatmapSetInfo>(Beatmap.Value.BeatmapSetInfo.ID);
refetchedSet!.OnlineID = (int)beatmapSetId.Value;
}).ConfigureAwait(true);
}
legacyBeatmapExporter = new SubmissionBeatmapExporter(storage, response);
await createBeatmapPackage(response.Files).ConfigureAwait(true);
};
createRequest.Failure += ex =>
{
createSetStep.SetFailed(ex.Message);
backButton.Enabled.Value = true;
Logger.Log($"Beatmap set submission failed on creation: {ex}");
};
createSetStep.SetInProgress();
api.Queue(createRequest);
}
private async Task createBeatmapPackage(ICollection<BeatmapSetFile> onlineFiles)
{
Debug.Assert(ThreadSafety.IsUpdateThread);
exportStep.SetInProgress();
try
{
await legacyBeatmapExporter.ExportToStreamAsync(Beatmap.Value.BeatmapSetInfo.ToLive(realmAccess), beatmapPackageStream, exportProgressNotification = new ProgressNotification())
.ConfigureAwait(true);
}
catch (Exception ex)
{
exportStep.SetFailed(ex.Message);
Logger.Log($"Beatmap set submission failed on export: {ex}");
backButton.Enabled.Value = true;
exportProgressNotification = null;
}
exportStep.SetCompleted();
exportProgressNotification = null;
if (onlineFiles.Count > 0)
await patchBeatmapSet(onlineFiles).ConfigureAwait(true);
else
replaceBeatmapSet();
}
private async Task patchBeatmapSet(ICollection<BeatmapSetFile> onlineFiles)
{
Debug.Assert(beatmapSetId != null);
var onlineFilesByFilename = onlineFiles.ToDictionary(f => f.Filename, f => f.SHA2Hash);
// disposing the `ArchiveReader` makes the underlying stream no longer readable which we don't want.
// make a local copy to defend against it.
using var archiveReader = new ZipArchiveReader(new MemoryStream(beatmapPackageStream.ToArray()));
var filesToUpdate = new HashSet<string>();
foreach (string filename in archiveReader.Filenames)
{
string localHash = archiveReader.GetStream(filename).ComputeSHA2Hash();
if (!onlineFilesByFilename.Remove(filename, out string? onlineHash))
{
filesToUpdate.Add(filename);
continue;
}
if (localHash != onlineHash)
filesToUpdate.Add(filename);
}
var changedFiles = new Dictionary<string, byte[]>();
foreach (string file in filesToUpdate)
changedFiles.Add(file, await archiveReader.GetStream(file).ReadAllBytesToArrayAsync().ConfigureAwait(true));
var patchRequest = new PatchBeatmapPackageRequest(beatmapSetId.Value);
patchRequest.FilesChanged.AddRange(changedFiles);
patchRequest.FilesDeleted.AddRange(onlineFilesByFilename.Keys);
patchRequest.Success += async () =>
{
uploadStep.SetCompleted();
if (configManager.Get<bool>(OsuSetting.EditorSubmissionLoadInBrowserAfterSubmission))
game?.OpenUrlExternally($"{api.EndpointConfiguration.WebsiteRootUrl}/beatmapsets/{beatmapSetId}");
await updateLocalBeatmap().ConfigureAwait(true);
};
patchRequest.Failure += ex =>
{
uploadStep.SetFailed(ex.Message);
Logger.Log($"Beatmap submission failed on upload: {ex}");
backButton.Enabled.Value = true;
};
patchRequest.Progressed += (current, total) => uploadStep.SetInProgress((float)current / total);
api.Queue(patchRequest);
uploadStep.SetInProgress();
}
private void replaceBeatmapSet()
{
Debug.Assert(beatmapSetId != null);
var uploadRequest = new ReplaceBeatmapPackageRequest(beatmapSetId.Value, beatmapPackageStream.ToArray());
uploadRequest.Success += async () =>
{
uploadStep.SetCompleted();
if (configManager.Get<bool>(OsuSetting.EditorSubmissionLoadInBrowserAfterSubmission))
game?.OpenUrlExternally($"{api.EndpointConfiguration.WebsiteRootUrl}/beatmapsets/{beatmapSetId}");
await updateLocalBeatmap().ConfigureAwait(true);
};
uploadRequest.Failure += ex =>
{
uploadStep.SetFailed(ex.Message);
Logger.Log($"Beatmap submission failed on upload: {ex}");
backButton.Enabled.Value = true;
};
uploadRequest.Progressed += (current, total) => uploadStep.SetInProgress((float)current / Math.Max(total, 1));
api.Queue(uploadRequest);
uploadStep.SetInProgress();
}
private async Task updateLocalBeatmap()
{
Debug.Assert(beatmapSetId != null);
updateStep.SetInProgress();
Live<BeatmapSetInfo>? importedSet;
try
{
importedSet = await beatmaps.ImportAsUpdate(
updateProgressNotification = new ProgressNotification(),
new ImportTask(beatmapPackageStream, $"{beatmapSetId}.osz"),
Beatmap.Value.BeatmapSetInfo).ConfigureAwait(true);
}
catch (Exception ex)
{
updateStep.SetFailed(ex.Message);
Logger.Log($"Beatmap submission failed on local update: {ex}");
Schedule(() => backButton.Enabled.Value = true);
return;
}
updateStep.SetCompleted();
backButton.Enabled.Value = true;
backButton.Action = () =>
{
game?.PerformFromScreen(s =>
{
if (s is OsuScreen osuScreen)
{
Debug.Assert(importedSet != null);
var targetBeatmap = importedSet.Value.Beatmaps.FirstOrDefault(b => b.DifficultyName == Beatmap.Value.BeatmapInfo.DifficultyName)
?? importedSet.Value.Beatmaps.First();
osuScreen.Beatmap.Value = beatmaps.GetWorkingBeatmap(targetBeatmap);
}
s.Push(new EditorLoader());
}, [typeof(MainMenu)]);
};
showBeatmapCard();
}
private void showBeatmapCard()
{
Debug.Assert(beatmapSetId != null);
var getBeatmapSetRequest = new GetBeatmapSetRequest((int)beatmapSetId.Value);
getBeatmapSetRequest.Success += beatmapSet =>
{
LoadComponentAsync(new BeatmapCardExtra(beatmapSet, false), loaded =>
{
successContainer.Add(loaded);
flashLayer.FadeOutFromOne(2000, Easing.OutQuint);
});
};
api.Queue(getBeatmapSetRequest);
}
protected override void Update()
{
base.Update();
if (exportProgressNotification != null && exportProgressNotification.Ongoing)
exportStep.SetInProgress(exportProgressNotification.Progress);
if (updateProgressNotification != null && updateProgressNotification.Ongoing)
updateStep.SetInProgress(updateProgressNotification.Progress);
}
public override void OnEntering(ScreenTransitionEvent e)
{
base.OnEntering(e);
overlay.Show();
}
}
}

View File

@ -0,0 +1,13 @@
// 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 osu.Framework.Bindables;
using osu.Game.Online.API.Requests;
namespace osu.Game.Screens.Edit.Submission
{
public class BeatmapSubmissionSettings
{
public Bindable<BeatmapSubmissionTarget> Target { get; } = new Bindable<BeatmapSubmissionTarget>();
}
}

View File

@ -11,6 +11,7 @@ using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.UserInterfaceV2;
using osu.Game.Localisation;
using osu.Game.Online.API.Requests;
using osu.Game.Overlays;
using osuTK;
@ -22,8 +23,10 @@ namespace osu.Game.Screens.Edit.Submission
private readonly BindableBool notifyOnDiscussionReplies = new BindableBool();
private readonly BindableBool loadInBrowserAfterSubmission = new BindableBool();
public override LocalisableString? NextStepText => BeatmapSubmissionStrings.ConfirmSubmission;
[BackgroundDependencyLoader]
private void load(OsuConfigManager configManager, OsuColour colours)
private void load(OsuConfigManager configManager, OsuColour colours, BeatmapSubmissionSettings settings)
{
configManager.BindWith(OsuSetting.EditorSubmissionNotifyOnDiscussionReplies, notifyOnDiscussionReplies);
configManager.BindWith(OsuSetting.EditorSubmissionLoadInBrowserAfterSubmission, loadInBrowserAfterSubmission);
@ -39,6 +42,7 @@ namespace osu.Game.Screens.Edit.Submission
{
RelativeSizeAxes = Axes.X,
Caption = BeatmapSubmissionStrings.BeatmapSubmissionTargetCaption,
Current = settings.Target,
},
new FormCheckBox
{
@ -60,14 +64,5 @@ namespace osu.Game.Screens.Edit.Submission
}
});
}
private enum BeatmapSubmissionTarget
{
[LocalisableDescription(typeof(BeatmapSubmissionStrings), nameof(BeatmapSubmissionStrings.BeatmapSubmissionTargetWIP))]
WIP,
[LocalisableDescription(typeof(BeatmapSubmissionStrings), nameof(BeatmapSubmissionStrings.BeatmapSubmissionTargetPending))]
Pending,
}
}
}