// 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.Diagnostics; using System.IO; using System.Linq; using System.Linq.Expressions; using System.Text; using osu.Framework.Extensions; using osu.Framework.Platform; using osu.Framework.Testing; using osu.Game.Beatmaps.Formats; using osu.Game.Database; using osu.Game.Extensions; using osu.Game.Skinning; using osu.Game.Stores; using osu.Game.Overlays.Notifications; #nullable enable namespace osu.Game.Beatmaps { [ExcludeFromDynamicCompile] public class BeatmapModelManager : BeatmapImporter { /// /// The game working beatmap cache, used to invalidate entries on changes. /// public IWorkingBeatmapCache? WorkingBeatmapCache { private get; set; } public override IEnumerable HandledExtensions => new[] { ".osz" }; protected override string[] HashableFileTypes => new[] { ".osu" }; private static readonly string[] video_extensions = { ".mp4", ".mov", ".avi", ".flv" }; public BeatmapModelManager(RealmAccess realm, Storage storage, BeatmapOnlineLookupQueue? onlineLookupQueue = null) : base(realm, storage, onlineLookupQueue) { } protected override bool ShouldDeleteArchive(string path) => Path.GetExtension(path)?.ToLowerInvariant() == ".osz"; /// /// 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 void Save(BeatmapInfo beatmapInfo, IBeatmap beatmapContent, ISkin? beatmapSkin = null) { var setInfo = beatmapInfo.BeatmapSet; Debug.Assert(setInfo != null); // Difficulty settings must be copied first due to the clone in `Beatmap<>.BeatmapInfo_Set`. // This should hopefully be temporary, assuming said clone is eventually removed. // Warning: The directionality here is important. Changes have to be copied *from* beatmapContent (which comes from editor and is being saved) // *to* the beatmapInfo (which is a database model and needs to receive values without the taiko slider velocity multiplier for correct operation). // CopyTo() will undo such adjustments, while CopyFrom() will not. beatmapContent.Difficulty.CopyTo(beatmapInfo.Difficulty); // All changes to metadata are made in the provided beatmapInfo, so this should be copied to the `IBeatmap` before encoding. beatmapContent.BeatmapInfo = beatmapInfo; 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); // AddFile generally handles updating/replacing files, but this is a case where the filename may have also changed so let's delete for simplicity. var existingFileInfo = setInfo.Files.SingleOrDefault(f => string.Equals(f.Filename, beatmapInfo.Path, StringComparison.OrdinalIgnoreCase)); string targetFilename = getFilename(beatmapInfo); // ensure that two difficulties from the set don't point at the same beatmap file. if (setInfo.Beatmaps.Any(b => b.ID != beatmapInfo.ID && string.Equals(b.Path, targetFilename, StringComparison.OrdinalIgnoreCase))) throw new InvalidOperationException($"{setInfo.GetDisplayString()} already has a difficulty with the name of '{beatmapInfo.DifficultyName}'."); if (existingFileInfo != null) DeleteFile(setInfo, existingFileInfo); beatmapInfo.MD5Hash = stream.ComputeMD5Hash(); beatmapInfo.Hash = stream.ComputeSHA2Hash(); AddFile(setInfo, stream, getFilename(beatmapInfo)); Update(setInfo); } WorkingBeatmapCache?.Invalidate(beatmapInfo); } private static string getFilename(BeatmapInfo beatmapInfo) { var metadata = beatmapInfo.Metadata; return $"{metadata.Artist} - {metadata.Title} ({metadata.Author.Username}) [{beatmapInfo.DifficultyName}].osu".GetValidArchiveContentFilename(); } /// /// 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) { return Realm.Run(realm => realm.All().FirstOrDefault(query)?.Detach()); } public void Update(BeatmapSetInfo item) { Realm.Write(r => { var existing = r.Find(item.ID); item.CopyChangesToRealm(existing); }); } /// /// Delete videos from a list of beatmaps. /// This will post notifications tracking progress. /// public void DeleteVideos(List items, bool silent = false) { if (items.Count == 0) return; var notification = new ProgressNotification { Progress = 0, Text = $"Preparing to delete all {HumanisedModelName} videos...", CompletionText = $"Deleted all {HumanisedModelName} videos!", State = ProgressNotificationState.Active, }; if (!silent) PostNotification?.Invoke(notification); int i = 0; foreach (var b in items) { if (notification.State == ProgressNotificationState.Cancelled) // user requested abort return; notification.Text = $"Deleting videos from {HumanisedModelName}s ({++i} of {items.Count})"; var video = b.Files.FirstOrDefault(f => video_extensions.Any(ex => f.Filename.EndsWith(ex, StringComparison.Ordinal))); if (video != null) DeleteFile(b, video); notification.Progress = (float)i / items.Count; } notification.State = ProgressNotificationState.Completed; } } }