diff --git a/osu.Desktop/OsuGameDesktop.cs b/osu.Desktop/OsuGameDesktop.cs index f37282366a..45ed66bad2 100644 --- a/osu.Desktop/OsuGameDesktop.cs +++ b/osu.Desktop/OsuGameDesktop.cs @@ -111,16 +111,11 @@ namespace osu.Desktop { var filePaths = new [] { e.FileName }; - if (filePaths.All(f => Path.GetExtension(f) == @".osz")) - Task.Factory.StartNew(() => BeatmapManager.Import(filePaths), TaskCreationOptions.LongRunning); - else if (filePaths.All(f => Path.GetExtension(f) == @".osr")) - Task.Run(() => - { - var score = ScoreStore.ReadReplayFile(filePaths.First()); - Schedule(() => LoadScore(score)); - }); - } + var firstExtension = Path.GetExtension(filePaths.First()); - private static readonly string[] allowed_extensions = { @".osz", @".osr" }; + if (filePaths.Any(f => Path.GetExtension(f) != firstExtension)) return; + + Task.Factory.StartNew(() => Import(filePaths), TaskCreationOptions.LongRunning); + } } } diff --git a/osu.Desktop/Program.cs b/osu.Desktop/Program.cs index 9760538197..048fe93c11 100644 --- a/osu.Desktop/Program.cs +++ b/osu.Desktop/Program.cs @@ -22,7 +22,7 @@ namespace osu.Desktop { if (!host.IsPrimaryInstance) { - var importer = new BeatmapIPCChannel(host); + var importer = new ArchiveImportIPCChannel(host); // Restore the cwd so relative paths given at the command line work correctly Directory.SetCurrentDirectory(cwd); foreach (var file in args) diff --git a/osu.Game.Rulesets.Mania/UI/ManiaRulesetContainer.cs b/osu.Game.Rulesets.Mania/UI/ManiaRulesetContainer.cs index 436d5c1ea6..3c9647117e 100644 --- a/osu.Game.Rulesets.Mania/UI/ManiaRulesetContainer.cs +++ b/osu.Game.Rulesets.Mania/UI/ManiaRulesetContainer.cs @@ -101,7 +101,7 @@ namespace osu.Game.Rulesets.Mania.UI return null; } - protected override Vector2 GetPlayfieldAspectAdjust() => new Vector2(1, 0.8f); + protected override Vector2 GetAspectAdjustedSize() => new Vector2(1, 0.8f); protected override FramedReplayInputHandler CreateReplayInputHandler(Replay replay) => new ManiaFramedReplayInputHandler(replay, this); diff --git a/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs b/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs index 17521f8992..7f8cbce78e 100644 --- a/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs +++ b/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs @@ -27,21 +27,8 @@ namespace osu.Game.Rulesets.Osu.UI public static readonly Vector2 BASE_SIZE = new Vector2(512, 384); - public override Vector2 Size - { - get - { - if (Parent == null) - return Vector2.Zero; - - var parentSize = Parent.DrawSize; - var aspectSize = parentSize.X * 0.75f < parentSize.Y ? new Vector2(parentSize.X, parentSize.X * 0.75f) : new Vector2(parentSize.Y * 4f / 3f, parentSize.Y); - - return new Vector2(aspectSize.X / parentSize.X, aspectSize.Y / parentSize.Y) * base.Size; - } - } - - public OsuPlayfield() : base(BASE_SIZE.X) + public OsuPlayfield() + : base(BASE_SIZE.X) { Anchor = Anchor.Centre; Origin = Anchor.Centre; diff --git a/osu.Game.Rulesets.Osu/UI/OsuRulesetContainer.cs b/osu.Game.Rulesets.Osu/UI/OsuRulesetContainer.cs index 526348062f..9cb6a13cb2 100644 --- a/osu.Game.Rulesets.Osu/UI/OsuRulesetContainer.cs +++ b/osu.Game.Rulesets.Osu/UI/OsuRulesetContainer.cs @@ -50,7 +50,11 @@ namespace osu.Game.Rulesets.Osu.UI protected override FramedReplayInputHandler CreateReplayInputHandler(Replay replay) => new OsuReplayInputHandler(replay); - protected override Vector2 GetPlayfieldAspectAdjust() => new Vector2(0.75f); + protected override Vector2 GetAspectAdjustedSize() + { + var aspectSize = DrawSize.X * 0.75f < DrawSize.Y ? new Vector2(DrawSize.X, DrawSize.X * 0.75f) : new Vector2(DrawSize.Y * 4f / 3f, DrawSize.Y); + return new Vector2(aspectSize.X / DrawSize.X, aspectSize.Y / DrawSize.Y) * 0.75f; + } protected override CursorContainer CreateCursor() => new GameplayCursor(); } diff --git a/osu.Game.Rulesets.Taiko/UI/TaikoRulesetContainer.cs b/osu.Game.Rulesets.Taiko/UI/TaikoRulesetContainer.cs index 1b9821d698..8342009e80 100644 --- a/osu.Game.Rulesets.Taiko/UI/TaikoRulesetContainer.cs +++ b/osu.Game.Rulesets.Taiko/UI/TaikoRulesetContainer.cs @@ -78,7 +78,7 @@ namespace osu.Game.Rulesets.Taiko.UI } } - protected override Vector2 GetPlayfieldAspectAdjust() + protected override Vector2 GetAspectAdjustedSize() { const float default_relative_height = TaikoPlayfield.DEFAULT_HEIGHT / 768; const float default_aspect = 16f / 9f; diff --git a/osu.Game.Tests/Beatmaps/IO/ImportBeatmapTest.cs b/osu.Game.Tests/Beatmaps/IO/ImportBeatmapTest.cs index cade50a9f3..6428881b54 100644 --- a/osu.Game.Tests/Beatmaps/IO/ImportBeatmapTest.cs +++ b/osu.Game.Tests/Beatmaps/IO/ImportBeatmapTest.cs @@ -165,7 +165,7 @@ namespace osu.Game.Tests.Beatmaps.IO var temp = prepareTempCopy(osz_path); Assert.IsTrue(File.Exists(temp)); - var importer = new BeatmapIPCChannel(client); + var importer = new ArchiveImportIPCChannel(client); if (!importer.ImportAsync(temp).Wait(10000)) Assert.Fail(@"IPC took too long to send"); @@ -209,7 +209,11 @@ namespace osu.Game.Tests.Beatmaps.IO Assert.IsTrue(File.Exists(temp)); - var imported = osu.Dependencies.Get().Import(temp); + var manager = osu.Dependencies.Get(); + + manager.Import(temp); + + var imported = manager.GetAllUsableBeatmapSets(); ensureLoaded(osu); diff --git a/osu.Game.Tests/Beatmaps/IO/OszArchiveReaderTest.cs b/osu.Game.Tests/Beatmaps/IO/OszArchiveReaderTest.cs index 44eb385e22..7a1c6d9b89 100644 --- a/osu.Game.Tests/Beatmaps/IO/OszArchiveReaderTest.cs +++ b/osu.Game.Tests/Beatmaps/IO/OszArchiveReaderTest.cs @@ -5,9 +5,9 @@ using System.IO; using System.Linq; using NUnit.Framework; using osu.Game.Beatmaps; -using osu.Game.Beatmaps.IO; using osu.Game.Tests.Resources; using osu.Game.Beatmaps.Formats; +using osu.Game.IO.Archives; namespace osu.Game.Tests.Beatmaps.IO { @@ -19,7 +19,7 @@ namespace osu.Game.Tests.Beatmaps.IO { using (var osz = Resource.OpenResource("Beatmaps.241526 Soleily - Renatus.osz")) { - var reader = new OszArchiveReader(osz); + var reader = new ZipArchiveReader(osz); string[] expected = { "Soleily - Renatus (Deif) [Platter].osu", @@ -46,7 +46,7 @@ namespace osu.Game.Tests.Beatmaps.IO { using (var osz = Resource.OpenResource("Beatmaps.241526 Soleily - Renatus.osz")) { - var reader = new OszArchiveReader(osz); + var reader = new ZipArchiveReader(osz); BeatmapMetadata meta; using (var stream = new StreamReader(reader.GetStream("Soleily - Renatus (Deif) [Platter].osu"))) @@ -71,7 +71,7 @@ namespace osu.Game.Tests.Beatmaps.IO { using (var osz = Resource.OpenResource("Beatmaps.241526 Soleily - Renatus.osz")) { - var reader = new OszArchiveReader(osz); + var reader = new ZipArchiveReader(osz); using (var stream = new StreamReader( reader.GetStream("Soleily - Renatus (Deif) [Platter].osu"))) { diff --git a/osu.Game.Tests/Visual/TestCasePlaySongSelect.cs b/osu.Game.Tests/Visual/TestCasePlaySongSelect.cs index 8bb0d152f6..13b2be9fdb 100644 --- a/osu.Game.Tests/Visual/TestCasePlaySongSelect.cs +++ b/osu.Game.Tests/Visual/TestCasePlaySongSelect.cs @@ -75,7 +75,7 @@ namespace osu.Game.Tests.Visual { if (deleteMaps) { - manager.DeleteAll(); + manager.Delete(manager.GetAllUsableBeatmapSets()); game.Beatmap.SetDefault(); } diff --git a/osu.Game/Beatmaps/BeatmapManager.cs b/osu.Game/Beatmaps/BeatmapManager.cs index 47773528a6..1d6d8b6726 100644 --- a/osu.Game/Beatmaps/BeatmapManager.cs +++ b/osu.Game/Beatmaps/BeatmapManager.cs @@ -7,17 +7,14 @@ using System.IO; using System.Linq; using System.Linq.Expressions; using System.Threading.Tasks; -using Ionic.Zip; using Microsoft.EntityFrameworkCore; using osu.Framework.Extensions; using osu.Framework.Logging; using osu.Framework.Platform; using osu.Game.Beatmaps.Formats; -using osu.Game.Beatmaps.IO; using osu.Game.Database; using osu.Game.Graphics; -using osu.Game.IO; -using osu.Game.IPC; +using osu.Game.IO.Archives; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Overlays.Notifications; @@ -28,23 +25,13 @@ namespace osu.Game.Beatmaps /// /// Handles the storage and retrieval of Beatmaps/WorkingBeatmaps. /// - public partial class BeatmapManager + public partial class BeatmapManager : ArchiveModelManager { - /// - /// Fired when a new becomes available in the database. - /// - public event Action BeatmapSetAdded; - /// /// Fired when a single difficulty has been hidden. /// public event Action BeatmapHidden; - /// - /// Fired when a is removed from the database. - /// - public event Action BeatmapSetRemoved; - /// /// Fired when a single difficulty has been restored. /// @@ -60,9 +47,7 @@ namespace osu.Game.Beatmaps /// public WorkingBeatmap DefaultBeatmap { private get; set; } - private readonly IDatabaseContextFactory contextFactory; - - private readonly FileStore files; + public override string[] HandledExtensions => new[] { ".osz" }; private readonly RulesetStore rulesets; @@ -72,150 +57,56 @@ namespace osu.Game.Beatmaps private readonly List currentDownloads = new List(); - // ReSharper disable once NotAccessedField.Local (we should keep a reference to this so it is not finalised) - private BeatmapIPCChannel ipc; - - /// - /// Set an endpoint for notifications to be posted to. - /// - public Action PostNotification { private get; set; } - /// /// Set a storage with access to an osu-stable install for import purposes. /// public Func GetStableStorage { private get; set; } public BeatmapManager(Storage storage, IDatabaseContextFactory contextFactory, RulesetStore rulesets, APIAccess api, IIpcHost importHost = null) + : base(storage, contextFactory, new BeatmapStore(contextFactory), importHost) { - this.contextFactory = contextFactory; - - beatmaps = new BeatmapStore(contextFactory); - - beatmaps.BeatmapSetAdded += s => BeatmapSetAdded?.Invoke(s); - beatmaps.BeatmapSetRemoved += s => BeatmapSetRemoved?.Invoke(s); + beatmaps = (BeatmapStore)ModelStore; beatmaps.BeatmapHidden += b => BeatmapHidden?.Invoke(b); beatmaps.BeatmapRestored += b => BeatmapRestored?.Invoke(b); - files = new FileStore(contextFactory, storage); - this.rulesets = rulesets; this.api = api; - - if (importHost != null) - ipc = new BeatmapIPCChannel(importHost, this); - - beatmaps.Cleanup(); } - /// - /// Import one or more from filesystem . - /// This will post notifications tracking progress. - /// - /// One or more beatmap locations on disk. - public List Import(params string[] paths) + protected override void Populate(BeatmapSetInfo model, ArchiveReader archive) { - var notification = new ProgressNotification + model.Beatmaps = createBeatmapDifficulties(archive); + + // remove metadata from difficulties where it matches the set + foreach (BeatmapInfo b in model.Beatmaps) + if (model.Metadata.Equals(b.Metadata)) + b.Metadata = null; + } + + protected override BeatmapSetInfo CheckForExisting(BeatmapSetInfo model) + { + // check if this beatmap has already been imported and exit early if so + var existingHashMatch = beatmaps.ConsumableItems.FirstOrDefault(b => b.Hash == model.Hash); + if (existingHashMatch != null) { - Text = "Beatmap import is initialising...", - CompletionText = "Import successful!", - Progress = 0, - State = ProgressNotificationState.Active, - }; + Undelete(existingHashMatch); + return existingHashMatch; + } - PostNotification?.Invoke(notification); - - List imported = new List(); - - int i = 0; - foreach (string path in paths) + // check if a set already exists with the same online id + if (model.OnlineBeatmapSetID != null) { - if (notification.State == ProgressNotificationState.Cancelled) - // user requested abort - return imported; - - try + var existingOnlineId = beatmaps.ConsumableItems.FirstOrDefault(b => b.OnlineBeatmapSetID == model.OnlineBeatmapSetID); + if (existingOnlineId != null) { - notification.Text = $"Importing ({i} of {paths.Length})\n{Path.GetFileName(path)}"; - using (ArchiveReader reader = getReaderFrom(path)) - imported.Add(Import(reader)); - - notification.Progress = (float)++i / paths.Length; - - // We may or may not want to delete the file depending on where it is stored. - // e.g. reconstructing/repairing database with beatmaps from default storage. - // Also, not always a single file, i.e. for LegacyFilesystemReader - // TODO: Add a check to prevent files from storage to be deleted. - try - { - if (File.Exists(path)) - File.Delete(path); - } - catch (Exception e) - { - Logger.Error(e, $@"Could not delete original file after import ({Path.GetFileName(path)})"); - } - } - catch (Exception e) - { - e = e.InnerException ?? e; - Logger.Error(e, $@"Could not import beatmap set ({Path.GetFileName(path)})"); + Delete(existingOnlineId); + beatmaps.PurgeDeletable(s => s.ID == existingOnlineId.ID); } } - notification.State = ProgressNotificationState.Completed; - return imported; + return null; } - /// - /// Import a beatmap from an . - /// - /// The beatmap to be imported. - public BeatmapSetInfo Import(ArchiveReader archive) - { - using (contextFactory.GetForWrite()) // used to share a context for full import. keep in mind this will block all writes. - { - // create a new set info (don't yet add to database) - var beatmapSet = createBeatmapSetInfo(archive); - - // check if this beatmap has already been imported and exit early if so - var existingHashMatch = beatmaps.BeatmapSets.FirstOrDefault(b => b.Hash == beatmapSet.Hash); - if (existingHashMatch != null) - { - Undelete(existingHashMatch); - return existingHashMatch; - } - - // check if a set already exists with the same online id - if (beatmapSet.OnlineBeatmapSetID != null) - { - var existingOnlineId = beatmaps.BeatmapSets.FirstOrDefault(b => b.OnlineBeatmapSetID == beatmapSet.OnlineBeatmapSetID); - if (existingOnlineId != null) - { - Delete(existingOnlineId); - beatmaps.Cleanup(s => s.ID == existingOnlineId.ID); - } - } - - beatmapSet.Files = createFileInfos(archive, files); - beatmapSet.Beatmaps = createBeatmapDifficulties(archive); - - // remove metadata from difficulties where it matches the set - foreach (BeatmapInfo b in beatmapSet.Beatmaps) - if (beatmapSet.Metadata.Equals(b.Metadata)) - b.Metadata = null; - - // import to beatmap store - Import(beatmapSet); - return beatmapSet; - } - } - - /// - /// Import a beatmap from a . - /// - /// The beatmap to be imported. - public void Import(BeatmapSetInfo beatmapSet) => beatmaps.Add(beatmapSet); - /// /// Downloads a beatmap. /// This will post notifications tracking progress. @@ -260,7 +151,7 @@ namespace osu.Game.Beatmaps { // This gets scheduled back to the update thread, but we want the import to run in the background. using (var stream = new MemoryStream(data)) - using (var archive = new OszArchiveReader(stream)) + using (var archive = new ZipArchiveReader(stream, beatmapSetInfo.ToString())) Import(archive); downloadNotification.State = ProgressNotificationState.Completed; @@ -300,95 +191,6 @@ namespace osu.Game.Beatmaps /// The object if it exists, or null. public DownloadBeatmapSetRequest GetExistingDownload(BeatmapSetInfo beatmap) => currentDownloads.Find(d => d.BeatmapSet.OnlineBeatmapSetID == beatmap.OnlineBeatmapSetID); - /// - /// Update a BeatmapSetInfo with all changes. TODO: This only supports very basic updates currently. - /// - /// The beatmap set to update. - public void Update(BeatmapSetInfo beatmap) => beatmaps.Update(beatmap); - - /// - /// Delete a beatmap from the manager. - /// Is a no-op for already deleted beatmaps. - /// - /// The beatmap set to delete. - public void Delete(BeatmapSetInfo beatmapSet) - { - using (var usage = contextFactory.GetForWrite()) - { - var context = usage.Context; - - context.ChangeTracker.AutoDetectChangesEnabled = false; - - // re-fetch the beatmap set on the import context. - beatmapSet = context.BeatmapSetInfo.Include(s => s.Files).ThenInclude(f => f.FileInfo).First(s => s.ID == beatmapSet.ID); - - if (beatmaps.Delete(beatmapSet)) - { - if (!beatmapSet.Protected) - files.Dereference(beatmapSet.Files.Select(f => f.FileInfo).ToArray()); - } - - context.ChangeTracker.AutoDetectChangesEnabled = true; - } - } - - /// - /// Restore all beatmaps that were previously deleted. - /// This will post notifications tracking progress. - /// - public void UndeleteAll() - { - var deleteMaps = QueryBeatmapSets(bs => bs.DeletePending).ToList(); - - if (!deleteMaps.Any()) return; - - var notification = new ProgressNotification - { - CompletionText = "Restored all deleted beatmaps!", - Progress = 0, - State = ProgressNotificationState.Active, - }; - - PostNotification?.Invoke(notification); - - int i = 0; - - foreach (var bs in deleteMaps) - { - if (notification.State == ProgressNotificationState.Cancelled) - // user requested abort - return; - - notification.Text = $"Restoring ({i} of {deleteMaps.Count})"; - notification.Progress = (float)++i / deleteMaps.Count; - Undelete(bs); - } - - notification.State = ProgressNotificationState.Completed; - } - - /// - /// Restore a beatmap that was previously deleted. Is a no-op if the beatmap is not in a deleted state, or has its protected flag set. - /// - /// The beatmap to restore - public void Undelete(BeatmapSetInfo beatmapSet) - { - if (beatmapSet.Protected) - return; - - using (var usage = contextFactory.GetForWrite()) - { - usage.Context.ChangeTracker.AutoDetectChangesEnabled = false; - - if (!beatmaps.Undelete(beatmapSet)) return; - - if (!beatmapSet.Protected) - files.Reference(beatmapSet.Files.Select(f => f.FileInfo).ToArray()); - - usage.Context.ChangeTracker.AutoDetectChangesEnabled = true; - } - } - /// /// Delete a beatmap difficulty. /// @@ -415,7 +217,7 @@ namespace osu.Game.Beatmaps if (beatmapInfo.Metadata == null) beatmapInfo.Metadata = beatmapInfo.BeatmapSet.Metadata; - WorkingBeatmap working = new BeatmapManagerWorkingBeatmap(files.Store, beatmapInfo); + WorkingBeatmap working = new BeatmapManagerWorkingBeatmap(Files.Store, beatmapInfo); previous?.TransferTo(working); @@ -427,27 +229,20 @@ namespace osu.Game.Beatmaps /// /// The query. /// The first result for the provided query, or null if no results were found. - public BeatmapSetInfo QueryBeatmapSet(Expression> query) => beatmaps.BeatmapSets.AsNoTracking().FirstOrDefault(query); - - /// - /// Refresh an existing instance of a from the store. - /// - /// A stale instance. - /// A fresh instance. - public BeatmapSetInfo Refresh(BeatmapSetInfo beatmapSet) => QueryBeatmapSet(s => s.ID == beatmapSet.ID); + public BeatmapSetInfo QueryBeatmapSet(Expression> query) => beatmaps.ConsumableItems.AsNoTracking().FirstOrDefault(query); /// /// Returns a list of all usable s. /// /// A list of available . - public List GetAllUsableBeatmapSets() => beatmaps.BeatmapSets.Where(s => !s.DeletePending).ToList(); + public List GetAllUsableBeatmapSets() => beatmaps.ConsumableItems.Where(s => !s.DeletePending && !s.Protected).ToList(); /// /// Perform a lookup query on available s. /// /// The query. /// Results from the provided query. - public IEnumerable QueryBeatmapSets(Expression> query) => beatmaps.BeatmapSets.AsNoTracking().Where(query); + public IEnumerable QueryBeatmapSets(Expression> query) => beatmaps.ConsumableItems.AsNoTracking().Where(query); /// /// Perform a lookup query on available s. @@ -484,54 +279,6 @@ namespace osu.Game.Beatmaps await Task.Factory.StartNew(() => Import(stable.GetDirectories("Songs")), TaskCreationOptions.LongRunning); } - /// - /// Delete all beatmaps. - /// This will post notifications tracking progress. - /// - public void DeleteAll() - { - var maps = GetAllUsableBeatmapSets(); - - if (maps.Count == 0) return; - - var notification = new ProgressNotification - { - Progress = 0, - CompletionText = "Deleted all beatmaps!", - State = ProgressNotificationState.Active, - }; - - PostNotification?.Invoke(notification); - - int i = 0; - - foreach (var b in maps) - { - if (notification.State == ProgressNotificationState.Cancelled) - // user requested abort - return; - - notification.Text = $"Deleting ({i} of {maps.Count})"; - notification.Progress = (float)++i / maps.Count; - Delete(b); - } - - notification.State = ProgressNotificationState.Completed; - } - - /// - /// Creates an from a valid storage path. - /// - /// A file or folder path resolving the beatmap content. - /// A reader giving access to the beatmap's content. - private ArchiveReader getReaderFrom(string path) - { - if (ZipFile.IsZipFile(path)) - // ReSharper disable once InconsistentlySynchronizedField - return new OszArchiveReader(files.Storage.GetStream(path)); - return new LegacyFilesystemReader(path); - } - /// /// Create a SHA-2 hash from the provided archive based on contained beatmap (.osu) file content. /// @@ -546,10 +293,7 @@ namespace osu.Game.Beatmaps return hashable.ComputeSHA2Hash(); } - /// - /// Create a from a provided archive. - /// - private BeatmapSetInfo createBeatmapSetInfo(ArchiveReader reader) + protected override BeatmapSetInfo CreateModel(ArchiveReader reader) { // let's make sure there are actually .osu files to import. string mapName = reader.Filenames.FirstOrDefault(f => f.EndsWith(".osu")); @@ -568,25 +312,6 @@ namespace osu.Game.Beatmaps }; } - /// - /// Create all required s for the provided archive, adding them to the global file store. - /// - private List createFileInfos(ArchiveReader reader, FileStore files) - { - List fileInfos = new List(); - - // import files to manager - foreach (string file in reader.Filenames) - using (Stream s = reader.GetStream(file)) - fileInfos.Add(new BeatmapSetFileInfo - { - Filename = file, - FileInfo = files.Add(s) - }); - - return fileInfos; - } - /// /// Create all required s for the provided archive. /// diff --git a/osu.Game/Beatmaps/BeatmapSetFileInfo.cs b/osu.Game/Beatmaps/BeatmapSetFileInfo.cs index ae4a6772a2..e88af6ed30 100644 --- a/osu.Game/Beatmaps/BeatmapSetFileInfo.cs +++ b/osu.Game/Beatmaps/BeatmapSetFileInfo.cs @@ -3,11 +3,12 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; +using osu.Game.Database; using osu.Game.IO; namespace osu.Game.Beatmaps { - public class BeatmapSetFileInfo + public class BeatmapSetFileInfo : INamedFileInfo { [DatabaseGenerated(DatabaseGeneratedOption.Identity)] public int ID { get; set; } diff --git a/osu.Game/Beatmaps/BeatmapSetInfo.cs b/osu.Game/Beatmaps/BeatmapSetInfo.cs index 982e41c92c..1736e3fa90 100644 --- a/osu.Game/Beatmaps/BeatmapSetInfo.cs +++ b/osu.Game/Beatmaps/BeatmapSetInfo.cs @@ -8,7 +8,7 @@ using osu.Game.Database; namespace osu.Game.Beatmaps { - public class BeatmapSetInfo : IHasPrimaryKey + public class BeatmapSetInfo : IHasPrimaryKey, IHasFiles, ISoftDelete { [DatabaseGenerated(DatabaseGeneratedOption.Identity)] public int ID { get; set; } diff --git a/osu.Game/Beatmaps/BeatmapStore.cs b/osu.Game/Beatmaps/BeatmapStore.cs index 29373c0715..93ad1badd2 100644 --- a/osu.Game/Beatmaps/BeatmapStore.cs +++ b/osu.Game/Beatmaps/BeatmapStore.cs @@ -2,8 +2,8 @@ // Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE using System; +using System.Collections.Generic; using System.Linq; -using System.Linq.Expressions; using Microsoft.EntityFrameworkCore; using osu.Game.Database; @@ -12,11 +12,8 @@ namespace osu.Game.Beatmaps /// /// Handles the storage and retrieval of Beatmaps/BeatmapSets to the database backing /// - public class BeatmapStore : DatabaseBackedStore + public class BeatmapStore : MutableDatabaseBackedStore { - public event Action BeatmapSetAdded; - public event Action BeatmapSetRemoved; - public event Action BeatmapHidden; public event Action BeatmapRestored; @@ -25,88 +22,6 @@ namespace osu.Game.Beatmaps { } - /// - /// Add a to the database. - /// - /// The beatmap to add. - public void Add(BeatmapSetInfo beatmapSet) - { - using (var usage = ContextFactory.GetForWrite()) - { - var context = usage.Context; - - foreach (var beatmap in beatmapSet.Beatmaps.Where(b => b.Metadata != null)) - { - // If we detect a new metadata object it'll be attached to the current context so it can be reused - // to prevent duplicate entries when persisting. To accomplish this we look in the cache (.Local) - // of the corresponding table (.Set()) for matching entries to our criteria. - var contextMetadata = context.Set().Local.SingleOrDefault(e => e.Equals(beatmap.Metadata)); - if (contextMetadata != null) - beatmap.Metadata = contextMetadata; - else - context.BeatmapMetadata.Attach(beatmap.Metadata); - } - - context.BeatmapSetInfo.Attach(beatmapSet); - - BeatmapSetAdded?.Invoke(beatmapSet); - } - } - - /// - /// Update a in the database. TODO: This only supports very basic updates currently. - /// - /// The beatmap to update. - public void Update(BeatmapSetInfo beatmapSet) - { - BeatmapSetRemoved?.Invoke(beatmapSet); - - using (var usage = ContextFactory.GetForWrite()) - usage.Context.BeatmapSetInfo.Update(beatmapSet); - - BeatmapSetAdded?.Invoke(beatmapSet); - } - - /// - /// Delete a from the database. - /// - /// The beatmap to delete. - /// Whether the beatmap's was changed. - public bool Delete(BeatmapSetInfo beatmapSet) - { - using (ContextFactory.GetForWrite()) - { - Refresh(ref beatmapSet, BeatmapSets); - - if (beatmapSet.DeletePending) return false; - - beatmapSet.DeletePending = true; - } - - BeatmapSetRemoved?.Invoke(beatmapSet); - return true; - } - - /// - /// Restore a previously deleted . - /// - /// The beatmap to restore. - /// Whether the beatmap's was changed. - public bool Undelete(BeatmapSetInfo beatmapSet) - { - using (ContextFactory.GetForWrite()) - { - Refresh(ref beatmapSet, BeatmapSets); - - if (!beatmapSet.DeletePending) return false; - - beatmapSet.DeletePending = false; - } - - BeatmapSetAdded?.Invoke(beatmapSet); - return true; - } - /// /// Hide a in the database. /// @@ -119,12 +34,10 @@ namespace osu.Game.Beatmaps Refresh(ref beatmap, Beatmaps); if (beatmap.Hidden) return false; - beatmap.Hidden = true; - - BeatmapHidden?.Invoke(beatmap); } + BeatmapHidden?.Invoke(beatmap); return true; } @@ -140,7 +53,6 @@ namespace osu.Game.Beatmaps Refresh(ref beatmap, Beatmaps); if (!beatmap.Hidden) return false; - beatmap.Hidden = false; } @@ -148,46 +60,38 @@ namespace osu.Game.Beatmaps return true; } - public override void Cleanup() => Cleanup(_ => true); + protected override IQueryable AddIncludesForDeletion(IQueryable query) => + base.AddIncludesForDeletion(query) + .Include(s => s.Beatmaps).ThenInclude(b => b.Metadata) + .Include(s => s.Beatmaps).ThenInclude(b => b.BaseDifficulty) + .Include(s => s.Metadata); - public void Cleanup(Expression> query) + protected override IQueryable AddIncludesForConsumption(IQueryable query) => + base.AddIncludesForConsumption(query) + .Include(s => s.Metadata) + .Include(s => s.Beatmaps).ThenInclude(s => s.Ruleset) + .Include(s => s.Beatmaps).ThenInclude(b => b.BaseDifficulty) + .Include(s => s.Beatmaps).ThenInclude(b => b.Metadata) + .Include(s => s.Files).ThenInclude(f => f.FileInfo); + + protected override void Purge(List items, OsuDbContext context) { - using (var usage = ContextFactory.GetForWrite()) - { - var context = usage.Context; + // metadata is M-N so we can't rely on cascades + context.BeatmapMetadata.RemoveRange(items.Select(s => s.Metadata)); + context.BeatmapMetadata.RemoveRange(items.SelectMany(s => s.Beatmaps.Select(b => b.Metadata).Where(m => m != null))); - var purgeable = context.BeatmapSetInfo.Where(s => s.DeletePending && !s.Protected) - .Where(query) - .Include(s => s.Beatmaps).ThenInclude(b => b.Metadata) - .Include(s => s.Beatmaps).ThenInclude(b => b.BaseDifficulty) - .Include(s => s.Metadata).ToList(); + // todo: we can probably make cascades work here with a FK in BeatmapDifficulty. just make to make it work correctly. + context.BeatmapDifficulty.RemoveRange(items.SelectMany(s => s.Beatmaps.Select(b => b.BaseDifficulty))); - if (!purgeable.Any()) return; - - // metadata is M-N so we can't rely on cascades - context.BeatmapMetadata.RemoveRange(purgeable.Select(s => s.Metadata)); - context.BeatmapMetadata.RemoveRange(purgeable.SelectMany(s => s.Beatmaps.Select(b => b.Metadata).Where(m => m != null))); - - // todo: we can probably make cascades work here with a FK in BeatmapDifficulty. just make to make it work correctly. - context.BeatmapDifficulty.RemoveRange(purgeable.SelectMany(s => s.Beatmaps.Select(b => b.BaseDifficulty))); - - // cascades down to beatmaps. - context.BeatmapSetInfo.RemoveRange(purgeable); - } + base.Purge(items, context); } - public IQueryable BeatmapSets => ContextFactory.Get().BeatmapSetInfo - .Include(s => s.Metadata) - .Include(s => s.Beatmaps).ThenInclude(s => s.Ruleset) - .Include(s => s.Beatmaps).ThenInclude(b => b.BaseDifficulty) - .Include(s => s.Beatmaps).ThenInclude(b => b.Metadata) - .Include(s => s.Files).ThenInclude(f => f.FileInfo); - - public IQueryable Beatmaps => ContextFactory.Get().BeatmapInfo - .Include(b => b.BeatmapSet).ThenInclude(s => s.Metadata) - .Include(b => b.BeatmapSet).ThenInclude(s => s.Files).ThenInclude(f => f.FileInfo) - .Include(b => b.Metadata) - .Include(b => b.Ruleset) - .Include(b => b.BaseDifficulty); + public IQueryable Beatmaps => + ContextFactory.Get().BeatmapInfo + .Include(b => b.BeatmapSet).ThenInclude(s => s.Metadata) + .Include(b => b.BeatmapSet).ThenInclude(s => s.Files).ThenInclude(f => f.FileInfo) + .Include(b => b.Metadata) + .Include(b => b.Ruleset) + .Include(b => b.BaseDifficulty); } } diff --git a/osu.Game/Database/ArchiveModelManager.cs b/osu.Game/Database/ArchiveModelManager.cs new file mode 100644 index 0000000000..a65593ff82 --- /dev/null +++ b/osu.Game/Database/ArchiveModelManager.cs @@ -0,0 +1,337 @@ +// Copyright (c) 2007-2018 ppy Pty Ltd . +// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Ionic.Zip; +using Microsoft.EntityFrameworkCore; +using osu.Framework.Logging; +using osu.Framework.Platform; +using osu.Game.IO; +using osu.Game.IO.Archives; +using osu.Game.IPC; +using osu.Game.Overlays.Notifications; +using FileInfo = osu.Game.IO.FileInfo; + +namespace osu.Game.Database +{ + /// + /// Encapsulates a model store class to give it import functionality. + /// Adds cross-functionality with to give access to the central file store for the provided model. + /// + /// The model type. + /// The associated file join type. + public abstract class ArchiveModelManager : ICanAcceptFiles + where TModel : class, IHasFiles, IHasPrimaryKey, ISoftDelete + where TFileModel : INamedFileInfo, new() + { + /// + /// Set an endpoint for notifications to be posted to. + /// + public Action PostNotification { protected get; set; } + + /// + /// Fired when a new becomes available in the database. + /// + public event Action ItemAdded; + + /// + /// Fired when a is removed from the database. + /// + public event Action ItemRemoved; + + public virtual string[] HandledExtensions => new[] { ".zip" }; + + protected readonly FileStore Files; + + protected readonly IDatabaseContextFactory ContextFactory; + + protected readonly MutableDatabaseBackedStore ModelStore; + + // ReSharper disable once NotAccessedField.Local (we should keep a reference to this so it is not finalised) + private ArchiveImportIPCChannel ipc; + + protected ArchiveModelManager(Storage storage, IDatabaseContextFactory contextFactory, MutableDatabaseBackedStore modelStore, IIpcHost importHost = null) + { + ContextFactory = contextFactory; + + ModelStore = modelStore; + ModelStore.ItemAdded += s => ItemAdded?.Invoke(s); + ModelStore.ItemRemoved += s => ItemRemoved?.Invoke(s); + + Files = new FileStore(contextFactory, storage); + + if (importHost != null) + ipc = new ArchiveImportIPCChannel(importHost, this); + + ModelStore.Cleanup(); + } + + /// + /// Import one or more items from filesystem . + /// This will post notifications tracking progress. + /// + /// One or more archive locations on disk. + public void Import(params string[] paths) + { + var notification = new ProgressNotification + { + Text = "Import is initialising...", + CompletionText = "Import successful!", + Progress = 0, + State = ProgressNotificationState.Active, + }; + + PostNotification?.Invoke(notification); + + List imported = new List(); + + int i = 0; + foreach (string path in paths) + { + if (notification.State == ProgressNotificationState.Cancelled) + // user requested abort + return; + + try + { + notification.Text = $"Importing ({i} of {paths.Length})\n{Path.GetFileName(path)}"; + using (ArchiveReader reader = getReaderFrom(path)) + imported.Add(Import(reader)); + + notification.Progress = (float)++i / paths.Length; + + // We may or may not want to delete the file depending on where it is stored. + // e.g. reconstructing/repairing database with items from default storage. + // Also, not always a single file, i.e. for LegacyFilesystemReader + // TODO: Add a check to prevent files from storage to be deleted. + try + { + if (File.Exists(path)) + File.Delete(path); + } + catch (Exception e) + { + Logger.Error(e, $@"Could not delete original file after import ({Path.GetFileName(path)})"); + } + } + catch (Exception e) + { + e = e.InnerException ?? e; + Logger.Error(e, $@"Could not import ({Path.GetFileName(path)})"); + } + } + + notification.State = ProgressNotificationState.Completed; + } + + /// + /// Import an item from an . + /// + /// The archive to be imported. + public TModel Import(ArchiveReader archive) + { + using (ContextFactory.GetForWrite()) // used to share a context for full import. keep in mind this will block all writes. + { + // create a new model (don't yet add to database) + var item = CreateModel(archive); + + var existing = CheckForExisting(item); + + if (existing != null) return existing; + + item.Files = createFileInfos(archive, Files); + + Populate(item, archive); + + // import to store + ModelStore.Add(item); + + return item; + } + } + + /// + /// Import an item from a . + /// + /// The model to be imported. + public void Import(TModel item) => ModelStore.Add(item); + + /// + /// Perform an update of the specified item. + /// TODO: Support file changes. + /// + /// The item to update. + public void Update(TModel item) => ModelStore.Update(item); + + /// + /// Delete an item from the manager. + /// Is a no-op for already deleted items. + /// + /// The item to delete. + public void Delete(TModel item) + { + using (var usage = ContextFactory.GetForWrite()) + { + var context = usage.Context; + + context.ChangeTracker.AutoDetectChangesEnabled = false; + + // re-fetch the model on the import context. + var foundModel = queryModel().Include(s => s.Files).ThenInclude(f => f.FileInfo).First(s => s.ID == item.ID); + + if (foundModel.DeletePending) return; + + if (ModelStore.Delete(foundModel)) + Files.Dereference(foundModel.Files.Select(f => f.FileInfo).ToArray()); + + context.ChangeTracker.AutoDetectChangesEnabled = true; + } + } + + /// + /// Delete multiple items. + /// This will post notifications tracking progress. + /// + public void Delete(List items) + { + if (items.Count == 0) return; + + var notification = new ProgressNotification + { + Progress = 0, + CompletionText = "Deleted all beatmaps!", + State = ProgressNotificationState.Active, + }; + + PostNotification?.Invoke(notification); + + int i = 0; + + using (ContextFactory.GetForWrite()) + { + foreach (var b in items) + { + if (notification.State == ProgressNotificationState.Cancelled) + // user requested abort + return; + + notification.Text = $"Deleting ({i} of {items.Count})"; + notification.Progress = (float)++i / items.Count; + Delete(b); + } + } + + notification.State = ProgressNotificationState.Completed; + } + + /// + /// Restore multiple items that were previously deleted. + /// This will post notifications tracking progress. + /// + public void Undelete(List items) + { + if (!items.Any()) return; + + var notification = new ProgressNotification + { + CompletionText = "Restored all deleted items!", + Progress = 0, + State = ProgressNotificationState.Active, + }; + + PostNotification?.Invoke(notification); + + int i = 0; + + using (ContextFactory.GetForWrite()) + { + foreach (var item in items) + { + if (notification.State == ProgressNotificationState.Cancelled) + // user requested abort + return; + + notification.Text = $"Restoring ({i} of {items.Count})"; + notification.Progress = (float)++i / items.Count; + Undelete(item); + } + } + + notification.State = ProgressNotificationState.Completed; + } + + /// + /// Restore an item that was previously deleted. Is a no-op if the item is not in a deleted state, or has its protected flag set. + /// + /// The item to restore + public void Undelete(TModel item) + { + using (var usage = ContextFactory.GetForWrite()) + { + usage.Context.ChangeTracker.AutoDetectChangesEnabled = false; + + if (!ModelStore.Undelete(item)) return; + + Files.Reference(item.Files.Select(f => f.FileInfo).ToArray()); + + usage.Context.ChangeTracker.AutoDetectChangesEnabled = true; + } + } + + /// + /// Create all required s for the provided archive, adding them to the global file store. + /// + private List createFileInfos(ArchiveReader reader, FileStore files) + { + var fileInfos = new List(); + + // import files to manager + foreach (string file in reader.Filenames) + using (Stream s = reader.GetStream(file)) + fileInfos.Add(new TFileModel + { + Filename = file, + FileInfo = files.Add(s) + }); + + return fileInfos; + } + + /// + /// Create a barebones model from the provided archive. + /// Actual expensive population should be done in ; this should just prepare for duplicate checking. + /// + /// The archive to create the model for. + /// A model populated with minimal information. + protected abstract TModel CreateModel(ArchiveReader archive); + + /// + /// Populate the provided model completely from the given archive. + /// After this method, the model should be in a state ready to commit to a store. + /// + /// The model to populate. + /// The archive to use as a reference for population. + protected virtual void Populate(TModel model, ArchiveReader archive) + { + } + + protected virtual TModel CheckForExisting(TModel model) => null; + + private DbSet queryModel() => ContextFactory.Get().Set(); + + /// + /// Creates an from a valid storage path. + /// + /// A file or folder path resolving the archive content. + /// A reader giving access to the archive's content. + private ArchiveReader getReaderFrom(string path) + { + if (ZipFile.IsZipFile(path)) + return new ZipArchiveReader(Files.Storage.GetStream(path), Path.GetFileName(path)); + return new LegacyFilesystemReader(path); + } + } +} diff --git a/osu.Game/Database/DatabaseBackedStore.cs b/osu.Game/Database/DatabaseBackedStore.cs index cf46b66422..0fafb77339 100644 --- a/osu.Game/Database/DatabaseBackedStore.cs +++ b/osu.Game/Database/DatabaseBackedStore.cs @@ -1,7 +1,6 @@ // Copyright (c) 2007-2018 ppy Pty Ltd . // Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE -using System.Collections.Generic; using System.Linq; using Microsoft.EntityFrameworkCore; using osu.Framework.Platform; @@ -23,7 +22,7 @@ namespace osu.Game.Database /// The object to use as a reference when negotiating a local instance. /// An optional lookup source which will be used to query and populate a freshly retrieved replacement. If not provided, the refreshed object will still be returned but will not have any includes. /// A valid EF-stored type. - protected virtual void Refresh(ref T obj, IEnumerable lookupSource = null) where T : class, IHasPrimaryKey + protected virtual void Refresh(ref T obj, IQueryable lookupSource = null) where T : class, IHasPrimaryKey { using (var usage = ContextFactory.GetForWrite()) { diff --git a/osu.Game/Database/DatabaseContextFactory.cs b/osu.Game/Database/DatabaseContextFactory.cs index 2068d6bd8a..712ed2d0cc 100644 --- a/osu.Game/Database/DatabaseContextFactory.cs +++ b/osu.Game/Database/DatabaseContextFactory.cs @@ -26,7 +26,8 @@ namespace osu.Game.Database } /// - /// Get a context for read-only usage. + /// Get a context for the current thread for read-only usage. + /// If a is in progress, the existing write-safe context will be returned. /// public OsuDbContext Get() => threadContexts.Value; diff --git a/osu.Game/Database/ICanAcceptFiles.cs b/osu.Game/Database/ICanAcceptFiles.cs new file mode 100644 index 0000000000..ab26525619 --- /dev/null +++ b/osu.Game/Database/ICanAcceptFiles.cs @@ -0,0 +1,22 @@ +// Copyright (c) 2007-2018 ppy Pty Ltd . +// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE + +namespace osu.Game.Database +{ + /// + /// A class which can accept files for importing. + /// + public interface ICanAcceptFiles + { + /// + /// Import the specified paths. + /// + /// The files which should be imported. + void Import(params string[] paths); + + /// + /// An array of accepted file extensions (in the standard format of ".abc"). + /// + string[] HandledExtensions { get; } + } +} diff --git a/osu.Game/Database/IHasFiles.cs b/osu.Game/Database/IHasFiles.cs new file mode 100644 index 0000000000..deaf75360c --- /dev/null +++ b/osu.Game/Database/IHasFiles.cs @@ -0,0 +1,16 @@ +// Copyright (c) 2007-2018 ppy Pty Ltd . +// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE + +using System.Collections.Generic; + +namespace osu.Game.Database +{ + /// + /// A model that contains a list of files it is responsible for. + /// + /// The model representing a file. + public interface IHasFiles + { + List Files { get; set; } + } +} diff --git a/osu.Game/Database/INamedFileInfo.cs b/osu.Game/Database/INamedFileInfo.cs new file mode 100644 index 0000000000..8de451dd78 --- /dev/null +++ b/osu.Game/Database/INamedFileInfo.cs @@ -0,0 +1,16 @@ +// Copyright (c) 2007-2018 ppy Pty Ltd . +// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE + +using osu.Game.IO; + +namespace osu.Game.Database +{ + /// + /// Represent a join model which gives a filename and scope to a . + /// + public interface INamedFileInfo + { + FileInfo FileInfo { get; set; } + string Filename { get; set; } + } +} diff --git a/osu.Game/Database/ISoftDelete.cs b/osu.Game/Database/ISoftDelete.cs new file mode 100644 index 0000000000..c884d7af00 --- /dev/null +++ b/osu.Game/Database/ISoftDelete.cs @@ -0,0 +1,16 @@ +// Copyright (c) 2007-2018 ppy Pty Ltd . +// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE + +namespace osu.Game.Database +{ + /// + /// A model that can be deleted from user's view without being instantly lost. + /// + public interface ISoftDelete + { + /// + /// Whether this model is marked for future deletion. + /// + bool DeletePending { get; set; } + } +} diff --git a/osu.Game/Database/MutableDatabaseBackedStore.cs b/osu.Game/Database/MutableDatabaseBackedStore.cs new file mode 100644 index 0000000000..4ab55691f2 --- /dev/null +++ b/osu.Game/Database/MutableDatabaseBackedStore.cs @@ -0,0 +1,149 @@ +// Copyright (c) 2007-2018 ppy Pty Ltd . +// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using osu.Framework.Platform; + +namespace osu.Game.Database +{ + /// + /// A typed store which supports basic addition, deletion and updating for soft-deletable models. + /// + /// The databased model. + public abstract class MutableDatabaseBackedStore : DatabaseBackedStore + where T : class, IHasPrimaryKey, ISoftDelete + { + public event Action ItemAdded; + public event Action ItemRemoved; + + protected MutableDatabaseBackedStore(IDatabaseContextFactory contextFactory, Storage storage = null) + : base(contextFactory, storage) + { + } + + /// + /// Access items pre-populated with includes for consumption. + /// + public IQueryable ConsumableItems => AddIncludesForConsumption(ContextFactory.Get().Set()); + + /// + /// Add a to the database. + /// + /// The item to add. + public void Add(T item) + { + using (var usage = ContextFactory.GetForWrite()) + { + var context = usage.Context; + context.Attach(item); + } + + ItemAdded?.Invoke(item); + } + + /// + /// Update a in the database. + /// + /// The item to update. + public void Update(T item) + { + ItemRemoved?.Invoke(item); + + using (var usage = ContextFactory.GetForWrite()) + usage.Context.Update(item); + + ItemAdded?.Invoke(item); + } + + /// + /// Delete a from the database. + /// + /// The item to delete. + public bool Delete(T item) + { + using (ContextFactory.GetForWrite()) + { + Refresh(ref item); + + if (item.DeletePending) return false; + item.DeletePending = true; + } + + ItemRemoved?.Invoke(item); + return true; + } + + /// + /// Restore a from a deleted state. + /// + /// The item to undelete. + public bool Undelete(T item) + { + using (ContextFactory.GetForWrite()) + { + Refresh(ref item, ConsumableItems); + + if (!item.DeletePending) return false; + item.DeletePending = false; + } + + ItemAdded?.Invoke(item); + return true; + } + + /// + /// Allow implementations to add database-side includes or constraints when querying for consumption of items. + /// + /// The input query. + /// A potentially modified output query. + protected virtual IQueryable AddIncludesForConsumption(IQueryable query) => query; + + /// + /// Allow implementations to add database-side includes or constraints when deleting items. + /// Included properties could then be subsequently deleted by overriding . + /// + /// The input query. + /// A potentially modified output query. + protected virtual IQueryable AddIncludesForDeletion(IQueryable query) => query; + + /// + /// Called when removing an item completely from the database. + /// + /// The items to be purged. + /// The write context which can be used to perform subsequent deletions. + protected virtual void Purge(List items, OsuDbContext context) => context.RemoveRange(items); + + public override void Cleanup() + { + base.Cleanup(); + PurgeDeletable(); + } + + /// + /// Purge items in a pending delete state. + /// + /// An optional query limiting the scope of the purge. + public void PurgeDeletable(Expression> query = null) + { + using (var usage = ContextFactory.GetForWrite()) + { + var context = usage.Context; + + var lookup = context.Set().Where(s => s.DeletePending); + + if (query != null) lookup = lookup.Where(query); + + lookup = AddIncludesForDeletion(lookup); + + var purgeable = lookup.ToList(); + + if (!purgeable.Any()) return; + + Purge(purgeable, context); + } + } + } +} diff --git a/osu.Game/Beatmaps/IO/ArchiveReader.cs b/osu.Game/IO/Archives/ArchiveReader.cs similarity index 76% rename from osu.Game/Beatmaps/IO/ArchiveReader.cs rename to osu.Game/IO/Archives/ArchiveReader.cs index 453a03b882..351a6dff39 100644 --- a/osu.Game/Beatmaps/IO/ArchiveReader.cs +++ b/osu.Game/IO/Archives/ArchiveReader.cs @@ -6,7 +6,7 @@ using System.Collections.Generic; using System.IO; using osu.Framework.IO.Stores; -namespace osu.Game.Beatmaps.IO +namespace osu.Game.IO.Archives { public abstract class ArchiveReader : IDisposable, IResourceStore { @@ -17,6 +17,16 @@ namespace osu.Game.Beatmaps.IO public abstract void Dispose(); + /// + /// The name of this archive (usually the containing filename). + /// + public readonly string Name; + + protected ArchiveReader(string name) + { + Name = name; + } + public abstract IEnumerable Filenames { get; } public virtual byte[] Get(string name) diff --git a/osu.Game/Beatmaps/IO/LegacyFilesystemReader.cs b/osu.Game/IO/Archives/LegacyFilesystemReader.cs similarity index 86% rename from osu.Game/Beatmaps/IO/LegacyFilesystemReader.cs rename to osu.Game/IO/Archives/LegacyFilesystemReader.cs index 4a85f6f526..d6d80783db 100644 --- a/osu.Game/Beatmaps/IO/LegacyFilesystemReader.cs +++ b/osu.Game/IO/Archives/LegacyFilesystemReader.cs @@ -1,12 +1,12 @@ // Copyright (c) 2007-2018 ppy Pty Ltd . // Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE -using osu.Framework.IO.File; using System.Collections.Generic; using System.IO; using System.Linq; +using osu.Framework.IO.File; -namespace osu.Game.Beatmaps.IO +namespace osu.Game.IO.Archives { /// /// Reads an extracted legacy beatmap from disk. @@ -15,7 +15,7 @@ namespace osu.Game.Beatmaps.IO { private readonly string path; - public LegacyFilesystemReader(string path) + public LegacyFilesystemReader(string path) : base(Path.GetFileName(path)) { this.path = path; } diff --git a/osu.Game/Beatmaps/IO/OszArchiveReader.cs b/osu.Game/IO/Archives/ZipArchiveReader.cs similarity index 83% rename from osu.Game/Beatmaps/IO/OszArchiveReader.cs rename to osu.Game/IO/Archives/ZipArchiveReader.cs index e5c971889b..a772382b5e 100644 --- a/osu.Game/Beatmaps/IO/OszArchiveReader.cs +++ b/osu.Game/IO/Archives/ZipArchiveReader.cs @@ -6,14 +6,15 @@ using System.IO; using System.Linq; using Ionic.Zip; -namespace osu.Game.Beatmaps.IO +namespace osu.Game.IO.Archives { - public sealed class OszArchiveReader : ArchiveReader + public sealed class ZipArchiveReader : ArchiveReader { private readonly Stream archiveStream; private readonly ZipFile archive; - public OszArchiveReader(Stream archiveStream) + public ZipArchiveReader(Stream archiveStream, string name = null) + : base(name) { this.archiveStream = archiveStream; archive = ZipFile.Read(archiveStream); diff --git a/osu.Game/IPC/BeatmapIPCChannel.cs b/osu.Game/IPC/ArchiveImportIPCChannel.cs similarity index 56% rename from osu.Game/IPC/BeatmapIPCChannel.cs rename to osu.Game/IPC/ArchiveImportIPCChannel.cs index 64e5d526e6..9d7bf17c77 100644 --- a/osu.Game/IPC/BeatmapIPCChannel.cs +++ b/osu.Game/IPC/ArchiveImportIPCChannel.cs @@ -2,23 +2,25 @@ // Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE using System.Diagnostics; +using System.IO; +using System.Linq; using System.Threading.Tasks; using osu.Framework.Platform; -using osu.Game.Beatmaps; +using osu.Game.Database; namespace osu.Game.IPC { - public class BeatmapIPCChannel : IpcChannel + public class ArchiveImportIPCChannel : IpcChannel { - private readonly BeatmapManager beatmaps; + private readonly ICanAcceptFiles importer; - public BeatmapIPCChannel(IIpcHost host, BeatmapManager beatmaps = null) + public ArchiveImportIPCChannel(IIpcHost host, ICanAcceptFiles importer = null) : base(host) { - this.beatmaps = beatmaps; + this.importer = importer; MessageReceived += msg => { - Debug.Assert(beatmaps != null); + Debug.Assert(importer != null); ImportAsync(msg.Path).ContinueWith(t => { if (t.Exception != null) throw t.Exception; @@ -28,18 +30,19 @@ namespace osu.Game.IPC public async Task ImportAsync(string path) { - if (beatmaps == null) + if (importer == null) { //we want to contact a remote osu! to handle the import. - await SendMessageAsync(new BeatmapImportMessage { Path = path }); + await SendMessageAsync(new ArchiveImportMessage { Path = path }); return; } - beatmaps.Import(path); + if (importer.HandledExtensions.Contains(Path.GetExtension(path))) + importer.Import(path); } } - public class BeatmapImportMessage + public class ArchiveImportMessage { public string Path; } diff --git a/osu.Game/Online/API/APIDownloadRequest.cs b/osu.Game/Online/API/APIDownloadRequest.cs new file mode 100644 index 0000000000..2dff07a847 --- /dev/null +++ b/osu.Game/Online/API/APIDownloadRequest.cs @@ -0,0 +1,33 @@ +// Copyright (c) 2007-2018 ppy Pty Ltd . +// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE + +using osu.Framework.IO.Network; + +namespace osu.Game.Online.API +{ + public abstract class APIDownloadRequest : APIRequest + { + protected override WebRequest CreateWebRequest() + { + var request = new WebRequest(Uri); + request.DownloadProgress += request_Progress; + return request; + } + + private void request_Progress(long current, long total) => API.Scheduler.Add(delegate { Progress?.Invoke(current, total); }); + + protected APIDownloadRequest() + { + base.Success += onSuccess; + } + + private void onSuccess() + { + Success?.Invoke(WebRequest.ResponseData); + } + + public event APIProgressHandler Progress; + + public new event APISuccessHandler Success; + } +} diff --git a/osu.Game/Online/API/APIRequest.cs b/osu.Game/Online/API/APIRequest.cs index ce6f3c7c7d..35af8eefd7 100644 --- a/osu.Game/Online/API/APIRequest.cs +++ b/osu.Game/Online/API/APIRequest.cs @@ -27,32 +27,6 @@ namespace osu.Game.Online.API public new event APISuccessHandler Success; } - public abstract class APIDownloadRequest : APIRequest - { - protected override WebRequest CreateWebRequest() - { - var request = new WebRequest(Uri); - request.DownloadProgress += request_Progress; - return request; - } - - private void request_Progress(long current, long total) => API.Scheduler.Add(delegate { Progress?.Invoke(current, total); }); - - protected APIDownloadRequest() - { - base.Success += onSuccess; - } - - private void onSuccess() - { - Success?.Invoke(WebRequest.ResponseData); - } - - public event APIProgressHandler Progress; - - public new event APISuccessHandler Success; - } - /// /// AN API request with no specified response type. /// diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index 624179cfe1..14bc31aecf 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -105,6 +105,8 @@ namespace osu.Game { this.frameworkConfig = frameworkConfig; + ScoreStore.ScoreImported += score => Schedule(() => LoadScore(score)); + if (!Host.IsPrimaryInstance) { Logger.Log(@"osu! does not support multiple running instances.", LoggingTarget.Runtime, LogLevel.Error); @@ -114,7 +116,8 @@ namespace osu.Game if (args?.Length > 0) { var paths = args.Where(a => !a.StartsWith(@"-")); - Task.Run(() => BeatmapManager.Import(paths.ToArray())); + + Task.Run(() => Import(paths.ToArray())); } dependencies.CacheAs(this); diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs index 505577416d..de2a4d0b82 100644 --- a/osu.Game/OsuGameBase.cs +++ b/osu.Game/OsuGameBase.cs @@ -2,7 +2,10 @@ // Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE using System; +using System.Collections.Generic; using System.Diagnostics; +using System.IO; +using System.Linq; using System.Reflection; using osu.Framework.Allocation; using osu.Framework.Audio; @@ -30,7 +33,7 @@ using osu.Game.Rulesets.Scoring; namespace osu.Game { - public class OsuGameBase : Framework.Game, IOnlineComponent + public class OsuGameBase : Framework.Game, IOnlineComponent, ICanAcceptFiles { protected OsuConfigManager LocalConfig; @@ -114,6 +117,8 @@ namespace osu.Game dependencies.Cache(SettingsStore = new SettingsStore(contextFactory)); dependencies.Cache(new OsuColour()); + fileImporters.Add(BeatmapManager); + //this completely overrides the framework default. will need to change once we make a proper FontStore. dependencies.Cache(Fonts = new FontStore { ScaleAdjust = 100 }); @@ -257,5 +262,17 @@ namespace osu.Game base.Dispose(isDisposing); } + + private readonly List fileImporters = new List(); + + public void Import(params string[] paths) + { + var extension = Path.GetExtension(paths.First()); + + foreach (var importer in fileImporters) + if (importer.HandledExtensions.Contains(extension)) importer.Import(paths); + } + + public string[] HandledExtensions => fileImporters.SelectMany(i => i.HandledExtensions).ToArray(); } } diff --git a/osu.Game/Overlays/BeatmapSet/Header.cs b/osu.Game/Overlays/BeatmapSet/Header.cs index 36b6a9964a..3ce0dfee31 100644 --- a/osu.Game/Overlays/BeatmapSet/Header.cs +++ b/osu.Game/Overlays/BeatmapSet/Header.cs @@ -223,13 +223,13 @@ namespace osu.Game.Overlays.BeatmapSet tabsBg.Colour = colours.Gray3; this.beatmaps = beatmaps; - beatmaps.BeatmapSetAdded += handleBeatmapAdd; + beatmaps.ItemAdded += handleBeatmapAdd; } protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); - if (beatmaps != null) beatmaps.BeatmapSetAdded -= handleBeatmapAdd; + if (beatmaps != null) beatmaps.ItemAdded -= handleBeatmapAdd; } private void handleBeatmapAdd(BeatmapSetInfo beatmap) diff --git a/osu.Game/Overlays/DirectOverlay.cs b/osu.Game/Overlays/DirectOverlay.cs index 05b5bba09c..8d8a4aebaa 100644 --- a/osu.Game/Overlays/DirectOverlay.cs +++ b/osu.Game/Overlays/DirectOverlay.cs @@ -185,7 +185,7 @@ namespace osu.Game.Overlays resultCountsContainer.Colour = colours.Yellow; - beatmaps.BeatmapSetAdded += setAdded; + beatmaps.ItemAdded += setAdded; } private void setAdded(BeatmapSetInfo set) diff --git a/osu.Game/Overlays/Music/PlaylistOverlay.cs b/osu.Game/Overlays/Music/PlaylistOverlay.cs index 2125984785..ac7ec6257b 100644 --- a/osu.Game/Overlays/Music/PlaylistOverlay.cs +++ b/osu.Game/Overlays/Music/PlaylistOverlay.cs @@ -74,8 +74,8 @@ namespace osu.Game.Overlays.Music }, }; - beatmaps.BeatmapSetAdded += list.AddBeatmapSet; - beatmaps.BeatmapSetRemoved += list.RemoveBeatmapSet; + beatmaps.ItemAdded += list.AddBeatmapSet; + beatmaps.ItemRemoved += list.RemoveBeatmapSet; list.BeatmapSets = beatmaps.GetAllUsableBeatmapSets(); diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs index 1223310c74..d9fedd0225 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs @@ -41,7 +41,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance dialogOverlay?.Push(new DeleteAllBeatmapsDialog(() => { deleteButton.Enabled.Value = false; - Task.Run(() => beatmaps.DeleteAll()).ContinueWith(t => Schedule(() => deleteButton.Enabled.Value = true)); + Task.Run(() => beatmaps.Delete(beatmaps.GetAllUsableBeatmapSets())).ContinueWith(t => Schedule(() => deleteButton.Enabled.Value = true)); })); } }, @@ -64,7 +64,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance Action = () => { undeleteButton.Enabled.Value = false; - Task.Run(() => beatmaps.UndeleteAll()).ContinueWith(t => Schedule(() => undeleteButton.Enabled.Value = true)); + Task.Run(() => beatmaps.Undelete(beatmaps.QueryBeatmapSets(b => b.DeletePending).ToList())).ContinueWith(t => Schedule(() => undeleteButton.Enabled.Value = true)); } }, }; diff --git a/osu.Game/Rulesets/Scoring/ScoreStore.cs b/osu.Game/Rulesets/Scoring/ScoreStore.cs index 8bde2747a2..7abee0b04f 100644 --- a/osu.Game/Rulesets/Scoring/ScoreStore.cs +++ b/osu.Game/Rulesets/Scoring/ScoreStore.cs @@ -1,6 +1,7 @@ // Copyright (c) 2007-2018 ppy Pty Ltd . // Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE +using System; using System.Collections.Generic; using System.IO; using osu.Framework.Platform; @@ -14,7 +15,7 @@ using SharpCompress.Compressors.LZMA; namespace osu.Game.Rulesets.Scoring { - public class ScoreStore : DatabaseBackedStore + public class ScoreStore : DatabaseBackedStore, ICanAcceptFiles { private readonly Storage storage; @@ -23,6 +24,8 @@ namespace osu.Game.Rulesets.Scoring private const string replay_folder = @"replays"; + public event Action ScoreImported; + // ReSharper disable once NotAccessedField.Local (we should keep a reference to this so it is not finalised) private ScoreIPCChannel ipc; @@ -36,6 +39,18 @@ namespace osu.Game.Rulesets.Scoring ipc = new ScoreIPCChannel(importHost, this); } + public string[] HandledExtensions => new[] { ".osr" }; + + public void Import(params string[] paths) + { + foreach (var path in paths) + { + var score = ReadReplayFile(path); + if (score != null) + ScoreImported?.Invoke(score); + } + } + public Score ReadReplayFile(string replayFilename) { Score score; @@ -159,5 +174,6 @@ namespace osu.Game.Rulesets.Scoring return new Replay { Frames = frames }; } + } } diff --git a/osu.Game/Rulesets/UI/RulesetContainer.cs b/osu.Game/Rulesets/UI/RulesetContainer.cs index 231250e858..f4e700a8eb 100644 --- a/osu.Game/Rulesets/UI/RulesetContainer.cs +++ b/osu.Game/Rulesets/UI/RulesetContainer.cs @@ -33,11 +33,6 @@ namespace osu.Game.Rulesets.UI /// public abstract class RulesetContainer : Container { - /// - /// Whether to apply adjustments to the child based on our own size. - /// - public bool AspectAdjust = true; - /// /// The selected variant. /// @@ -324,7 +319,7 @@ namespace osu.Game.Rulesets.UI { base.Update(); - Playfield.Size = AspectAdjust ? GetPlayfieldAspectAdjust() : Vector2.One; + Playfield.Size = GetAspectAdjustedSize(); } /// @@ -335,10 +330,11 @@ namespace osu.Game.Rulesets.UI protected virtual BeatmapProcessor CreateBeatmapProcessor() => new BeatmapProcessor(); /// - /// In some cases we want to apply changes to the relative size of our contained based on custom conditions. + /// Computes the final size of the in relative coordinate space after all + /// aspect and scale adjustments. /// - /// - protected virtual Vector2 GetPlayfieldAspectAdjust() => new Vector2(0.75f); //a sane default + /// The aspect-adjusted size. + protected virtual Vector2 GetAspectAdjustedSize() => new Vector2(0.75f); // A sane default /// /// Creates a converter to convert Beatmap to a specific mode. diff --git a/osu.Game/Screens/Menu/Intro.cs b/osu.Game/Screens/Menu/Intro.cs index 10b08d704d..ce3c93ebcf 100644 --- a/osu.Game/Screens/Menu/Intro.cs +++ b/osu.Game/Screens/Menu/Intro.cs @@ -10,8 +10,8 @@ using osu.Framework.Screens; using osu.Framework.Graphics; using osu.Framework.MathUtils; using osu.Game.Beatmaps; -using osu.Game.Beatmaps.IO; using osu.Game.Configuration; +using osu.Game.IO.Archives; using osu.Game.Screens.Backgrounds; using OpenTK; using OpenTK.Graphics; @@ -62,8 +62,10 @@ namespace osu.Game.Screens.Menu if (setInfo == null) { // we need to import the default menu background beatmap - setInfo = beatmaps.Import(new OszArchiveReader(game.Resources.GetStream(@"Tracks/circles.osz"))); + setInfo = beatmaps.Import(new ZipArchiveReader(game.Resources.GetStream(@"Tracks/circles.osz"), "circles.osz")); + setInfo.Protected = true; + beatmaps.Update(setInfo); } } @@ -73,9 +75,6 @@ namespace osu.Game.Screens.Menu welcome = audio.Sample.Get(@"welcome"); seeya = audio.Sample.Get(@"seeya"); - - if (setInfo.Protected) - beatmaps.Delete(setInfo); } protected override void OnEntering(Screen last) diff --git a/osu.Game/Screens/Play/HUD/PlayerSettingsOverlay.cs b/osu.Game/Screens/Play/HUD/PlayerSettingsOverlay.cs index e6cf1f7982..5dba10ffc1 100644 --- a/osu.Game/Screens/Play/HUD/PlayerSettingsOverlay.cs +++ b/osu.Game/Screens/Play/HUD/PlayerSettingsOverlay.cs @@ -39,7 +39,7 @@ namespace osu.Game.Screens.Play.HUD //CollectionSettings = new CollectionSettings(), //DiscussionSettings = new DiscussionSettings(), PlaybackSettings = new PlaybackSettings(), - VisualSettings = new VisualSettings() + VisualSettings = new VisualSettings { Expanded = false } } }; diff --git a/osu.Game/Screens/Play/PlayerSettings/PlayerSettingsGroup.cs b/osu.Game/Screens/Play/PlayerSettings/PlayerSettingsGroup.cs index e8a4bc6b27..95b464154a 100644 --- a/osu.Game/Screens/Play/PlayerSettings/PlayerSettingsGroup.cs +++ b/osu.Game/Screens/Play/PlayerSettings/PlayerSettingsGroup.cs @@ -31,6 +31,28 @@ namespace osu.Game.Screens.Play.PlayerSettings private bool expanded = true; + public bool Expanded + { + get { return expanded; } + set + { + if (expanded == value) return; + expanded = value; + + content.ClearTransforms(); + + if (expanded) + content.AutoSizeAxes = Axes.Y; + else + { + content.AutoSizeAxes = Axes.None; + content.ResizeHeightTo(0, transition_duration, Easing.OutQuint); + } + + button.FadeColour(expanded ? buttonActiveColour : Color4.White, 200, Easing.OutQuint); + } + } + private Color4 buttonActiveColour; protected PlayerSettingsGroup() @@ -82,7 +104,7 @@ namespace osu.Game.Screens.Play.PlayerSettings Position = new Vector2(-15, 0), Icon = FontAwesome.fa_bars, Scale = new Vector2(0.75f), - Action = toggleContentVisibility, + Action = () => Expanded = !Expanded, }, } }, @@ -111,22 +133,5 @@ namespace osu.Game.Screens.Play.PlayerSettings } protected override Container Content => content; - - private void toggleContentVisibility() - { - content.ClearTransforms(); - - expanded = !expanded; - - if (expanded) - content.AutoSizeAxes = Axes.Y; - else - { - content.AutoSizeAxes = Axes.None; - content.ResizeHeightTo(0, transition_duration, Easing.OutQuint); - } - - button.FadeColour(expanded ? buttonActiveColour : Color4.White, 200, Easing.OutQuint); - } } } diff --git a/osu.Game/Screens/Select/SongSelect.cs b/osu.Game/Screens/Select/SongSelect.cs index 2421a4fdfe..de6847d866 100644 --- a/osu.Game/Screens/Select/SongSelect.cs +++ b/osu.Game/Screens/Select/SongSelect.cs @@ -197,8 +197,8 @@ namespace osu.Game.Screens.Select if (osu != null) Ruleset.BindTo(osu.Ruleset); - this.beatmaps.BeatmapSetAdded += onBeatmapSetAdded; - this.beatmaps.BeatmapSetRemoved += onBeatmapSetRemoved; + this.beatmaps.ItemAdded += onBeatmapSetAdded; + this.beatmaps.ItemRemoved += onBeatmapSetRemoved; this.beatmaps.BeatmapHidden += onBeatmapHidden; this.beatmaps.BeatmapRestored += onBeatmapRestored; @@ -401,8 +401,8 @@ namespace osu.Game.Screens.Select if (beatmaps != null) { - beatmaps.BeatmapSetAdded -= onBeatmapSetAdded; - beatmaps.BeatmapSetRemoved -= onBeatmapSetRemoved; + beatmaps.ItemAdded -= onBeatmapSetAdded; + beatmaps.ItemRemoved -= onBeatmapSetRemoved; beatmaps.BeatmapHidden -= onBeatmapHidden; beatmaps.BeatmapRestored -= onBeatmapRestored; } @@ -448,7 +448,7 @@ namespace osu.Game.Screens.Select private void carouselBeatmapsLoaded() { - if (!Beatmap.IsDefault && Beatmap.Value.BeatmapSetInfo?.DeletePending == false) + if (!Beatmap.IsDefault && Beatmap.Value.BeatmapSetInfo?.DeletePending == false && Beatmap.Value.BeatmapSetInfo?.Protected == false) { Carousel.SelectBeatmap(Beatmap.Value.BeatmapInfo); } diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index 272f182d86..70c904e8b9 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -274,13 +274,23 @@ + + + + + + + + + + @@ -368,8 +378,6 @@ - - @@ -384,7 +392,6 @@ - @@ -467,7 +474,7 @@ - +