diff --git a/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs b/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs index b6ab73eceb..045246e5ed 100644 --- a/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs +++ b/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs @@ -111,6 +111,7 @@ namespace osu.Game.Tests.NonVisual var osu = LoadOsuIntoHost(host); var storage = osu.Dependencies.Get(); + var osuStorage = storage as MigratableStorage; // Store the current storage's path. We'll need to refer to this for assertions in the original directory after the migration completes. string originalDirectory = storage.GetFullPath("."); @@ -137,13 +138,15 @@ namespace osu.Game.Tests.NonVisual Assert.That(!Directory.Exists(Path.Combine(originalDirectory, "test-nested", "cache"))); Assert.That(storage.ExistsDirectory(Path.Combine("test-nested", "cache"))); - foreach (var file in OsuStorage.IGNORE_FILES) + Assert.That(osuStorage, Is.Not.Null); + + foreach (var file in osuStorage.IgnoreFiles) { Assert.That(File.Exists(Path.Combine(originalDirectory, file))); Assert.That(storage.Exists(file), Is.False); } - foreach (var dir in OsuStorage.IGNORE_DIRECTORIES) + foreach (var dir in osuStorage.IgnoreDirectories) { Assert.That(Directory.Exists(Path.Combine(originalDirectory, dir))); Assert.That(storage.ExistsDirectory(dir), Is.False); diff --git a/osu.Game.Tournament.Tests/NonVisual/CustomTourneyDirectoryTest.cs b/osu.Game.Tournament.Tests/NonVisual/CustomTourneyDirectoryTest.cs new file mode 100644 index 0000000000..567d9f0d62 --- /dev/null +++ b/osu.Game.Tournament.Tests/NonVisual/CustomTourneyDirectoryTest.cs @@ -0,0 +1,169 @@ +// 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.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using osu.Framework; +using osu.Framework.Allocation; +using osu.Framework.Platform; +using osu.Game.Tournament.Configuration; +using osu.Game.Tests; + +namespace osu.Game.Tournament.Tests.NonVisual +{ + [TestFixture] + public class CustomTourneyDirectoryTest + { + [Test] + public void TestDefaultDirectory() + { + using (HeadlessGameHost host = new CleanRunHeadlessGameHost()) + { + try + { + var osu = loadOsu(host); + var storage = osu.Dependencies.Get(); + + Assert.That(storage.GetFullPath("."), Is.EqualTo(Path.Combine(host.Storage.GetFullPath("."), "tournaments", "default"))); + } + finally + { + host.Exit(); + } + } + } + + [Test] + public void TestCustomDirectory() + { + using (HeadlessGameHost host = new HeadlessGameHost(nameof(TestCustomDirectory))) // don't use clean run as we are writing a config file. + { + string osuDesktopStorage = basePath(nameof(TestCustomDirectory)); + const string custom_tournament = "custom"; + + // need access before the game has constructed its own storage yet. + Storage storage = new DesktopStorage(osuDesktopStorage, host); + // manual cleaning so we can prepare a config file. + storage.DeleteDirectory(string.Empty); + + using (var storageConfig = new TournamentStorageManager(storage)) + storageConfig.Set(StorageConfig.CurrentTournament, custom_tournament); + + try + { + var osu = loadOsu(host); + + storage = osu.Dependencies.Get(); + + Assert.That(storage.GetFullPath("."), Is.EqualTo(Path.Combine(host.Storage.GetFullPath("."), "tournaments", custom_tournament))); + } + finally + { + host.Exit(); + } + } + } + + [Test] + public void TestMigration() + { + using (HeadlessGameHost host = new HeadlessGameHost(nameof(TestMigration))) // don't use clean run as we are writing test files for migration. + { + string osuRoot = basePath(nameof(TestMigration)); + string configFile = Path.Combine(osuRoot, "tournament.ini"); + + if (File.Exists(configFile)) + File.Delete(configFile); + + // Recreate the old setup that uses "tournament" as the base path. + string oldPath = Path.Combine(osuRoot, "tournament"); + + string videosPath = Path.Combine(oldPath, "videos"); + string modsPath = Path.Combine(oldPath, "mods"); + string flagsPath = Path.Combine(oldPath, "flags"); + + Directory.CreateDirectory(videosPath); + Directory.CreateDirectory(modsPath); + Directory.CreateDirectory(flagsPath); + + // Define testing files corresponding to the specific file migrations that are needed + string bracketFile = Path.Combine(osuRoot, "bracket.json"); + + string drawingsConfig = Path.Combine(osuRoot, "drawings.ini"); + string drawingsFile = Path.Combine(osuRoot, "drawings.txt"); + string drawingsResult = Path.Combine(osuRoot, "drawings_results.txt"); + + // Define sample files to test recursive copying + string videoFile = Path.Combine(videosPath, "video.mp4"); + string modFile = Path.Combine(modsPath, "mod.png"); + string flagFile = Path.Combine(flagsPath, "flag.png"); + + File.WriteAllText(bracketFile, "{}"); + File.WriteAllText(drawingsConfig, "test"); + File.WriteAllText(drawingsFile, "test"); + File.WriteAllText(drawingsResult, "test"); + File.WriteAllText(videoFile, "test"); + File.WriteAllText(modFile, "test"); + File.WriteAllText(flagFile, "test"); + + try + { + var osu = loadOsu(host); + + var storage = osu.Dependencies.Get(); + + string migratedPath = Path.Combine(host.Storage.GetFullPath("."), "tournaments", "default"); + + videosPath = Path.Combine(migratedPath, "videos"); + modsPath = Path.Combine(migratedPath, "mods"); + flagsPath = Path.Combine(migratedPath, "flags"); + + videoFile = Path.Combine(videosPath, "video.mp4"); + modFile = Path.Combine(modsPath, "mod.png"); + flagFile = Path.Combine(flagsPath, "flag.png"); + + Assert.That(storage.GetFullPath("."), Is.EqualTo(migratedPath)); + + Assert.True(storage.Exists("bracket.json")); + Assert.True(storage.Exists("drawings.txt")); + Assert.True(storage.Exists("drawings_results.txt")); + + Assert.True(storage.Exists("drawings.ini")); + + Assert.True(storage.Exists(videoFile)); + Assert.True(storage.Exists(modFile)); + Assert.True(storage.Exists(flagFile)); + } + finally + { + host.Storage.Delete("tournament.ini"); + host.Storage.DeleteDirectory("tournaments"); + host.Exit(); + } + } + } + + private TournamentGameBase loadOsu(GameHost host) + { + var osu = new TournamentGameBase(); + Task.Run(() => host.Run(osu)); + waitForOrAssert(() => osu.IsLoaded, @"osu! failed to start in a reasonable amount of time"); + return osu; + } + + private static void waitForOrAssert(Func result, string failureMessage, int timeout = 90000) + { + Task task = Task.Run(() => + { + while (!result()) Thread.Sleep(200); + }); + + Assert.IsTrue(task.Wait(timeout), failureMessage); + } + + private string basePath(string testInstance) => Path.Combine(RuntimeInfo.StartupDirectory, "headless", testInstance); + } +} diff --git a/osu.Game.Tournament/Components/TourneyVideo.cs b/osu.Game.Tournament/Components/TourneyVideo.cs index 317c5f6a56..2709580385 100644 --- a/osu.Game.Tournament/Components/TourneyVideo.cs +++ b/osu.Game.Tournament/Components/TourneyVideo.cs @@ -9,6 +9,7 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Video; using osu.Framework.Timing; using osu.Game.Graphics; +using osu.Game.Tournament.IO; namespace osu.Game.Tournament.Components { @@ -17,7 +18,6 @@ namespace osu.Game.Tournament.Components private readonly string filename; private readonly bool drawFallbackGradient; private Video video; - private ManualClock manualClock; public TourneyVideo(string filename, bool drawFallbackGradient = false) @@ -27,9 +27,9 @@ namespace osu.Game.Tournament.Components } [BackgroundDependencyLoader] - private void load(TournamentStorage storage) + private void load(TournamentVideoResourceStore storage) { - var stream = storage.GetStream($@"videos/{filename}"); + var stream = storage.GetStream(filename); if (stream != null) { diff --git a/osu.Game.Tournament/Configuration/TournamentStorageManager.cs b/osu.Game.Tournament/Configuration/TournamentStorageManager.cs new file mode 100644 index 0000000000..e3d0a9e75c --- /dev/null +++ b/osu.Game.Tournament/Configuration/TournamentStorageManager.cs @@ -0,0 +1,23 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Configuration; +using osu.Framework.Platform; + +namespace osu.Game.Tournament.Configuration +{ + public class TournamentStorageManager : IniConfigManager + { + protected override string Filename => "tournament.ini"; + + public TournamentStorageManager(Storage storage) + : base(storage) + { + } + } + + public enum StorageConfig + { + CurrentTournament, + } +} diff --git a/osu.Game.Tournament/IO/TournamentStorage.cs b/osu.Game.Tournament/IO/TournamentStorage.cs new file mode 100644 index 0000000000..2e8a6ce667 --- /dev/null +++ b/osu.Game.Tournament/IO/TournamentStorage.cs @@ -0,0 +1,72 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Logging; +using osu.Framework.Platform; +using osu.Game.IO; +using System.IO; +using osu.Game.Tournament.Configuration; + +namespace osu.Game.Tournament.IO +{ + public class TournamentStorage : MigratableStorage + { + private const string default_tournament = "default"; + private readonly Storage storage; + private readonly TournamentStorageManager storageConfig; + + public TournamentStorage(Storage storage) + : base(storage.GetStorageForDirectory("tournaments"), string.Empty) + { + this.storage = storage; + + storageConfig = new TournamentStorageManager(storage); + + if (storage.Exists("tournament.ini")) + { + ChangeTargetStorage(UnderlyingStorage.GetStorageForDirectory(storageConfig.Get(StorageConfig.CurrentTournament))); + } + else + Migrate(UnderlyingStorage.GetStorageForDirectory(default_tournament)); + + Logger.Log("Using tournament storage: " + GetFullPath(string.Empty)); + } + + public override void Migrate(Storage newStorage) + { + // this migration only happens once on moving to the per-tournament storage system. + // listed files are those known at that point in time. + // this can be removed at some point in the future (6 months obsoletion would mean 2021-04-19) + + var source = new DirectoryInfo(storage.GetFullPath("tournament")); + var destination = new DirectoryInfo(newStorage.GetFullPath(".")); + + if (source.Exists) + { + Logger.Log("Migrating tournament assets to default tournament storage."); + CopyRecursive(source, destination); + DeleteRecursive(source); + } + + moveFileIfExists("bracket.json", destination); + moveFileIfExists("drawings.txt", destination); + moveFileIfExists("drawings_results.txt", destination); + moveFileIfExists("drawings.ini", destination); + + ChangeTargetStorage(newStorage); + storageConfig.Set(StorageConfig.CurrentTournament, default_tournament); + storageConfig.Save(); + } + + private void moveFileIfExists(string file, DirectoryInfo destination) + { + if (!storage.Exists(file)) + return; + + Logger.Log($"Migrating {file} to default tournament storage."); + var fileInfo = new System.IO.FileInfo(storage.GetFullPath(file)); + AttemptOperation(() => fileInfo.CopyTo(Path.Combine(destination.FullName, fileInfo.Name), true)); + fileInfo.Delete(); + } + } +} diff --git a/osu.Game.Tournament/TournamentStorage.cs b/osu.Game.Tournament/IO/TournamentVideoResourceStore.cs similarity index 58% rename from osu.Game.Tournament/TournamentStorage.cs rename to osu.Game.Tournament/IO/TournamentVideoResourceStore.cs index 139ad3857b..4b26840b79 100644 --- a/osu.Game.Tournament/TournamentStorage.cs +++ b/osu.Game.Tournament/IO/TournamentVideoResourceStore.cs @@ -4,12 +4,12 @@ using osu.Framework.IO.Stores; using osu.Framework.Platform; -namespace osu.Game.Tournament +namespace osu.Game.Tournament.IO { - internal class TournamentStorage : NamespacedResourceStore + public class TournamentVideoResourceStore : NamespacedResourceStore { - public TournamentStorage(Storage storage) - : base(new StorageBackedResourceStore(storage), "tournament") + public TournamentVideoResourceStore(Storage storage) + : base(new StorageBackedResourceStore(storage), "videos") { AddExtension("m4v"); AddExtension("avi"); diff --git a/osu.Game.Tournament/TournamentGameBase.cs b/osu.Game.Tournament/TournamentGameBase.cs index 5fc1d03f6d..dbda6aa023 100644 --- a/osu.Game.Tournament/TournamentGameBase.cs +++ b/osu.Game.Tournament/TournamentGameBase.cs @@ -8,11 +8,12 @@ using Newtonsoft.Json; using osu.Framework.Allocation; using osu.Framework.Graphics.Textures; using osu.Framework.Input; -using osu.Framework.IO.Stores; using osu.Framework.Platform; +using osu.Framework.IO.Stores; using osu.Game.Beatmaps; using osu.Game.Online.API.Requests; using osu.Game.Tournament.IPC; +using osu.Game.Tournament.IO; using osu.Game.Tournament.Models; using osu.Game.Users; using osuTK.Input; @@ -23,13 +24,8 @@ namespace osu.Game.Tournament public class TournamentGameBase : OsuGameBase { private const string bracket_filename = "bracket.json"; - private LadderInfo ladder; - - private Storage storage; - - private TournamentStorage tournamentStorage; - + private TournamentStorage storage; private DependencyContainer dependencies; private FileBasedIPC ipc; @@ -39,15 +35,14 @@ namespace osu.Game.Tournament } [BackgroundDependencyLoader] - private void load(Storage storage) + private void load(Storage baseStorage) { Resources.AddStore(new DllResourceStore(typeof(TournamentGameBase).Assembly)); - dependencies.CacheAs(tournamentStorage = new TournamentStorage(storage)); + dependencies.CacheAs(storage = new TournamentStorage(baseStorage)); + dependencies.Cache(new TournamentVideoResourceStore(storage)); - Textures.AddStore(new TextureLoaderStore(tournamentStorage)); - - this.storage = storage; + Textures.AddStore(new TextureLoaderStore(new StorageBackedResourceStore(storage))); readBracket(); diff --git a/osu.Game/IO/MigratableStorage.cs b/osu.Game/IO/MigratableStorage.cs new file mode 100644 index 0000000000..1b76725b04 --- /dev/null +++ b/osu.Game/IO/MigratableStorage.cs @@ -0,0 +1,132 @@ +// 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; +using osu.Framework.Platform; + +namespace osu.Game.IO +{ + /// + /// A that is migratable to different locations. + /// + public abstract class MigratableStorage : WrappedStorage + { + /// + /// A relative list of directory paths which should not be migrated. + /// + public virtual string[] IgnoreDirectories => Array.Empty(); + + /// + /// A relative list of file paths which should not be migrated. + /// + public virtual string[] IgnoreFiles => Array.Empty(); + + protected MigratableStorage(Storage storage, string subPath = null) + : base(storage, subPath) + { + } + + /// + /// A general purpose migration method to move the storage to a different location. + /// The target storage of the migration. + /// + public virtual void Migrate(Storage newStorage) + { + var source = new DirectoryInfo(GetFullPath(".")); + var destination = new DirectoryInfo(newStorage.GetFullPath(".")); + + // using Uri is the easiest way to check equality and contains (https://stackoverflow.com/a/7710620) + var sourceUri = new Uri(source.FullName + Path.DirectorySeparatorChar); + var destinationUri = new Uri(destination.FullName + Path.DirectorySeparatorChar); + + if (sourceUri == destinationUri) + throw new ArgumentException("Destination provided is already the current location", destination.FullName); + + if (sourceUri.IsBaseOf(destinationUri)) + throw new ArgumentException("Destination provided is inside the source", destination.FullName); + + // ensure the new location has no files present, else hard abort + if (destination.Exists) + { + if (destination.GetFiles().Length > 0 || destination.GetDirectories().Length > 0) + throw new ArgumentException("Destination provided already has files or directories present", destination.FullName); + } + + CopyRecursive(source, destination); + ChangeTargetStorage(newStorage); + DeleteRecursive(source); + } + + protected void DeleteRecursive(DirectoryInfo target, bool topLevelExcludes = true) + { + foreach (System.IO.FileInfo fi in target.GetFiles()) + { + if (topLevelExcludes && IgnoreFiles.Contains(fi.Name)) + continue; + + AttemptOperation(() => fi.Delete()); + } + + foreach (DirectoryInfo dir in target.GetDirectories()) + { + if (topLevelExcludes && IgnoreDirectories.Contains(dir.Name)) + continue; + + AttemptOperation(() => dir.Delete(true)); + } + + if (target.GetFiles().Length == 0 && target.GetDirectories().Length == 0) + AttemptOperation(target.Delete); + } + + protected void CopyRecursive(DirectoryInfo source, DirectoryInfo destination, bool topLevelExcludes = true) + { + // based off example code https://docs.microsoft.com/en-us/dotnet/api/system.io.directoryinfo + if (!destination.Exists) + Directory.CreateDirectory(destination.FullName); + + foreach (System.IO.FileInfo fi in source.GetFiles()) + { + if (topLevelExcludes && IgnoreFiles.Contains(fi.Name)) + continue; + + AttemptOperation(() => fi.CopyTo(Path.Combine(destination.FullName, fi.Name), true)); + } + + foreach (DirectoryInfo dir in source.GetDirectories()) + { + if (topLevelExcludes && IgnoreDirectories.Contains(dir.Name)) + continue; + + CopyRecursive(dir, destination.CreateSubdirectory(dir.Name), false); + } + } + + /// + /// Attempt an IO operation multiple times and only throw if none of the attempts succeed. + /// + /// The action to perform. + /// The number of attempts (250ms wait between each). + protected static void AttemptOperation(Action action, int attempts = 10) + { + while (true) + { + try + { + action(); + return; + } + catch (Exception) + { + if (attempts-- == 0) + throw; + } + + Thread.Sleep(250); + } + } + } +} diff --git a/osu.Game/IO/OsuStorage.cs b/osu.Game/IO/OsuStorage.cs index 1d15294666..8097f61ea4 100644 --- a/osu.Game/IO/OsuStorage.cs +++ b/osu.Game/IO/OsuStorage.cs @@ -1,11 +1,8 @@ // 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.Diagnostics; -using System.IO; using System.Linq; -using System.Threading; using JetBrains.Annotations; using osu.Framework.Logging; using osu.Framework.Platform; @@ -13,7 +10,7 @@ using osu.Game.Configuration; namespace osu.Game.IO { - public class OsuStorage : WrappedStorage + public class OsuStorage : MigratableStorage { /// /// Indicates the error (if any) that occurred when initialising the custom storage during initial startup. @@ -36,9 +33,9 @@ namespace osu.Game.IO private readonly StorageConfigManager storageConfig; private readonly Storage defaultStorage; - public static readonly string[] IGNORE_DIRECTORIES = { "cache" }; + public override string[] IgnoreDirectories => new[] { "cache" }; - public static readonly string[] IGNORE_FILES = + public override string[] IgnoreFiles => new[] { "framework.ini", "storage.ini" @@ -103,106 +100,11 @@ namespace osu.Game.IO Logger.Storage = UnderlyingStorage.GetStorageForDirectory("logs"); } - public void Migrate(string newLocation) + public override void Migrate(Storage newStorage) { - var source = new DirectoryInfo(GetFullPath(".")); - var destination = new DirectoryInfo(newLocation); - - // using Uri is the easiest way to check equality and contains (https://stackoverflow.com/a/7710620) - var sourceUri = new Uri(source.FullName + Path.DirectorySeparatorChar); - var destinationUri = new Uri(destination.FullName + Path.DirectorySeparatorChar); - - if (sourceUri == destinationUri) - throw new ArgumentException("Destination provided is already the current location", nameof(newLocation)); - - if (sourceUri.IsBaseOf(destinationUri)) - throw new ArgumentException("Destination provided is inside the source", nameof(newLocation)); - - // ensure the new location has no files present, else hard abort - if (destination.Exists) - { - if (destination.GetFiles().Length > 0 || destination.GetDirectories().Length > 0) - throw new ArgumentException("Destination provided already has files or directories present", nameof(newLocation)); - - deleteRecursive(destination); - } - - copyRecursive(source, destination); - - ChangeTargetStorage(host.GetStorage(newLocation)); - - storageConfig.Set(StorageConfig.FullPath, newLocation); + base.Migrate(newStorage); + storageConfig.Set(StorageConfig.FullPath, newStorage.GetFullPath(".")); storageConfig.Save(); - - deleteRecursive(source); - } - - private static void deleteRecursive(DirectoryInfo target, bool topLevelExcludes = true) - { - foreach (System.IO.FileInfo fi in target.GetFiles()) - { - if (topLevelExcludes && IGNORE_FILES.Contains(fi.Name)) - continue; - - attemptOperation(() => fi.Delete()); - } - - foreach (DirectoryInfo dir in target.GetDirectories()) - { - if (topLevelExcludes && IGNORE_DIRECTORIES.Contains(dir.Name)) - continue; - - attemptOperation(() => dir.Delete(true)); - } - - if (target.GetFiles().Length == 0 && target.GetDirectories().Length == 0) - attemptOperation(target.Delete); - } - - private static void copyRecursive(DirectoryInfo source, DirectoryInfo destination, bool topLevelExcludes = true) - { - // based off example code https://docs.microsoft.com/en-us/dotnet/api/system.io.directoryinfo - Directory.CreateDirectory(destination.FullName); - - foreach (System.IO.FileInfo fi in source.GetFiles()) - { - if (topLevelExcludes && IGNORE_FILES.Contains(fi.Name)) - continue; - - attemptOperation(() => fi.CopyTo(Path.Combine(destination.FullName, fi.Name), true)); - } - - foreach (DirectoryInfo dir in source.GetDirectories()) - { - if (topLevelExcludes && IGNORE_DIRECTORIES.Contains(dir.Name)) - continue; - - copyRecursive(dir, destination.CreateSubdirectory(dir.Name), false); - } - } - - /// - /// Attempt an IO operation multiple times and only throw if none of the attempts succeed. - /// - /// The action to perform. - /// The number of attempts (250ms wait between each). - private static void attemptOperation(Action action, int attempts = 10) - { - while (true) - { - try - { - action(); - return; - } - catch (Exception) - { - if (attempts-- == 0) - throw; - } - - Thread.Sleep(250); - } } } diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs index f61ff43ca9..84766f196a 100644 --- a/osu.Game/OsuGameBase.cs +++ b/osu.Game/OsuGameBase.cs @@ -406,7 +406,7 @@ namespace osu.Game public void Migrate(string path) { contextFactory.FlushConnections(); - (Storage as OsuStorage)?.Migrate(path); + (Storage as OsuStorage)?.Migrate(Host.GetStorage(path)); } } }