diff --git a/osu.Game/Database/LazerImportManager.cs b/osu.Game/Database/LazerImportManager.cs new file mode 100644 index 000000000000..f01573289024 --- /dev/null +++ b/osu.Game/Database/LazerImportManager.cs @@ -0,0 +1,384 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using osu.Framework.Platform; +using osu.Game.Beatmaps; +using osu.Game.Collections; +using osu.Game.Models; +using osu.Game.Overlays; +using osu.Game.Overlays.Notifications; +using osu.Game.Rulesets; +using osu.Game.Scoring; +using osu.Game.Skinning; +using Realms; + +namespace osu.Game.Database +{ + /// + /// Handles importing files and database from another osu!lazer install + /// + public class LazerImportManager + { + private RealmFileStore realmFileStore; + private readonly RealmAccess realmAccess; + private readonly INotificationOverlay notifications; + private readonly Storage storage; + + public LazerImportManager(RealmAccess realmAccess, INotificationOverlay notifications, Storage storage) + { + this.realmAccess = realmAccess; + this.notifications = notifications; + this.storage = storage; + realmFileStore = new RealmFileStore(realmAccess, storage); + } + + /// + /// Imports content from a separate osu!lazer data directory. + /// + /// The root directory of the source installation (containing 'client.realm' and 'files'). + /// Whether to import beatmaps or not + /// Whether to import scores or not + /// Whether to import skins or not + /// Whether to import collections or not + public Task ImportFrom(string sourcePath, bool shouldImportBeatmaps, bool shouldImportScores, bool shouldImportSkins, bool shouldImportCollections) + { + realmAccess.Write(destRealm => + { + var sourceConfig = new RealmConfiguration(Path.Combine(sourcePath, "client.realm")) + { + IsReadOnly = true, + SchemaVersion = RealmAccess.schema_version + }; + + using (var sourceRealm = Realm.GetInstance(sourceConfig)) + { + // gather files to copy over + var filesToImport = new HashSet(); + + if (shouldImportBeatmaps) + { + var beatmaps = sourceRealm.All(); + foreach (var b in beatmaps) + foreach (var f in b.Files) + filesToImport.Add(f.File.Hash); + } + + if (shouldImportScores) + { + var scores = sourceRealm.All(); + foreach (var s in scores) + foreach (var f in s.Files) + filesToImport.Add(f.File.Hash); + } + + if (shouldImportSkins) + { + var skins = sourceRealm.All(); + foreach (var s in skins) + foreach (var f in s.Files) + filesToImport.Add(f.File.Hash); + } + + if (filesToImport.Count > 0) + { + copyFiles(destRealm, sourcePath, filesToImport); + } + + if (shouldImportBeatmaps) + importBeatmaps(sourceRealm, destRealm); + + if (shouldImportScores) + importScores(sourceRealm, destRealm); + + if (shouldImportSkins) + importSkins(sourceRealm, destRealm); + + if (shouldImportCollections) + importCollections(sourceRealm, destRealm); + } + }); + + return Task.CompletedTask; + } + + private void copyFiles(Realm destRealm, string sourcePath, HashSet filesToCopy) + { + var notification = new ProgressNotification + { + Text = "Importing files...", + State = ProgressNotificationState.Active + }; + notifications.Post(notification); + + string sourceFilesPath = Path.Combine(sourcePath, "files"); + + int current = 0; + int total = filesToCopy.Count; + + foreach (string hash in filesToCopy) + { + if (notification.State == ProgressNotificationState.Cancelled) return; + + string folder1 = hash.Substring(0, 1); + string folder2 = hash.Substring(0, 2); + string sourceFilePath = Path.Combine(sourceFilesPath, folder1, folder2, hash); + + if (File.Exists(sourceFilePath)) + { + using (var data = File.OpenRead(sourceFilePath)) + { + realmFileStore.Add(data, destRealm, preferHardLinks: true); + } + } + + current++; + notification.Text = $"Copying files ({current}/{total})"; + notification.Progress = (float)current / total; + } + + notification.CompletionText = "Files copied!"; + notification.State = ProgressNotificationState.Completed; + } + + private void importBeatmaps(Realm sourceRealm, Realm destRealm) + { + var sourceBeatmaps = sourceRealm.All().Where(b => !b.DeletePending).AsEnumerable(); + int total = sourceBeatmaps.Count(); + int current = 0; + + var notification = new ProgressNotification + { + Text = "Importing beatmaps...", + State = ProgressNotificationState.Active + }; + notifications.Post(notification); + + var existingRulesets = destRealm.All().ToDictionary(r => r.ShortName); + var existingIDs = new HashSet(destRealm.All().AsEnumerable().Select(b => b.ID)); + + foreach (var set in sourceBeatmaps) + { + if (notification.State == ProgressNotificationState.Cancelled) return; + + if (existingIDs.Contains(set.ID)) + { + current++; + notification.Text = $"Importing beatmaps ({current} of {total})"; + notification.Progress = (float)current / total; + continue; + } + + var newSet = set.Detach(); + + newSet.Files.Clear(); + foreach (var fileUsage in set.Files) + { + var dbFile = destRealm.Find(fileUsage.File.Hash); + if (dbFile != null) + { + newSet.Files.Add(new RealmNamedFileUsage(dbFile, fileUsage.Filename)); + } + } + + foreach (var beatmap in newSet.Beatmaps) + { + if (existingRulesets.TryGetValue(beatmap.Ruleset.ShortName, out var ruleset)) + beatmap.Ruleset = ruleset; + + beatmap.BeatmapSet = newSet; + } + + destRealm.Add(newSet); + existingIDs.Add(newSet.ID); + + current++; + notification.Text = $"Importing beatmaps ({current} of {total})"; + notification.Progress = (float)current / total; + } + + notification.CompletionText = "Beatmaps imported!"; + notification.State = ProgressNotificationState.Completed; + } + + private void importScores(Realm sourceRealm, Realm destRealm) + { + var sourceScores = sourceRealm.All().Where(s => !s.DeletePending).AsEnumerable(); + int total = sourceScores.Count(); + int current = 0; + + var notification = new ProgressNotification + { + Text = "Importing scores...", + State = ProgressNotificationState.Active + }; + notifications.Post(notification); + + var existingRulesets = destRealm.All().ToDictionary(r => r.ShortName); + var existingIDs = new HashSet(destRealm.All().AsEnumerable().Select(s => s.ID)); + + foreach (var score in sourceScores) + { + if (notification.State == ProgressNotificationState.Cancelled) return; + + if (existingIDs.Contains(score.ID)) + { + current++; + notification.Text = $"Importing scores ({current} of {total})"; + notification.Progress = (float)current / total; + continue; + } + + string mapHash = score.BeatmapInfo?.MD5Hash ?? score.BeatmapHash; + var targetBeatmap = destRealm.All().FirstOrDefault(b => b.MD5Hash == mapHash); + + if (targetBeatmap == null) + { + current++; + notification.Progress = (float)current / total; + continue; + } + + var newScore = score.Detach(); + newScore.BeatmapInfo = targetBeatmap; + + if (existingRulesets.TryGetValue(newScore.Ruleset.ShortName, out var ruleset)) + newScore.Ruleset = ruleset; + + newScore.Files.Clear(); + foreach (var fileUsage in score.Files) + { + var dbFile = destRealm.Find(fileUsage.File.Hash); + if (dbFile != null) + { + newScore.Files.Add(new RealmNamedFileUsage(dbFile, fileUsage.Filename)); + } + } + + destRealm.Add(newScore); + existingIDs.Add(newScore.ID); + + current++; + notification.Text = $"Importing scores ({current} of {total})"; + notification.Progress = (float)current / total; + } + + notification.CompletionText = "Scores imported!"; + notification.State = ProgressNotificationState.Completed; + } + + private void importSkins(Realm sourceRealm, Realm destRealm) + { + var sourceSkins = sourceRealm.All().Where(s => !s.DeletePending).AsEnumerable(); + int total = sourceSkins.Count(); + int current = 0; + + var notification = new ProgressNotification + { + Text = "Importing skins...", + State = ProgressNotificationState.Active + }; + notifications.Post(notification); + + var existingIDs = new HashSet(destRealm.All().AsEnumerable().Select(s => s.ID)); + + foreach (var skin in sourceSkins) + { + if (notification.State == ProgressNotificationState.Cancelled) return; + + if (existingIDs.Contains(skin.ID)) + { + current++; + notification.Text = $"Importing skins ({current} of {total})"; + notification.Progress = (float)current / total; + continue; + } + + var newSkin = skin.Detach(); + + newSkin.Files.Clear(); + foreach (var fileUsage in skin.Files) + { + var dbFile = destRealm.Find(fileUsage.File.Hash); + if (dbFile != null) + { + newSkin.Files.Add(new RealmNamedFileUsage(dbFile, fileUsage.Filename)); + } + } + + destRealm.Add(newSkin); + existingIDs.Add(newSkin.ID); + + current++; + notification.Text = $"Importing skins ({current} of {total})"; + notification.Progress = (float)current / total; + } + + notification.CompletionText = "Skins imported!"; + notification.State = ProgressNotificationState.Completed; + } + + private void importCollections(Realm sourceRealm, Realm destRealm) + { + var sourceCollections = sourceRealm.All().AsEnumerable(); + int total = sourceCollections.Count(); + int current = 0; + + var notification = new ProgressNotification + { + Text = "Importing collections...", + State = ProgressNotificationState.Active + }; + notifications.Post(notification); + + var existingCollections = destRealm.All() + .ToList() + .GroupBy(c => c.Name) + .ToDictionary(g => g.Key, g => g.First()); + + foreach (var sourceCollection in sourceCollections) + { + if (notification.State == ProgressNotificationState.Cancelled) return; + + if (existingCollections.TryGetValue(sourceCollection.Name, out var destCollection)) + { + foreach (string hash in sourceCollection.BeatmapMD5Hashes) + { + if (!destCollection.BeatmapMD5Hashes.Contains(hash)) + { + destCollection.BeatmapMD5Hashes.Add(hash); + } + } + } + else + { + destCollection = new BeatmapCollection + { + ID = sourceCollection.ID, + Name = sourceCollection.Name, + LastModified = DateTimeOffset.UtcNow + }; + + foreach (string hash in sourceCollection.BeatmapMD5Hashes) + { + destCollection.BeatmapMD5Hashes.Add(hash); + } + + destRealm.Add(destCollection); + existingCollections[destCollection.Name] = destCollection; + } + + current++; + notification.Text = $"Importing collections ({current} of {total})"; + notification.Progress = (float)current / total; + } + + notification.CompletionText = "Collections imported!"; + notification.State = ProgressNotificationState.Completed; + } + } +} diff --git a/osu.Game/Database/RealmAccess.cs b/osu.Game/Database/RealmAccess.cs index fa54ed538a49..e1489fb983b2 100644 --- a/osu.Game/Database/RealmAccess.cs +++ b/osu.Game/Database/RealmAccess.cs @@ -102,7 +102,7 @@ public class RealmAccess : IDisposable /// 50 2025-07-11 Add UserTags to BeatmapMetadata. /// 51 2025-07-22 Add ScoreInfo.Pauses. /// - private const int schema_version = 51; + public const int schema_version = 51; /// /// Lock object which is held during sections, blocking realm retrieval during blocking periods. diff --git a/osu.Game/Overlays/Settings/Sections/General/InstallationSettings.cs b/osu.Game/Overlays/Settings/Sections/General/InstallationSettings.cs index 68f3ba9b1722..0504d6bd0b0e 100644 --- a/osu.Game/Overlays/Settings/Sections/General/InstallationSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/General/InstallationSettings.cs @@ -32,6 +32,12 @@ private void load(Storage storage) Text = GeneralSettingsStrings.ChangeFolderLocation, Action = () => game?.PerformFromScreen(menu => menu.Push(new MigrationSelectScreen())) }); + + Add(new DangerousSettingsButton + { + Text = "Merge another osu!lazer install", + Action = () => game?.PerformFromScreen(menu => menu.Push(new ScreenImportFromLazer())) + }); } } } diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/ScreenImportFromLazer.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/ScreenImportFromLazer.cs new file mode 100644 index 000000000000..a4ced92ff51a --- /dev/null +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/ScreenImportFromLazer.cs @@ -0,0 +1,330 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Logging; +using osu.Framework.Platform; +using osu.Framework.Screens; +using osu.Game.Beatmaps; +using osu.Game.Collections; +using osu.Game.Database; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Scoring; +using osu.Game.Screens; +using osu.Game.Skinning; +using osuTK; +using Realms; + +namespace osu.Game.Overlays.Settings.Sections.Maintenance +{ + public partial class ScreenImportFromLazer : OsuScreen + { + public override bool HideOverlaysOnEnter => true; + + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); + + [Resolved] + private RealmAccess realmAccess { get; set; } = null!; + + [Resolved] + private INotificationOverlay notifications { get; set; } = null!; + + [Resolved] + private Storage? storage { get; set; } + + [Resolved] + private OsuColour colours { get; set; } = null!; + + private Container contentContainer = null!; + private DirectorySelector directorySelector = null!; + private RoundedButton importButton = null!; + private TextFlowContainer statusText = null!; + + private SettingsCheckbox checkboxBeatmaps = null!; + private SettingsCheckbox checkboxScores = null!; + private SettingsCheckbox checkboxSkins = null!; + private SettingsCheckbox checkboxCollections = null!; + + private string? fullLazerPath; + + private const float duration = 300; + private const float button_height = 50; + private const float button_vertical_margin = 15; + + [BackgroundDependencyLoader] + private void load() + { + checkboxBeatmaps = new SettingsCheckbox + { + LabelText = "Beatmaps", + Current = { Value = false, Disabled = true } + }; + checkboxScores = new SettingsCheckbox + { + LabelText = "Scores", + Current = { Value = false, Disabled = true } + }; + checkboxSkins = new SettingsCheckbox + { + LabelText = "Skins", + Current = { Value = false, Disabled = true } + }; + checkboxCollections = new SettingsCheckbox + { + LabelText = "Collections", + Current = { Value = false, Disabled = true } + }; + + InternalChild = contentContainer = new Container + { + Masking = true, + CornerRadius = 10, + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(0.9f, 0.8f), + Children = new Drawable[] + { + directorySelector = new OsuDirectorySelector + { + RelativeSizeAxes = Axes.Both, + Width = 0.65f, + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Width = 0.35f, + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Children = new Drawable[] + { + new Box + { + Colour = colourProvider.Background4, + RelativeSizeAxes = Axes.Both + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Bottom = button_height + button_vertical_margin * 2 }, + Child = new OsuScrollContainer + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Top = 20, Left = 20, Right = 20 }, + Child = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 5), + Children = new Drawable[] + { + new OsuSpriteText + { + Text = "Import Options", + Font = OsuFont.GetFont(size: 20, weight: FontWeight.Bold), + Colour = colourProvider.Content1, + Margin = new MarginPadding { Bottom = 10 } + }, + checkboxBeatmaps, + checkboxScores, + checkboxSkins, + checkboxCollections, + new Box + { + RelativeSizeAxes = Axes.X, + Height = 1, + Colour = colourProvider.Light4, + Margin = new MarginPadding { Vertical = 10 } + }, + statusText = new TextFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Colour = colours.Red1 + } + } + } + } + }, + importButton = new RoundedButton + { + Text = "Start Import", + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + RelativeSizeAxes = Axes.X, + Height = button_height, + Width = 0.9f, + Margin = new MarginPadding { Bottom = button_vertical_margin }, + Action = startImport, + Enabled = { Value = false } + } + } + } + } + }; + + directorySelector.CurrentPath.BindValueChanged(e => validatePath(e.NewValue), true); + } + + public override void OnEntering(ScreenTransitionEvent e) + { + base.OnEntering(e); + contentContainer.ScaleTo(0.95f).ScaleTo(1, duration, Easing.OutQuint); + this.FadeInFromZero(duration); + } + + public override bool OnExiting(ScreenExitEvent e) + { + contentContainer.ScaleTo(0.95f, duration, Easing.OutQuint); + this.FadeOut(duration, Easing.OutQuint); + return base.OnExiting(e); + } + + private void validatePath(DirectoryInfo directory) + { + if (directory == null || !directory.Exists) return; + + importButton.Enabled.Value = false; + statusText.Text = "Checking..."; + + resetLabels(); + fullLazerPath = null; + + Task.Run(() => + { + string proposedPath = Path.GetFullPath(directory.FullName); + string realmPath = Path.Combine(proposedPath, "client.realm"); + + if (!File.Exists(realmPath)) + { + Schedule(() => statusText.Text = "No client.realm found."); + return; + } + + try + { + var config = new RealmConfiguration(realmPath) + { + IsReadOnly = true, + SchemaVersion = RealmAccess.schema_version + }; + + using (var realm = Realm.GetInstance(config)) + { + int countMaps = realm.All().Count(); + int countScores = realm.All().Count(); + int countSkins = realm.All().Count(); + int countCollections = realm.All().Count(); + + Schedule(() => + { + statusText.Text = ""; + + updateCheckbox(checkboxBeatmaps, "Beatmaps", countMaps); + updateCheckbox(checkboxScores, "Scores", countScores); + updateCheckbox(checkboxSkins, "Skins", countSkins); + updateCheckbox(checkboxCollections, "Collections", countCollections); + + fullLazerPath = proposedPath; + importButton.Enabled.Value = true; + }); + } + } + catch (Exception ex) + { + Schedule(() => statusText.Text = "Error reading database."); + Logger.Error(ex, "Error validating lazer path"); + } + }); + } + + private void resetLabels() + { + checkboxBeatmaps.Current.Disabled = false; + checkboxScores.Current.Disabled = false; + checkboxSkins.Current.Disabled = false; + checkboxCollections.Current.Disabled = false; + + checkboxBeatmaps.LabelText = "Beatmaps"; + checkboxScores.LabelText = "Scores"; + checkboxSkins.LabelText = "Skins"; + checkboxCollections.LabelText = "Collections"; + + checkboxBeatmaps.Current.Value = false; + checkboxScores.Current.Value = false; + checkboxSkins.Current.Value = false; + checkboxCollections.Current.Value = false; + + checkboxBeatmaps.Current.Disabled = true; + checkboxScores.Current.Disabled = true; + checkboxSkins.Current.Disabled = true; + checkboxCollections.Current.Disabled = true; + } + + private void updateCheckbox(SettingsCheckbox checkbox, string name, int count) + { + checkbox.Current.Disabled = false; + + if (count > 0) + { + checkbox.LabelText = $"{name} ({count:N0})"; + checkbox.Current.Value = true; + checkbox.Alpha = 1; + } + else + { + checkbox.LabelText = $"{name} (None found)"; + checkbox.Current.Value = false; + + checkbox.Current.Disabled = true; + checkbox.Alpha = 0.5f; + } + } + + private void startImport() + { + if (fullLazerPath == null || storage == null) return; + + bool importBeatmaps = checkboxBeatmaps.Current.Value; + bool importScores = checkboxScores.Current.Value; + bool importSkins = checkboxSkins.Current.Value; + bool importCollections = checkboxCollections.Current.Value; + + if (!importBeatmaps && !importScores && !importSkins && !importCollections) + { + statusText.Text = "Please select at least one item to import."; + statusText.FadeIn(100).Then().Delay(2000).FadeOut(500); + return; + } + + var lazerImportManager = new LazerImportManager(realmAccess, notifications, storage); + + this.Exit(); + + Task.Run(async () => + { + try + { + await lazerImportManager.ImportFrom(fullLazerPath, importBeatmaps, importScores, importSkins, importCollections).ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.Error(ex, "Import failed"); + } + }); + } + } +}