// 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.Linq.Expressions; using System.Threading; using System.Threading.Tasks; using osu.Framework.Audio; using osu.Framework.Audio.Track; using osu.Framework.IO.Stores; using osu.Framework.Platform; using osu.Framework.Testing; using osu.Game.Database; using osu.Game.IO.Archives; using osu.Game.Models; using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays.Notifications; using osu.Game.Rulesets; using osu.Game.Skinning; using osu.Game.Stores; using osu.Game.Utils; #nullable enable namespace osu.Game.Beatmaps { /// /// Handles general operations related to global beatmap management. /// [ExcludeFromDynamicCompile] public class BeatmapManager : IModelManager, IModelFileManager, IModelImporter, IWorkingBeatmapCache, IDisposable { public ITrackStore BeatmapTrackStore { get; } private readonly BeatmapModelManager beatmapModelManager; private readonly WorkingBeatmapCache workingBeatmapCache; private readonly BeatmapOnlineLookupQueue? onlineBeatmapLookupQueue; private readonly RealmAccess realm; public BeatmapManager(Storage storage, RealmAccess realm, RulesetStore rulesets, IAPIProvider? api, AudioManager audioManager, IResourceStore gameResources, GameHost? host = null, WorkingBeatmap? defaultBeatmap = null, bool performOnlineLookups = false) { this.realm = realm; if (performOnlineLookups) { if (api == null) throw new ArgumentNullException(nameof(api), "API must be provided if online lookups are required."); onlineBeatmapLookupQueue = new BeatmapOnlineLookupQueue(api, storage); } var userResources = new RealmFileStore(realm, storage).Store; BeatmapTrackStore = audioManager.GetTrackStore(userResources); beatmapModelManager = CreateBeatmapModelManager(storage, realm, rulesets, onlineBeatmapLookupQueue); workingBeatmapCache = CreateWorkingBeatmapCache(audioManager, gameResources, userResources, defaultBeatmap, host); beatmapModelManager.WorkingBeatmapCache = workingBeatmapCache; } protected virtual WorkingBeatmapCache CreateWorkingBeatmapCache(AudioManager audioManager, IResourceStore resources, IResourceStore storage, WorkingBeatmap? defaultBeatmap, GameHost? host) { return new WorkingBeatmapCache(BeatmapTrackStore, audioManager, resources, storage, defaultBeatmap, host); } protected virtual BeatmapModelManager CreateBeatmapModelManager(Storage storage, RealmAccess realm, RulesetStore rulesets, BeatmapOnlineLookupQueue? onlineLookupQueue) => new BeatmapModelManager(realm, storage, onlineLookupQueue); /// /// Create a new beatmap set, backed by a model, /// with a single difficulty which is backed by a model /// and represented by the returned usable . /// public WorkingBeatmap CreateNew(RulesetInfo ruleset, APIUser user) { var metadata = new BeatmapMetadata { Author = new RealmUser { OnlineID = user.OnlineID, Username = user.Username, } }; var beatmapSet = new BeatmapSetInfo { Beatmaps = { new BeatmapInfo(ruleset, new BeatmapDifficulty(), metadata) } }; foreach (BeatmapInfo b in beatmapSet.Beatmaps) b.BeatmapSet = beatmapSet; var imported = beatmapModelManager.Import(beatmapSet); if (imported == null) throw new InvalidOperationException("Failed to import new beatmap"); return imported.PerformRead(s => GetWorkingBeatmap(s.Beatmaps.First())); } /// /// Add a new difficulty to the provided based on the provided . /// The new difficulty will be backed by a model /// and represented by the returned . /// /// /// Contrary to , this method does not preserve hitobjects and beatmap-level settings from . /// The created beatmap will have zero hitobjects and will have default settings (including difficulty settings), but will preserve metadata and existing timing points. /// /// The to add the new difficulty to. /// The to use as a baseline reference when creating the new difficulty. /// The ruleset with which the new difficulty should be created. public virtual WorkingBeatmap CreateNewDifficulty(BeatmapSetInfo targetBeatmapSet, WorkingBeatmap referenceWorkingBeatmap, RulesetInfo rulesetInfo) { var playableBeatmap = referenceWorkingBeatmap.GetPlayableBeatmap(rulesetInfo); var newBeatmapInfo = new BeatmapInfo(rulesetInfo, new BeatmapDifficulty(), playableBeatmap.Metadata.DeepClone()) { DifficultyName = NamingUtils.GetNextBestName(targetBeatmapSet.Beatmaps.Select(b => b.DifficultyName), "New Difficulty") }; var newBeatmap = new Beatmap { BeatmapInfo = newBeatmapInfo }; foreach (var timingPoint in playableBeatmap.ControlPointInfo.TimingPoints) newBeatmap.ControlPointInfo.Add(timingPoint.Time, timingPoint.DeepClone()); return addDifficultyToSet(targetBeatmapSet, newBeatmap, referenceWorkingBeatmap.Skin); } /// /// Add a copy of the provided to the provided . /// The new difficulty will be backed by a model /// and represented by the returned . /// /// /// Contrary to , this method creates a nearly-exact copy of /// (with the exception of a few key properties that cannot be copied under any circumstance, like difficulty name, beatmap hash, or online status). /// /// The to add the copy to. /// The to be copied. public virtual WorkingBeatmap CopyExistingDifficulty(BeatmapSetInfo targetBeatmapSet, WorkingBeatmap referenceWorkingBeatmap) { var newBeatmap = referenceWorkingBeatmap.GetPlayableBeatmap(referenceWorkingBeatmap.BeatmapInfo.Ruleset).Clone(); BeatmapInfo newBeatmapInfo; newBeatmap.BeatmapInfo = newBeatmapInfo = referenceWorkingBeatmap.BeatmapInfo.Clone(); // assign a new ID to the clone. newBeatmapInfo.ID = Guid.NewGuid(); // add "(copy)" suffix to difficulty name, and additionally ensure that it doesn't conflict with any other potentially pre-existing copies. newBeatmapInfo.DifficultyName = NamingUtils.GetNextBestName( targetBeatmapSet.Beatmaps.Select(b => b.DifficultyName), $"{newBeatmapInfo.DifficultyName} (copy)"); // clear the hash, as that's what is used to match .osu files with their corresponding realm beatmaps. newBeatmapInfo.Hash = string.Empty; // clear online properties. newBeatmapInfo.OnlineID = -1; newBeatmapInfo.Status = BeatmapOnlineStatus.None; return addDifficultyToSet(targetBeatmapSet, newBeatmap, referenceWorkingBeatmap.Skin); } private WorkingBeatmap addDifficultyToSet(BeatmapSetInfo targetBeatmapSet, IBeatmap newBeatmap, ISkin beatmapSkin) { // populate circular beatmap set info <-> beatmap info references manually. // several places like `BeatmapModelManager.Save()` or `GetWorkingBeatmap()` // rely on them being freely traversable in both directions for correct operation. targetBeatmapSet.Beatmaps.Add(newBeatmap.BeatmapInfo); newBeatmap.BeatmapInfo.BeatmapSet = targetBeatmapSet; beatmapModelManager.Save(newBeatmap.BeatmapInfo, newBeatmap, beatmapSkin); workingBeatmapCache.Invalidate(targetBeatmapSet); return GetWorkingBeatmap(newBeatmap.BeatmapInfo); } /// /// Delete a beatmap difficulty. /// /// The beatmap difficulty to hide. public void Hide(BeatmapInfo beatmapInfo) { realm.Run(r => { using (var transaction = r.BeginWrite()) { if (!beatmapInfo.IsManaged) beatmapInfo = r.Find(beatmapInfo.ID); beatmapInfo.Hidden = true; transaction.Commit(); } }); } /// /// Restore a beatmap difficulty. /// /// The beatmap difficulty to restore. public void Restore(BeatmapInfo beatmapInfo) { realm.Run(r => { using (var transaction = r.BeginWrite()) { if (!beatmapInfo.IsManaged) beatmapInfo = r.Find(beatmapInfo.ID); beatmapInfo.Hidden = false; transaction.Commit(); } }); } public void RestoreAll() { realm.Run(r => { using (var transaction = r.BeginWrite()) { foreach (var beatmap in r.All().Where(b => b.Hidden)) beatmap.Hidden = false; transaction.Commit(); } }); } /// /// Returns a list of all usable s. /// /// A list of available . public List GetAllUsableBeatmapSets() { return realm.Run(r => { r.Refresh(); return r.All().Where(b => !b.DeletePending).Detach(); }); } /// /// Perform a lookup query on available s. /// /// The query. /// The first result for the provided query, or null if no results were found. public Live? QueryBeatmapSet(Expression> query) { return realm.Run(r => r.All().FirstOrDefault(query)?.ToLive(realm)); } #region Delegation to BeatmapModelManager (methods which previously existed locally). /// /// Perform a lookup query on available s. /// /// The query. /// The first result for the provided query, or null if no results were found. public BeatmapInfo? QueryBeatmap(Expression> query) => beatmapModelManager.QueryBeatmap(query)?.Detach(); /// /// Saves an file against a given . /// /// The to save the content against. The file referenced by will be replaced. /// The content to write. /// The beatmap content to write, null if to be omitted. public virtual void Save(BeatmapInfo info, IBeatmap beatmapContent, ISkin? beatmapSkin = null) => beatmapModelManager.Save(info, beatmapContent, beatmapSkin); /// /// A default representation of a WorkingBeatmap to use when no beatmap is available. /// public IWorkingBeatmap DefaultBeatmap => workingBeatmapCache.DefaultBeatmap; /// /// Fired when a notification should be presented to the user. /// public Action PostNotification { set => beatmapModelManager.PostNotification = value; } #endregion #region Implementation of IModelManager public bool IsAvailableLocally(BeatmapSetInfo model) { return beatmapModelManager.IsAvailableLocally(model); } public bool Delete(BeatmapSetInfo item) { return beatmapModelManager.Delete(item); } public void Delete(List items, bool silent = false) { beatmapModelManager.Delete(items, silent); } public void Delete(Expression>? filter = null, bool silent = false) { realm.Run(r => { var items = r.All().Where(s => !s.DeletePending && !s.Protected); if (filter != null) items = items.Where(filter); beatmapModelManager.Delete(items.ToList(), silent); }); } public void UndeleteAll() { realm.Run(r => beatmapModelManager.Undelete(r.All().Where(s => s.DeletePending).ToList())); } public void Undelete(List items, bool silent = false) { beatmapModelManager.Undelete(items, silent); } public void Undelete(BeatmapSetInfo item) { beatmapModelManager.Undelete(item); } #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>> Import(ProgressNotification notification, params ImportTask[] tasks) { return beatmapModelManager.Import(notification, tasks); } public Task?> Import(ImportTask task, bool lowPriority = false, CancellationToken cancellationToken = default) { return beatmapModelManager.Import(task, lowPriority, cancellationToken); } public Task?> Import(ArchiveReader archive, bool lowPriority = false, CancellationToken cancellationToken = default) { return beatmapModelManager.Import(archive, lowPriority, cancellationToken); } public Live? Import(BeatmapSetInfo item, ArchiveReader? archive = null, bool lowPriority = false, CancellationToken cancellationToken = default) { return beatmapModelManager.Import(item, archive, lowPriority, cancellationToken); } public IEnumerable HandledExtensions => beatmapModelManager.HandledExtensions; #endregion #region Implementation of IWorkingBeatmapCache public WorkingBeatmap GetWorkingBeatmap(BeatmapInfo? importedBeatmap) { // Detached sets don't come with files. // If we seem to be missing files, now is a good time to re-fetch. if (importedBeatmap?.BeatmapSet?.Files.Count == 0) { realm.Run(r => { var refetch = r.Find(importedBeatmap.ID)?.Detach(); if (refetch != null) importedBeatmap = refetch; }); } return workingBeatmapCache.GetWorkingBeatmap(importedBeatmap); } public WorkingBeatmap GetWorkingBeatmap(Live? importedBeatmap) { WorkingBeatmap working = workingBeatmapCache.GetWorkingBeatmap(null); importedBeatmap?.PerformRead(b => working = workingBeatmapCache.GetWorkingBeatmap(b)); return working; } void IWorkingBeatmapCache.Invalidate(BeatmapSetInfo beatmapSetInfo) => workingBeatmapCache.Invalidate(beatmapSetInfo); void IWorkingBeatmapCache.Invalidate(BeatmapInfo beatmapInfo) => workingBeatmapCache.Invalidate(beatmapInfo); #endregion #region Implementation of IModelFileManager public void ReplaceFile(BeatmapSetInfo model, RealmNamedFileUsage file, Stream contents) { beatmapModelManager.ReplaceFile(model, file, contents); } public void DeleteFile(BeatmapSetInfo model, RealmNamedFileUsage file) { beatmapModelManager.DeleteFile(model, file); } public void AddFile(BeatmapSetInfo model, Stream contents, string filename) { beatmapModelManager.AddFile(model, contents, filename); } #endregion #region Implementation of IDisposable public void Dispose() { onlineBeatmapLookupQueue?.Dispose(); } #endregion #region Implementation of IPostImports public Action>>? PostImport { set => beatmapModelManager.PostImport = value; } #endregion } }