diff --git a/.editorconfig b/.editorconfig index a8fbc43..80292aa 100644 --- a/.editorconfig +++ b/.editorconfig @@ -247,6 +247,7 @@ dotnet_naming_style.begins_with_i.capitalization = pascal_case # CA1716: Identifiers should not match keywords dotnet_diagnostic.CA1716.severity = none +dotnet_code_quality.CA1826.exclude_ordefault_methods = true [*.{cs,vb}] dotnet_style_object_initializer = true:suggestion diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ab44d4c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,66 @@ +name: CI + +on: + pull_request: + branches: [master] + +env: + DOTNET_VERSION: '9.0.x' + CONFIGURATION: Release + +jobs: + build-and-test: + runs-on: windows-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Set version with commit hash + shell: pwsh + run: | + $commitSha = "${{ github.sha }}".Substring(0, 7) + $filePath = "Directory.Build.props" + $content = Get-Content $filePath -Raw + $content = $content -replace '.*?', "0.0.0+$commitSha" + Set-Content $filePath $content -NoNewline + Write-Host "InformationalVersion set to: 0.0.0+$commitSha" + + - name: Restore dependencies + run: dotnet restore + + - name: Build solution + run: dotnet build --configuration ${{ env.CONFIGURATION }} --no-restore + + - name: Run tests + run: dotnet test --configuration ${{ env.CONFIGURATION }} --no-build --verbosity normal + + - name: Publish WinForms app + run: dotnet publish CollectionManager.App.WinForms/CollectionManager.App.WinForms.csproj -f net9.0-windows -p:PublishProfile=FolderProfile + + - name: Publish CLI app + run: dotnet publish CollectionManager.App.Cli/CollectionManager.App.Cli.csproj -f net9.0 -p:PublishProfile=FolderProfile + + - name: Zip WinForms app + shell: pwsh + run: | + New-Item -ItemType Directory -Path ./artifacts -Force + Compress-Archive -Path ./CollectionManager.App.WinForms/bin/publish/* -DestinationPath ./artifacts/CollectionManager-WinForms.zip + + - name: Zip CLI app + shell: pwsh + run: | + Compress-Archive -Path ./CollectionManager.App.Cli/bin/publish/* -DestinationPath ./artifacts/CollectionManager-CLI.zip + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: build-artifacts + path: | + artifacts/CollectionManager-WinForms.zip + artifacts/CollectionManager-CLI.zip diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..feda8d2 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,90 @@ +name: Release + +on: + push: + tags: + - 'v*' + +env: + DOTNET_VERSION: '9.0.x' + CONFIGURATION: Release + +jobs: + build-and-release: + runs-on: windows-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Extract version from tag + id: version + shell: pwsh + run: | + $version = "${{ github.ref_name }}" -replace '^v', '' + echo "VERSION=$version" >> $env:GITHUB_OUTPUT + Write-Host "Version: $version" + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Update version in Directory.Build.props + shell: pwsh + run: | + $version = "${{ steps.version.outputs.VERSION }}" + $filePath = "Directory.Build.props" + $content = Get-Content $filePath -Raw + $content = $content -replace '.*?', "$version" + $content = $content -replace '.*?', "$version" + $content = $content -replace '.*?', "$version" + Set-Content $filePath $content -NoNewline + + - name: Restore dependencies + run: dotnet restore + + - name: Build solution + run: dotnet build --configuration ${{ env.CONFIGURATION }} --no-restore + + - name: Run tests + run: dotnet test --configuration ${{ env.CONFIGURATION }} --no-build --verbosity normal + + - name: Publish WinForms app + run: dotnet publish CollectionManager.App.WinForms/CollectionManager.App.WinForms.csproj -f net9.0-windows -p:PublishProfile=FolderProfile + + - name: Publish CLI app + run: dotnet publish CollectionManager.App.Cli/CollectionManager.App.Cli.csproj -f net9.0 -p:PublishProfile=FolderProfile + + - name: Zip WinForms app + shell: pwsh + run: | + New-Item -ItemType Directory -Path ./artifacts -Force + Compress-Archive -Path ./CollectionManager.App.WinForms/bin/publish/* -DestinationPath ./artifacts/CollectionManager-WinForms.zip + + - name: Zip CLI app + shell: pwsh + run: | + Compress-Archive -Path ./CollectionManager.App.Cli/bin/publish/* -DestinationPath ./artifacts/CollectionManager-CLI.zip + + - name: Install InnoSetup + run: choco install innosetup -y + + - name: Build installer + shell: pwsh + run: | + $version = "${{ steps.version.outputs.VERSION }}" + iscc "/DAppVersion=$version" "InnoSetup\script.iss" + Move-Item "InnoSetup\output\CollectionManagerSetup.exe" "./artifacts/" -Force + + - name: Create draft release + uses: softprops/action-gh-release@v2 + with: + draft: true + generate_release_notes: true + files: | + artifacts/CollectionManager-WinForms.zip + artifacts/CollectionManager-CLI.zip + artifacts/CollectionManagerSetup.exe + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/CollectionManager.App.Cli/CollectionManager.App.Cli.csproj b/CollectionManager.App.Cli/CollectionManager.App.Cli.csproj new file mode 100644 index 0000000..6055428 --- /dev/null +++ b/CollectionManager.App.Cli/CollectionManager.App.Cli.csproj @@ -0,0 +1,17 @@ + + + Exe + osu! Collection Manager CLI + Copyright © 2017-present Piotrekol + CollectionManager.App.Cli + + + + + + + + + + + diff --git a/CollectionManager.App.Cli/Common/CommonOptions.cs b/CollectionManager.App.Cli/Common/CommonOptions.cs new file mode 100644 index 0000000..0d917a2 --- /dev/null +++ b/CollectionManager.App.Cli/Common/CommonOptions.cs @@ -0,0 +1,15 @@ +namespace CollectionManager.App.Cli.Common; + +using CommandLine; + +internal abstract class CommonOptions +{ + [Option('o', "Output", Required = true, HelpText = "Output filename with or without path.")] + public string OutputFilePath { get; init; } + + [Option('l', "OsuLocation", Required = false, HelpText = "Location of your osu! or directory where valid osu!.db or client.realm can be found. If not provided, will be found automatically.")] + public string OsuLocation { get; init; } + + [Option('s', "SkipOsuLocation", Required = false, HelpText = "Skip loading of osu! database.")] + public bool SkipOsuLocation { get; init; } +} diff --git a/CollectionManager.App.Cli/Common/CommonOptionsExtensions.cs b/CollectionManager.App.Cli/Common/CommonOptionsExtensions.cs new file mode 100644 index 0000000..acb3387 --- /dev/null +++ b/CollectionManager.App.Cli/Common/CommonOptionsExtensions.cs @@ -0,0 +1,65 @@ +namespace CollectionManager.App.Cli.Common; + +using CollectionManager.Core.Extensions; +using CollectionManager.Core.Modules.FileIo; +using CollectionManager.Core.Types; +using System.IO; + +internal static class CommonOptionsExtensions +{ + public static OsuFileIo LoadOsuDatabase(this CommonOptions options) + { + OsuFileIo osuFileIo = new(new BeatmapExtension()); + + if (options.SkipOsuLocation) + { + return osuFileIo; + } + + string osuLocation = ResolveOsuLocation(options); + + if (string.IsNullOrWhiteSpace(osuLocation)) + { + throw new InvalidOperationException("Could not find osu!"); + } + + Console.WriteLine($"Using osu! database found at \"{osuLocation}\"."); + _ = osuFileIo.OsuDatabase.Load(osuLocation, progress: null, cancellationToken: default); + + return osuFileIo; + } + + private static string ResolveOsuLocation(CommonOptions options) + { + string path = options.OsuLocation; + + if (string.IsNullOrWhiteSpace(path)) + { + OsuPathResult osuPath = OsuPathResolver.GetOsuOrLazerPath(); + + if (osuPath.Type is OsuType.None) + { + return null; + } + + path = Path.Combine(osuPath.Path, osuPath.Type.GetDatabaseFileName()); + } + + if (Path.HasExtension(path)) + { + return path; + } + + if (OsuPathResolver.IsOsuStableDirectory(path)) + { + return Path.Combine(path, OsuType.Stable.GetDatabaseFileName()); + } + + if (OsuPathResolver.IsOsuLazerDataDirectory(path)) + { + return Path.Combine(path, OsuType.Lazer.GetDatabaseFileName()); + } + + return path; + } +} diff --git a/CollectionManager.App.Cli/Convert/ConvertCommand.cs b/CollectionManager.App.Cli/Convert/ConvertCommand.cs new file mode 100644 index 0000000..dd21719 --- /dev/null +++ b/CollectionManager.App.Cli/Convert/ConvertCommand.cs @@ -0,0 +1,25 @@ +namespace CollectionManager.App.Cli.Convert; + +using CollectionManager.App.Cli.Common; +using CollectionManager.Core.Modules.FileIo; +using CollectionManager.Core.Types; +using CommandLine; +using System.Threading.Tasks; + +[Verb("convert", HelpText = "Convert collection files between formats (.db/.osdb)")] +internal sealed class ConvertCommand : CommonOptions +{ + [Option('i', "Input", Required = true, HelpText = "Input db/osdb collection file.")] + public string InputFilePath { get; init; } + + public Task RunAsync() + { + using OsuFileIo osuFileIo = this.LoadOsuDatabase(); + Console.WriteLine("Converting collections."); + OsuCollections collections = osuFileIo.CollectionLoader.LoadCollection(InputFilePath); + osuFileIo.CollectionLoader.SaveCollection(collections, OutputFilePath); + Console.WriteLine("Done."); + + return Task.FromResult(0); + } +} diff --git a/CollectionManager.App.Cli/Create/CreateCommand.cs b/CollectionManager.App.Cli/Create/CreateCommand.cs new file mode 100644 index 0000000..0ada315 --- /dev/null +++ b/CollectionManager.App.Cli/Create/CreateCommand.cs @@ -0,0 +1,118 @@ +namespace CollectionManager.App.Cli.Create; + +using CollectionManager.App.Cli.Common; +using CollectionManager.Core.Modules.FileIo; +using CollectionManager.Core.Types; +using CommandLine; +using System.IO; +using System.Threading.Tasks; + +[Verb("create", HelpText = "Create collection from beatmap IDs or hashes")] +internal sealed class CreateCommand : CommonOptions +{ + private static readonly char[] Separator = [' ', ',', '\n', '\r', '\t']; + + [Option('b', "BeatmapIds", Required = false, HelpText = "Comma or whitespace separated list of beatmap ids. Can be also path to the file.\nYou should have all beatmapIds mentioned available locally in order to generate ready-to-use collection file, otherwise after generating upload it to https://osustats.ppy.sh/collections to get remaining data.")] + public string BeatmapIds { get; init; } + + [Option('h', "Hashes", Required = false, HelpText = "Comma or whitespace separated list of beatmap hashes (MD5). Can be also path to the file.\nYou should have all beatmaps mentioned available locally in order to generate ready-to-use collection file.")] + public string Hashes { get; init; } + + public Task RunAsync() + { + if (!Validate()) + { + return Task.FromResult(1); + } + + using OsuFileIo osuFileIo = this.LoadOsuDatabase(); + Console.WriteLine("Creating collections."); + + int result = !string.IsNullOrWhiteSpace(BeatmapIds) + ? ProcessBeatmapIds(osuFileIo) + : ProcessHashes(osuFileIo); + + return Task.FromResult(result); + } + + private bool Validate() + { + bool hasBeatmapIds = !string.IsNullOrWhiteSpace(BeatmapIds); + bool hasHashes = !string.IsNullOrWhiteSpace(Hashes); + + if (!hasBeatmapIds && !hasHashes) + { + Console.WriteLine("Error: Either --BeatmapIds or --Hashes must be provided."); + return false; + } + + if (hasBeatmapIds && hasHashes) + { + Console.WriteLine("Error: --BeatmapIds and --Hashes cannot be used together. Use one or the other."); + return false; + } + + return true; + } + + private int ProcessBeatmapIds(OsuFileIo osuFileIo) + { + string rawBeatmapIds = BeatmapIds; + + if (File.Exists(rawBeatmapIds)) + { + rawBeatmapIds = File.ReadAllText(rawBeatmapIds); + } + + string[] beatmapIdArray = rawBeatmapIds.Split(Separator, StringSplitOptions.RemoveEmptyEntries); + OsuCollection collection = new(osuFileIo.LoadedMaps) { Name = "from mapIds" }; + + foreach (string beatmapId in beatmapIdArray) + { + if (int.TryParse(beatmapId.Trim(), out int id)) + { + collection.AddBeatmapByMapId(id); + } + } + + string outputPath = GetOutputPath(); + osuFileIo.CollectionLoader.SaveCollection([collection], outputPath); + Console.WriteLine($"Done. Created collection from {beatmapIdArray.Length} beatmap IDs."); + + return 0; + } + + private int ProcessHashes(OsuFileIo osuFileIo) + { + string rawHashes = Hashes; + + if (File.Exists(rawHashes)) + { + rawHashes = File.ReadAllText(rawHashes); + } + + string[] hashArray = rawHashes.Split(Separator, StringSplitOptions.RemoveEmptyEntries); + OsuCollection collection = new(osuFileIo.LoadedMaps) { Name = "from hashes" }; + + foreach (string hash in hashArray) + { + string trimmedHash = hash.Trim(); + + if (!string.IsNullOrWhiteSpace(trimmedHash)) + { + collection.AddBeatmapByHash(trimmedHash); + } + } + + string outputPath = GetOutputPath(); + osuFileIo.CollectionLoader.SaveCollection([collection], outputPath); + Console.WriteLine($"Done. Created collection from {hashArray.Length} hashes."); + + return 0; + } + + private string GetOutputPath() + => Path.HasExtension(OutputFilePath) + ? OutputFilePath + : $"{OutputFilePath}.osdb"; +} diff --git a/CollectionManager.App.Cli/Generate/GenerateCommand.cs b/CollectionManager.App.Cli/Generate/GenerateCommand.cs new file mode 100644 index 0000000..075fa17 --- /dev/null +++ b/CollectionManager.App.Cli/Generate/GenerateCommand.cs @@ -0,0 +1,191 @@ +namespace CollectionManager.App.Cli.Generate; + +using CollectionManager.App.Cli.Common; +using CollectionManager.Core.Modules.FileIo; +using CollectionManager.Core.Types; +using CollectionManager.Extensions.DataTypes; +using CollectionManager.Extensions.Modules.CollectionApiGenerator; +using CommandLine; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +[Verb("generate", HelpText = "Generate collections from user top scores using osu! API")] +internal sealed class GenerateCommand : CommonOptions +{ + private static readonly char[] Separator = [' ', ',', '\n', '\r', '\t']; + + [Option('u', "Usernames", Required = true, HelpText = "Comma or whitespace separated list of usernames. Can be also path to the file.")] + public string Usernames { get; init; } + + [Option('k', "ApiKey", Required = true, HelpText = "osu! API key for accessing user data.")] + public string ApiKey { get; init; } + + [Option('p', "CollectionNamePattern", Required = false, HelpText = "Collection name format pattern. {0}=username, {1}=mods. Default: \"{0} - {1}\"")] + public string CollectionNamePattern { get; init; } = "{0} - {1}"; + + [Option('g', "Gamemode", Required = false, HelpText = "Game mode: 0=Osu, 1=Taiko, 2=Catch, 3=Mania. Default: 0")] + public int Gamemode { get; init; } = 0; + + [Option("MinPp", Required = false, HelpText = "Minimum PP required for a score. Default: 0")] + public double MinimumPp { get; init; } + + [Option("MaxPp", Required = false, HelpText = "Maximum PP allowed for a score. Default: 5000")] + public double MaximumPp { get; init; } = 5000; + + [Option("MinAcc", Required = false, HelpText = "Minimum accuracy required for a score (0-100). Default: 0")] + public double MinimumAcc { get; init; } + + [Option("MaxAcc", Required = false, HelpText = "Maximum accuracy allowed for a score (0-100). Default: 100")] + public double MaximumAcc { get; init; } = 100; + + [Option('r', "Ranks", Required = false, HelpText = "Rank filter: 0=S and better, 1=A and worse, 2=All. Default: 2")] + public int RankFilter { get; init; } = 2; + + [Option('m', "Mods", Required = false, HelpText = "Comma separated list of required mods (e.g., 'Hd,Hr'). If empty, all mods are included.")] + public string Mods { get; init; } + + public Task RunAsync() + { + List usernames = ParseUsernames(); + + if (usernames.Count == 0) + { + Console.WriteLine("Error: No valid usernames provided."); + return Task.FromResult(1); + } + + using OsuFileIo osuFileIo = this.LoadOsuDatabase(); + Console.WriteLine($"Generating collections for {usernames.Count} user(s)."); + + try + { + CollectionGeneratorConfiguration configuration = CreateConfiguration(usernames); + return GenerateCollections(osuFileIo, configuration); + } + catch (Exception ex) + { + Console.WriteLine($"Error: {ex.Message}"); + return Task.FromResult(1); + } + } + + private List ParseUsernames() + { + string rawUsernames = Usernames; + + if (File.Exists(rawUsernames)) + { + rawUsernames = File.ReadAllText(rawUsernames); + } + + return [.. rawUsernames.Split(Separator, StringSplitOptions.RemoveEmptyEntries) + .Select(username => username.Trim()) + .Where(username => !string.IsNullOrWhiteSpace(username))]; + } + + private CollectionGeneratorConfiguration CreateConfiguration(List usernames) + { + List modList = []; + + if (!string.IsNullOrWhiteSpace(Mods)) + { + string[] modNames = Mods.Split([' ', ','], StringSplitOptions.RemoveEmptyEntries); + + foreach (string modName in modNames) + { + if (Enum.TryParse(modName, ignoreCase: true, out Mods mod)) + { + modList.Add(mod); + } + else + { + Console.WriteLine($"Warning: Invalid mod '{modName}' will be ignored."); + } + } + } + + return new CollectionGeneratorConfiguration + { + ApiKey = ApiKey, + Usernames = usernames, + CollectionNameSavePattern = CollectionNamePattern, + Gamemode = Gamemode, + ScoreSaveConditions = new ScoreSaveConditions + { + MinimumPp = MinimumPp, + MaximumPp = MaximumPp, + MinimumAcc = MinimumAcc, + MaximumAcc = MaximumAcc, + RanksToGet = (RankTypes)RankFilter, + ModCombinations = modList + } + }; + } + + private async Task GenerateCollections(OsuFileIo osuFileIo, CollectionGeneratorConfiguration configuration) + { + using CollectionsApiGenerator generator = new(osuFileIo.LoadedMaps); + using CancellationTokenSource cts = new(); + + Console.CancelKeyPress += (s, e) => + { + e.Cancel = true; + Console.WriteLine("\nAborting..."); + cts.Cancel(); + }; + + // Subscribe to status updates + generator.StatusUpdated += (s, e) => + { + if (!string.IsNullOrWhiteSpace(generator.Status)) + { + Console.WriteLine(generator.Status); + } + }; + + // Start generation + generator.GenerateCollection(configuration); + + // Wait for completion + await Task.Run(async () => + { + while (!cts.Token.IsCancellationRequested) + { + await Task.Delay(100); + + // Check if task completed + if (generator.Collections != null && generator.Collections.Count > 0) + { + break; + } + } + + if (cts.Token.IsCancellationRequested) + { + await generator.AbortAsync(); + } + }); + + if (cts.Token.IsCancellationRequested || generator.Collections == null) + { + Console.WriteLine("Generation was aborted."); + return 1; + } + + // Save collections + string outputPath = GetOutputPath(); + osuFileIo.CollectionLoader.SaveCollection(generator.Collections, outputPath); + Console.WriteLine($"Done. Generated {generator.Collections.Count} collection(s)."); + + return 0; + } + + private string GetOutputPath() + => Path.HasExtension(OutputFilePath) + ? OutputFilePath + : $"{OutputFilePath}.osdb"; +} diff --git a/CollectionManager.App.Cli/Program.cs b/CollectionManager.App.Cli/Program.cs new file mode 100644 index 0000000..89f6f9b --- /dev/null +++ b/CollectionManager.App.Cli/Program.cs @@ -0,0 +1,19 @@ +namespace CollectionManager.App.Cli; + +using CollectionManager.App.Cli.Convert; +using CollectionManager.App.Cli.Create; +using CollectionManager.App.Cli.Generate; +using CommandLine; +using System.Threading.Tasks; + +internal static class Program +{ + private static async Task Main(string[] args) + => await Parser.Default.ParseArguments(args) + .MapResult( + (ConvertCommand cmd) => cmd.RunAsync(), + (CreateCommand cmd) => cmd.RunAsync(), + (GenerateCommand cmd) => cmd.RunAsync(), + _ => Task.FromResult(1) + ); +} diff --git a/CollectionManager.App.Cli/Properties/PublishProfiles/FolderProfile.pubxml b/CollectionManager.App.Cli/Properties/PublishProfiles/FolderProfile.pubxml new file mode 100644 index 0000000..8d668d0 --- /dev/null +++ b/CollectionManager.App.Cli/Properties/PublishProfiles/FolderProfile.pubxml @@ -0,0 +1,16 @@ + + + + + Release + Any CPU + bin\publish\ + FileSystem + <_TargetId>Folder + net9.0 + win-x64 + false + true + false + + \ No newline at end of file diff --git a/CollectionManager.App.Shared/Interfaces/IUpdateModel.cs b/CollectionManager.App.Shared/Interfaces/IUpdateModel.cs index 10b374d..745b801 100644 --- a/CollectionManager.App.Shared/Interfaces/IUpdateModel.cs +++ b/CollectionManager.App.Shared/Interfaces/IUpdateModel.cs @@ -6,6 +6,7 @@ public interface IUpdateModel bool Error { get; } Version OnlineVersion { get; } string NewVersionLink { get; } + string CurrentProductVersion { get; } Version CurrentVersion { get; } bool CheckForUpdates(); } \ No newline at end of file diff --git a/CollectionManager.App.Shared/Misc/BeatmapListingActionsHandler.cs b/CollectionManager.App.Shared/Misc/BeatmapListingActionsHandler.cs index 8724216..be94c7d 100644 --- a/CollectionManager.App.Shared/Misc/BeatmapListingActionsHandler.cs +++ b/CollectionManager.App.Shared/Misc/BeatmapListingActionsHandler.cs @@ -8,11 +8,15 @@ using CollectionManager.Core.Interfaces; using CollectionManager.Core.Modules.Collection; using CollectionManager.Core.Modules.FileIo; +using CollectionManager.Core.Modules.FileIo.OsuDb; using CollectionManager.Core.Types; using CollectionManager.Extensions.Enums; using CollectionManager.Extensions.Modules.CollectionListGenerator; using CollectionManager.Extensions.Modules.CollectionListGenerator.ListTypes; using CollectionManager.Extensions.Utils; +using System.ComponentModel; +using System.Diagnostics; +using System.Linq; using System.Threading.Tasks; public class BeatmapListingActionsHandler @@ -39,11 +43,75 @@ public BeatmapListingActionsHandler(ICollectionEditor collectionEditor, IUserDia {BeatmapListingAction.DownloadBeatmapsManaged, DownloadBeatmapsManaged }, {BeatmapListingAction.DownloadBeatmaps, DownloadBeatmapsAsync }, {BeatmapListingAction.OpenBeatmapPages, OpenBeatmapPagesAsync }, + {BeatmapListingAction.OpenInOsu, OpenInOsu }, {BeatmapListingAction.OpenBeatmapFolder, OpenBeatmapFolder }, {BeatmapListingAction.PullWholeMapSet, PullWholeMapsets }, {BeatmapListingAction.ExportBeatmapSets, ExportBeatmapSets }, }; } + + private async void OpenInOsu(object sender) + { + if (sender is not IBeatmapListingModel model || model.SelectedBeatmap is null) + { + return; + } + + if (model.SelectedBeatmap.MapId <= 0) + { + string artist = model.SelectedBeatmap.Artist ?? string.Empty; + string title = model.SelectedBeatmap.Title ?? string.Empty; + string diffName = model.SelectedBeatmap.DiffName ?? string.Empty; + + Helpers.SetClipboardText($"{artist} - {title} [{diffName}]"); + + bool dialogShown = await _userDialogs.OkMessageBoxAsync( + "Selected beatmap has no valid map id. Beatmap name was copied to your clipboard.", + "Info", + MessageBoxType.Info, + TimeSpan.FromSeconds(5), + "Do not inform me again in this session, and instead always focus osu!"); + + if (!dialogShown) + { + FocusOsuIfRunning(); + } + + return; + } + + try + { + _ = ProcessExtensions.OpenUrl($"osu://b/{model.SelectedBeatmap.MapId}"); + } + catch (InvalidOperationException) + { + _ = await _userDialogs.OkMessageBoxAsync("Unable to open osu! (osu:// protocol handler is not registered).", "Error", MessageBoxType.Error); + } + catch (Win32Exception) + { + _ = await _userDialogs.OkMessageBoxAsync("Unable to open osu! (osu:// protocol handler is not registered).", "Error", MessageBoxType.Error); + } + } + + private static void FocusOsuIfRunning() + { + Process? osuProcess = Process.GetProcessesByName("osu!").FirstOrDefault(); + if (osuProcess is null) + { + return; + } + + IntPtr windowHandle = osuProcess.MainWindowHandle; + if (windowHandle == IntPtr.Zero) + { + return; + } + + _ = Win32Interop.ShowWindow(windowHandle, Win32Interop.SwRestore); + _ = Win32Interop.SetForegroundWindow(windowHandle); + } + public void Bind(IBeatmapListingModel beatmapListingModel) => beatmapListingModel.BeatmapOperation += BeatmapListingModel_BeatmapOperation; public void UnBind(IBeatmapListingModel beatmapListingModel) => beatmapListingModel.BeatmapOperation -= BeatmapListingModel_BeatmapOperation; @@ -53,22 +121,59 @@ public BeatmapListingActionsHandler(ICollectionEditor collectionEditor, IUserDia private void PullWholeMapsets(object sender) { IBeatmapListingModel model = (IBeatmapListingModel)sender; + if (model.SelectedBeatmaps?.Count > 0) { - Beatmaps setBeatmaps = []; + HashSet targetMapSetIds = []; + HashSet targetDirs = []; foreach (Beatmap selectedBeatmap in model.SelectedBeatmaps) { - IEnumerable set = selectedBeatmap.MapSetId <= 20 - ? Initalizer.LoadedBeatmaps.Where(b => b.Dir == selectedBeatmap.Dir) - : Initalizer.LoadedBeatmaps.Where(b => b.MapSetId == selectedBeatmap.MapSetId); - setBeatmaps.AddRange(set); + if (selectedBeatmap.MapSetId <= MapCacher.InvalidMapIdThreshold) + { + _ = targetDirs.Add(selectedBeatmap.Dir); + } + else + { + _ = targetMapSetIds.Add(selectedBeatmap.MapSetId); + } + } + + Dictionary> mapsetBeatmaps = []; + Dictionary> unsubmittedBeatmaps = []; + + foreach (Beatmap beatmap in Initalizer.LoadedBeatmaps) + { + if (beatmap.MapSetId > 20 && targetMapSetIds.Contains(beatmap.MapSetId)) + { + if (!mapsetBeatmaps.TryGetValue(beatmap.MapSetId, out HashSet value)) + { + value = []; + mapsetBeatmaps[beatmap.MapSetId] = value; + } + + _ = value.Add(beatmap); + } + else if (beatmap.MapSetId <= MapCacher.InvalidMapIdThreshold && targetDirs.Contains(beatmap.Dir)) + { + if (!unsubmittedBeatmaps.TryGetValue(beatmap.Dir, out HashSet value)) + { + value = []; + unsubmittedBeatmaps[beatmap.Dir] = value; + } + _ = value.Add(beatmap); + } } - Initalizer.CollectionEditor.EditCollection( - CollectionEditArgs.AddBeatmaps(model.CurrentCollection.Name, setBeatmaps) - ); + List bulkEditArgs = new(mapsetBeatmaps.Count + unsubmittedBeatmaps.Count); + + bulkEditArgs.AddRange(mapsetBeatmaps.Values + .Select(beatmaps => CollectionEditArgs.AddBeatmaps(model.CurrentCollection.Name, beatmaps))); + bulkEditArgs.AddRange(unsubmittedBeatmaps.Values + .Select(beatmaps => CollectionEditArgs.AddBeatmaps(model.CurrentCollection.Name, beatmaps))); + + Initalizer.CollectionEditor.EditCollection(bulkEditArgs); } } private async void DownloadBeatmapsManaged(object sender) @@ -77,7 +182,7 @@ private async void DownloadBeatmapsManaged(object sender) OsuDownloadManager manager = OsuDownloadManager.Instance; if (model.SelectedBeatmaps == null || !model.SelectedBeatmaps.Any()) { - await _userDialogs.OkMessageBoxAsync("Select beatmaps with should be downloaded first, or use Online->Download all missing beatmaps option at the top instead", "Info", MessageBoxType.Info); + _ = await _userDialogs.OkMessageBoxAsync("Select beatmaps with should be downloaded first, or use Online->Download all missing beatmaps option at the top instead", "Info", MessageBoxType.Info); return; } diff --git a/CollectionManager.App.Shared/Misc/CollectionEditor.cs b/CollectionManager.App.Shared/Misc/CollectionEditor.cs index b939498..5debafe 100644 --- a/CollectionManager.App.Shared/Misc/CollectionEditor.cs +++ b/CollectionManager.App.Shared/Misc/CollectionEditor.cs @@ -6,7 +6,7 @@ namespace CollectionManager.App.Shared.Misc; using CollectionManager.Core.Modules.Collection; using CollectionManager.Core.Modules.FileIo.OsuDb; using CollectionManager.Core.Types; -using System.Collections.Generic; +using System.Linq; public class CollectionEditor : ICollectionEditor, ICollectionNameValidator { @@ -22,32 +22,53 @@ public CollectionEditor(CollectionsManager collectionsManager, _mapCacher = mapCacher; } - public void EditCollection(CollectionEditArgs args) + public void EditCollection(CollectionEditArgs args) => EditCollection([args]); + + public void EditCollection(IReadOnlyList args) { - if (args.Action is CollectionEdit.Rename or CollectionEdit.Add) + if (args is null || args.Count == 0) { - bool isRenameform = args.Action == CollectionEdit.Rename; + return; + } - string newCollectionName = _collectionAddRenameForm - .GetCollectionName(IsCollectionNameValid, args.CollectionNames.FirstOrDefault(), isRenameform); + List processedArgs = new(args.Count); - if (newCollectionName == "") - { - return; - } + foreach (CollectionEditArgs arg in args) + { + CollectionEditArgs processedArg = ProcessUIActionIfNeeded(arg); - switch (args.Action) + if (processedArg is not null) { - case CollectionEdit.Rename: - args = CollectionEditArgs.RenameCollection(args.CollectionNames[0], newCollectionName); - break; - case CollectionEdit.Add: - args = CollectionEditArgs.AddCollections([new OsuCollection(_mapCacher) { Name = newCollectionName }]); - break; + processedArgs.Add(processedArg); } } - _collectionsManager.EditCollection(args); + if (processedArgs.Count > 0) + { + _collectionsManager.EditCollection(processedArgs); + } + } + + private CollectionEditArgs? ProcessUIActionIfNeeded(CollectionEditArgs args) + { + if (args.Action is not (CollectionEdit.Rename or CollectionEdit.Add)) + { + return args; + } + + string newCollectionName = _collectionAddRenameForm.GetCollectionName(IsCollectionNameValid, args.CollectionNames.FirstOrDefault(), isRenameForm: args.Action == CollectionEdit.Rename); + + if (string.IsNullOrEmpty(newCollectionName)) + { + return null; + } + + return args.Action switch + { + CollectionEdit.Rename => CollectionEditArgs.RenameCollection(args.CollectionNames[0], newCollectionName), + CollectionEdit.Add => CollectionEditArgs.AddCollections([new OsuCollection(_mapCacher) { Name = newCollectionName }]), + _ => args + }; } public OsuCollections GetCollectionsForBeatmaps(Beatmaps beatmaps) diff --git a/CollectionManager.App.Shared/Misc/Helpers.cs b/CollectionManager.App.Shared/Misc/Helpers.cs index 87eae06..7e855da 100644 --- a/CollectionManager.App.Shared/Misc/Helpers.cs +++ b/CollectionManager.App.Shared/Misc/Helpers.cs @@ -8,23 +8,9 @@ namespace CollectionManager.App.Shared.Misc; using CollectionManager.Core.Types; using CollectionManager.Extensions.Modules.Downloader.Api; using System.Collections.Specialized; -using System.Reflection; + public static class Helpers { - public static IEnumerable GetLoadableTypes(this Assembly assembly) - { - ArgumentNullException.ThrowIfNull(assembly); - - try - { - return assembly.GetTypes(); - } - catch (ReflectionTypeLoadException e) - { - return e.Types.Where(t => t != null); - } - } - public static LoginData GetLoginData(this ILoginFormView loginForm, IReadOnlyList downloadSources) { diff --git a/CollectionManager.App.Shared/Misc/OsuDownloadManager.cs b/CollectionManager.App.Shared/Misc/OsuDownloadManager.cs index 2464156..59be7e0 100644 --- a/CollectionManager.App.Shared/Misc/OsuDownloadManager.cs +++ b/CollectionManager.App.Shared/Misc/OsuDownloadManager.cs @@ -10,7 +10,6 @@ namespace CollectionManager.App.Shared.Misc; using Newtonsoft.Json; using System.IO; using System.Net; -using System.Reflection; using System.Threading.Tasks; public sealed class OsuDownloadManager @@ -27,7 +26,7 @@ public sealed class OsuDownloadManager private DownloadManager _mapDownloader; private readonly Lazy> _downloadSources = new(() => { - string configLocation = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "downloadSources.json"); + string configLocation = Path.Combine(Path.GetDirectoryName(Environment.ProcessPath), "downloadSources.json"); return !File.Exists(configLocation) ? throw new FileNotFoundException("download sources configuration is missing!") : JsonConvert.DeserializeObject>(File.ReadAllText(configLocation)); @@ -101,20 +100,8 @@ public async Task AskUserForSaveDirectoryAndLoginAsync(IUserDialogs userDi public void DownloadBeatmap(Beatmap beatmap) => DownloadBeatmap(beatmap, true); - public void PauseDownloads() - { - if (_mapDownloader != null) - { - _mapDownloader.StopDownloads = true; - } - } - public void ResumeDownloads() - { - if (_mapDownloader != null) - { - _mapDownloader.StopDownloads = false; - } - } + public void PauseDownloads() => _mapDownloader?.StopDownloads = true; + public void ResumeDownloads() => _mapDownloader?.StopDownloads = false; private void MapDownloaderOnProgressUpdated(object sender, DownloadProgressChangedEventArgs downloadProgressChangedEventArgs) { diff --git a/CollectionManager.App.Shared/Misc/SidePanelActions/GenerateCollectionsHandler.cs b/CollectionManager.App.Shared/Misc/SidePanelActions/GenerateCollectionsHandler.cs index aadba22..142205c 100644 --- a/CollectionManager.App.Shared/Misc/SidePanelActions/GenerateCollectionsHandler.cs +++ b/CollectionManager.App.Shared/Misc/SidePanelActions/GenerateCollectionsHandler.cs @@ -20,7 +20,7 @@ public sealed class GenerateCollectionsHandler : IMainSidePanelActionHandler, ID private readonly IUserDialogs _userDialogs; private readonly ICollectionEditor _collectionEditor; - private CollectionsApiGenerator _collectionGenerator; + private readonly CollectionsApiGenerator _collectionGenerator; private IUserTopGeneratorForm _userTopGeneratorForm; private IUsernameGeneratorForm _usernameGeneratorForm; private readonly OsuSite _osuSite = new(); @@ -94,5 +94,9 @@ private static OsuSite.LogUsernameGeneration CreateProcessingLogger(UsernameGene model.CompletionPercentage = completionPercentage; }; - public void Dispose() => throw new NotImplementedException(); + public void Dispose() + { + _collectionGenerator.Dispose(); + _osuSite.Dispose(); + } } diff --git a/CollectionManager.App.Shared/Misc/SidePanelActions/SaveCollectionsAsDbHandler.cs b/CollectionManager.App.Shared/Misc/SidePanelActions/SaveCollectionsAsDbHandler.cs new file mode 100644 index 0000000..b147329 --- /dev/null +++ b/CollectionManager.App.Shared/Misc/SidePanelActions/SaveCollectionsAsDbHandler.cs @@ -0,0 +1,34 @@ +namespace CollectionManager.App.Shared.Misc.SidePanelActions; + +using CollectionManager.App.Shared; +using CollectionManager.Common; +using CollectionManager.Common.Interfaces; +using CollectionManager.Core.Modules.FileIo; + +public sealed class SaveCollectionsAsDbHandler : IMainSidePanelActionHandler +{ + private const string Filter = "osu! Collection database (.db)|*.db"; + + private readonly OsuFileIo _osuFileIo; + private readonly IUserDialogs _userDialogs; + + public MainSidePanelActions Action { get; } = MainSidePanelActions.SaveCollectionsAsDb; + + public SaveCollectionsAsDbHandler(OsuFileIo osuFileIo, IUserDialogs userDialogs) + { + _osuFileIo = osuFileIo; + _userDialogs = userDialogs; + } + + public async Task HandleAsync(object sender, object data) + { + string fileLocation = await _userDialogs.SaveFileAsync("Where collection file should be saved?", Filter); + if (string.IsNullOrWhiteSpace(fileLocation)) + { + return; + } + + await SidePanelActionHelpers.BeforeCollectionSave(Initalizer.LoadedCollections); + _osuFileIo.CollectionLoader.SaveCollection(Initalizer.LoadedCollections, fileLocation); + } +} diff --git a/CollectionManager.App.Shared/Misc/SidePanelActions/SaveCollectionsHandler.cs b/CollectionManager.App.Shared/Misc/SidePanelActions/SaveCollectionsAsOsdbHandler.cs similarity index 69% rename from CollectionManager.App.Shared/Misc/SidePanelActions/SaveCollectionsHandler.cs rename to CollectionManager.App.Shared/Misc/SidePanelActions/SaveCollectionsAsOsdbHandler.cs index ca13ecf..3154fa6 100644 --- a/CollectionManager.App.Shared/Misc/SidePanelActions/SaveCollectionsHandler.cs +++ b/CollectionManager.App.Shared/Misc/SidePanelActions/SaveCollectionsAsOsdbHandler.cs @@ -5,14 +5,16 @@ namespace CollectionManager.App.Shared.Misc.SidePanelActions; using CollectionManager.Common.Interfaces; using CollectionManager.Core.Modules.FileIo; -public sealed class SaveCollectionsHandler : IMainSidePanelActionHandler +public sealed class SaveCollectionsAsOsdbHandler : IMainSidePanelActionHandler { + private const string Filter = "CM database (.osdb)|*.osdb"; + private readonly OsuFileIo _osuFileIo; private readonly IUserDialogs _userDialogs; - public MainSidePanelActions Action { get; } = MainSidePanelActions.SaveCollections; + public MainSidePanelActions Action { get; } = MainSidePanelActions.SaveCollectionsAsOsdb; - public SaveCollectionsHandler(OsuFileIo osuFileIo, IUserDialogs userDialogs) + public SaveCollectionsAsOsdbHandler(OsuFileIo osuFileIo, IUserDialogs userDialogs) { _osuFileIo = osuFileIo; _userDialogs = userDialogs; @@ -20,8 +22,8 @@ public SaveCollectionsHandler(OsuFileIo osuFileIo, IUserDialogs userDialogs) public async Task HandleAsync(object sender, object data) { - string fileLocation = await _userDialogs.SaveFileAsync("Where collection file should be saved?", "osu! Collection database (.db)|*.db|CM database (.osdb)|*.osdb"); - if (fileLocation == string.Empty) + string fileLocation = await _userDialogs.SaveFileAsync("Where collection file should be saved?", Filter); + if (string.IsNullOrWhiteSpace(fileLocation)) { return; } diff --git a/CollectionManager.App.Shared/Misc/SidePanelActions/SaveDefaultCollectionHandler.cs b/CollectionManager.App.Shared/Misc/SidePanelActions/SaveDefaultCollectionHandler.cs index e6f1de2..44128b0 100644 --- a/CollectionManager.App.Shared/Misc/SidePanelActions/SaveDefaultCollectionHandler.cs +++ b/CollectionManager.App.Shared/Misc/SidePanelActions/SaveDefaultCollectionHandler.cs @@ -5,6 +5,7 @@ namespace CollectionManager.App.Shared.Misc.SidePanelActions; using CollectionManager.Common.Interfaces; using CollectionManager.Core.Modules.FileIo; using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Security.Cryptography; @@ -33,7 +34,7 @@ public async Task HandleAsync(object sender, object data) if (!File.Exists(fileLocation)) { - await _userDialogs.OkMessageBoxAsync("Could not find collection file to overwritte!", "Error", MessageBoxType.Error); + await _userDialogs.OkMessageBoxAsync("Could not find collection file to overwritte!", caption("Error"), MessageBoxType.Error); return; } } @@ -42,7 +43,7 @@ public async Task HandleAsync(object sender, object data) { if (SidePanelActionHelpers.OsuIsRunning(isLegacyCollectionFile)) { - await _userDialogs.OkMessageBoxAsync("Close your osu! before saving collections!", "Error", MessageBoxType.Error); + await _userDialogs.OkMessageBoxAsync("Close your osu! before saving collections!", caption("Error"), MessageBoxType.Error); return; } } @@ -54,28 +55,32 @@ public async Task HandleAsync(object sender, object data) throw; } - await _userDialogs.OkMessageBoxAsync("Could not determine if osu! is running due to a permissions error.", "Warning", MessageBoxType.Warning); + await _userDialogs.OkMessageBoxAsync("Could not determine if osu! is running due to a permissions error.", caption("Warning"), MessageBoxType.Warning); } if (await _userDialogs.YesNoMessageBoxAsync($"Are you sure that you want to overwrite your existing osu! collection at \"{fileLocation}\"?", - "Are you sure?", MessageBoxType.Question)) + caption(), MessageBoxType.Question)) { await SidePanelActionHelpers.BeforeCollectionSave(Initalizer.LoadedCollections); string backupFolder = Path.Combine(Initalizer.OsuDirectory, "collectionBackups"); if (!TryBackupOsuCollection(backupFolder)) { - await _userDialogs.OkMessageBoxAsync("Could not create collection backup. Save aborted.", "Error", MessageBoxType.Error); + await _userDialogs.OkMessageBoxAsync("Could not create collection backup. Save aborted.", caption("Error"), MessageBoxType.Error); return; } _osuFileIo.CollectionLoader.SaveCollection(Initalizer.LoadedCollections, fileLocation); - await _userDialogs.OkMessageBoxAsync($"Collections saved.{Environment.NewLine}Previous collection backup was saved in \"{backupFolder}\" and will be kept for 30 days.", "Info", MessageBoxType.Success); + await _userDialogs.OkMessageBoxAsync($"Collections saved.{Environment.NewLine}Previous collection backup was saved in \"{backupFolder}\" and will be kept for 30 days.", caption("Success"), MessageBoxType.Success); } else { - await _userDialogs.OkMessageBoxAsync("Save Aborted", "Info", MessageBoxType.Warning); + await _userDialogs.OkMessageBoxAsync("Save Aborted", caption("Info"), MessageBoxType.Info); } + + static string caption(string subAction = default) => subAction is null + ? "Default collection save" + : $"Default collection save - {subAction}"; } private static bool TryBackupOsuCollection(string backupFolder) @@ -116,6 +121,7 @@ private static bool TryBackupOsuCollection(string backupFolder) return true; + [SuppressMessage("Security", "CA5351:Do Not Use Broken Cryptographic Algorithms", Justification = "Not relevant for file names.")] static string CalculateMD5(string filename) { using MD5 md5 = MD5.Create(); diff --git a/CollectionManager.App.Shared/Misc/SidePanelActionsHandler.cs b/CollectionManager.App.Shared/Misc/SidePanelActionsHandler.cs index 7d4c846..9901093 100644 --- a/CollectionManager.App.Shared/Misc/SidePanelActionsHandler.cs +++ b/CollectionManager.App.Shared/Misc/SidePanelActionsHandler.cs @@ -63,7 +63,8 @@ private static Dictionary Cre { MainSidePanelActions.LoadCollection, new LoadCollectionHandler(osuFileIo, collectionEditor, userDialogs) }, { MainSidePanelActions.LoadDefaultCollection, new LoadDefaultCollectionHandler(osuFileIo, collectionEditor, userDialogs) }, { MainSidePanelActions.ClearCollections, new ClearCollectionsHandler(collectionEditor) }, - { MainSidePanelActions.SaveCollections, new SaveCollectionsHandler(osuFileIo, userDialogs) }, + { MainSidePanelActions.SaveCollectionsAsDb, new SaveCollectionsAsDbHandler(osuFileIo, userDialogs) }, + { MainSidePanelActions.SaveCollectionsAsOsdb, new SaveCollectionsAsOsdbHandler(osuFileIo, userDialogs) }, { MainSidePanelActions.SaveDefaultCollection, new SaveDefaultCollectionHandler(osuFileIo, userDialogs) }, { MainSidePanelActions.SaveIndividualCollections, new SaveIndividualCollectionsHandler(osuFileIo, userDialogs) }, { MainSidePanelActions.ShowBeatmapListing, new ShowBeatmapListingHandler(beatmapListingBindingProvider, userDialogs) }, diff --git a/CollectionManager.App.Shared/Misc/UpdateChecker.cs b/CollectionManager.App.Shared/Misc/UpdateChecker.cs index 3d6a15e..76f120f 100644 --- a/CollectionManager.App.Shared/Misc/UpdateChecker.cs +++ b/CollectionManager.App.Shared/Misc/UpdateChecker.cs @@ -1,31 +1,46 @@ -namespace CollectionManager.App.Shared.Misc; +namespace CollectionManager.App.Shared.Misc; using CollectionManager.App.Shared.Interfaces; using CollectionManager.Extensions.Utils; using Newtonsoft.Json; using Newtonsoft.Json.Linq; -using System.Reflection; +using System.Diagnostics; public class UpdateChecker : IUpdateModel { private const string baseGithubUrl = "https://api.github.com/repos/Piotrekol/CollectionManager"; private const string githubUpdateUrl = baseGithubUrl + "/releases/latest"; - public UpdateChecker() + private readonly Lazy _versionInfo = new(() => { - FileVersionInfo version = FileVersionInfo.GetVersionInfo(Assembly.GetExecutingAssembly().Location); - CurrentVersion = new Version(version.FileVersion); - } + string executableLocation = Environment.ProcessPath; + + return string.IsNullOrEmpty(executableLocation) + ? null + : FileVersionInfo.GetVersionInfo(executableLocation); + }); public bool Error { get; private set; } public Version OnlineVersion { get; private set; } public string NewVersionLink { get; private set; } - public Version CurrentVersion { get; } + public string CurrentProductVersion + => _versionInfo.Value?.ProductVersion ?? "unknown"; + + public Version CurrentVersion + => field ??= _versionInfo.Value is { FileVersion: { } v } + ? new Version(v) + : new Version(-1, -1, -1, -1); public bool UpdateIsAvailable => OnlineVersion != null && OnlineVersion > CurrentVersion; public bool CheckForUpdates() { + if (CurrentVersion.MajorRevision < 0) + { + Error = true; + return false; + } + string data = GetStringData(githubUpdateUrl); if (string.IsNullOrEmpty(data)) { diff --git a/CollectionManager.App.Shared/Misc/Win32Interop.cs b/CollectionManager.App.Shared/Misc/Win32Interop.cs new file mode 100644 index 0000000..7e4207f --- /dev/null +++ b/CollectionManager.App.Shared/Misc/Win32Interop.cs @@ -0,0 +1,20 @@ +namespace CollectionManager.App.Shared.Misc; + +using System; +using System.Runtime.InteropServices; + +internal static class Win32Interop +{ + /// + /// Command for that activates and displays a window. + /// If the window is minimized or maximized, the system restores it to its original size and position. + /// Equivalent to Win32 SW_RESTORE. + /// + internal const int SwRestore = 9; + + [DllImport("user32.dll")] + internal static extern bool SetForegroundWindow(IntPtr hWnd); + + [DllImport("user32.dll")] + internal static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); +} diff --git a/CollectionManager.App.Shared/Presenters/Controls/InfoTextPresenter.cs b/CollectionManager.App.Shared/Presenters/Controls/InfoTextPresenter.cs index c817433..c92ddbf 100644 --- a/CollectionManager.App.Shared/Presenters/Controls/InfoTextPresenter.cs +++ b/CollectionManager.App.Shared/Presenters/Controls/InfoTextPresenter.cs @@ -3,6 +3,7 @@ namespace CollectionManager.App.Shared.Presenters.Controls; using CollectionManager.App.Shared.Interfaces; using CollectionManager.App.Shared.Interfaces.Controls; using CollectionManager.Common.Interfaces.Controls; +using System.Globalization; public class InfoTextPresenter { @@ -10,8 +11,8 @@ public class InfoTextPresenter private readonly IInfoTextModel _model; private static Task CheckForUpdatesTask; - private const string UpdateAvaliable = "Update is avaliable!({0})"; - private const string UpdateError = "Error while checking for updates."; + private const string UpdateAvaliable = "Update is avaliable! ({0})"; + private const string UpdateError = "Error while checking for updates. ({0})"; private const string NoUpdatesAvailable = "No updates avaliable ({0})"; private const string FetchingUpdateInformation = "Fetching update information.."; @@ -35,7 +36,7 @@ public InfoTextPresenter(IInfoTextView view, IInfoTextModel model) private void ViewOnUpdateTextClicked(object sender, EventArgs eventArgs) => _model.EmitUpdateTextClicked(); - private void ModelOnCountsUpdated(object sender, EventArgs eventArgs) => _view.CollectionManagerStatus = $"Loaded {_model.BeatmapCount} beatmaps && {_model.CollectionsCount} collections with {_model.BeatmapsInCollectionsCount} beatmaps. Missing {_model.MissingMapSetsCount} downloadable map sets. {_model.UnknownMapCount} unknown maps."; + private void ModelOnCountsUpdated(object sender, EventArgs eventArgs) => _view.CollectionManagerStatus = $"Loaded {_model.BeatmapCount} beatmaps && {_model.CollectionsCount} collections with {_model.BeatmapsInCollectionsCount} beatmaps. Missing {_model.MissingMapSetsCount} downloadable map sets. {_model.UnknownMapCount} unknown maps"; private Task CheckForUpdates() { @@ -70,23 +71,24 @@ private void SetUpdateText(IUpdateModel updater) _view.ColorUpdateText = true; - if (updater.OnlineVersion == null) + if (updater.Error) { - _view.UpdateText = FetchingUpdateInformation; + _view.UpdateText = string.Format(CultureInfo.InvariantCulture, UpdateError, updater.CurrentProductVersion); _view.ColorUpdateText = false; } - else if (updater.UpdateIsAvailable) + else if (updater.OnlineVersion == null) { - _view.UpdateText = string.Format(UpdateAvaliable, updater.OnlineVersion); + _view.UpdateText = FetchingUpdateInformation; + _view.ColorUpdateText = false; } - else if (updater.Error) + else if (updater.UpdateIsAvailable) { - _view.UpdateText = UpdateError; + _view.UpdateText = string.Format(CultureInfo.InvariantCulture, UpdateAvaliable, updater.OnlineVersion); } else { _view.ColorUpdateText = false; - _view.UpdateText = string.Format(NoUpdatesAvailable, updater.CurrentVersion); + _view.UpdateText = string.Format(CultureInfo.InvariantCulture, NoUpdatesAvailable, updater.CurrentProductVersion); } } } \ No newline at end of file diff --git a/CollectionManager.App.Shared/Presenters/Controls/MusicControlPresenter.cs b/CollectionManager.App.Shared/Presenters/Controls/MusicControlPresenter.cs index a4afc87..7e30380 100644 --- a/CollectionManager.App.Shared/Presenters/Controls/MusicControlPresenter.cs +++ b/CollectionManager.App.Shared/Presenters/Controls/MusicControlPresenter.cs @@ -3,6 +3,7 @@ using CollectionManager.App.Shared.Interfaces.Controls; using CollectionManager.Audio; using CollectionManager.Common.Interfaces.Controls; +using CollectionManager.Core.Modules.FileIo.OsuDb; using CollectionManager.Core.Types; using CollectionManager.Extensions.Utils; using System.ComponentModel; @@ -147,7 +148,7 @@ private bool ShouldSkipTrack(Beatmap map, string audioLocation) //this is user-invoked play request (_music player mode_ check above) //Is there a chance that preview of this beatmap exists in online source? - return map.MapSetId <= 20; + return map.MapSetId <= MapCacher.InvalidMapIdThreshold; } private void PlayBeatmap(Beatmap map) @@ -160,7 +161,7 @@ private void PlayBeatmap(Beatmap map) bool onlineSource = false; if (audioLocation == string.Empty) { - if (map.MapSetId <= 20) + if (map.MapSetId <= MapCacher.InvalidMapIdThreshold) { return; } diff --git a/CollectionManager.App.Shared/Presenters/Controls/StartupPresenter.cs b/CollectionManager.App.Shared/Presenters/Controls/StartupPresenter.cs index 3037116..84cf5d8 100644 --- a/CollectionManager.App.Shared/Presenters/Controls/StartupPresenter.cs +++ b/CollectionManager.App.Shared/Presenters/Controls/StartupPresenter.cs @@ -215,13 +215,14 @@ private void _view_Closing(object sender, EventArgs eventArgs) private void LoadDatabase(CancellationToken cancellationToken) { OsuFileIo osuFileIo = Initalizer.OsuFileIo; - string osuDirectory = Initalizer.OsuDirectory = string.IsNullOrEmpty(_startupSettings.OsuLocation) - ? OsuPathResolver.GetOsuOrLazerPath() + string osuDirectory = string.IsNullOrEmpty(_startupSettings.OsuLocation) + ? OsuPathResolver.GetOsuOrLazerPath().Path : _startupSettings.OsuLocation; + Initalizer.OsuDirectory = osuDirectory; + string osuDbOrRealmPath = new[] { Path.Combine(osuDirectory, @"osu!.db"), Path.Combine(osuDirectory, @"client.realm") } - .Where(File.Exists) - .FirstOrDefault(); + .FirstOrDefault(File.Exists); if (string.IsNullOrEmpty(osuDirectory) || !Directory.Exists(osuDirectory) || string.IsNullOrEmpty(osuDbOrRealmPath)) { diff --git a/CollectionManager.App.WinForms/CollectionManager.App.WinForms.csproj b/CollectionManager.App.WinForms/CollectionManager.App.WinForms.csproj index a2c834c..2e4d4b0 100644 --- a/CollectionManager.App.WinForms/CollectionManager.App.WinForms.csproj +++ b/CollectionManager.App.WinForms/CollectionManager.App.WinForms.csproj @@ -2,10 +2,7 @@ WinExe osu! Collection Manager - Gui Copyright © 2017-present Piotrekol - false - false @@ -26,12 +23,10 @@ + - - - diff --git a/CollectionManager.App.WinForms/Properties/AssemblyInfo.cs b/CollectionManager.App.WinForms/Properties/AssemblyInfo.cs index ec99762..945ccf6 100644 --- a/CollectionManager.App.WinForms/Properties/AssemblyInfo.cs +++ b/CollectionManager.App.WinForms/Properties/AssemblyInfo.cs @@ -1,5 +1,4 @@ -using System.Reflection; -using System.Runtime.InteropServices; +using System.Runtime.InteropServices; // Setting ComVisible to false makes the types in this assembly not visible // to COM components. If you need to access a type in this assembly from @@ -8,5 +7,3 @@ // The following GUID is for the ID of the typelib if this project is exposed to COM [assembly: Guid("f87a0f61-b6f3-4b5e-8a1a-c19c8c8feaa4")] -[assembly: AssemblyVersion("1.0.0.0")] -[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/CollectionManager.App.WinForms/Properties/PublishProfiles/FolderProfile.pubxml b/CollectionManager.App.WinForms/Properties/PublishProfiles/FolderProfile.pubxml new file mode 100644 index 0000000..3b2b63a --- /dev/null +++ b/CollectionManager.App.WinForms/Properties/PublishProfiles/FolderProfile.pubxml @@ -0,0 +1,16 @@ + + + + + Release + Any CPU + bin\publish\ + FileSystem + <_TargetId>Folder + net9.0-windows + false + win-x64 + true + false + + \ No newline at end of file diff --git a/CollectionManager.App.WinForms/WinFormsGuiComponentsProvider.cs b/CollectionManager.App.WinForms/WinFormsGuiComponentsProvider.cs index e884260..fcbb109 100644 --- a/CollectionManager.App.WinForms/WinFormsGuiComponentsProvider.cs +++ b/CollectionManager.App.WinForms/WinFormsGuiComponentsProvider.cs @@ -1,39 +1,19 @@ namespace CollectionManager.App.Winforms; using CollectionManager.App.Shared.Misc; -using System.IO; -using System.Reflection; +using CollectionManager.WinForms; +using Microsoft.Extensions.DependencyInjection; public sealed class WinFormsGuiComponentsProvider : IGuiComponentsProvider { - public WinFormsGuiComponentsProvider() - { - LoadGuiDll(); - } - - private string GuiDllLocation { get; set; } - private Assembly GuiDllAssembly { get; set; } - private void LoadGuiDll() - { - GuiDllLocation = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "CollectionManager.WinForms.dll"); - GuiDllAssembly = Assembly.LoadFile(GuiDllLocation); - } + private readonly ServiceProvider serviceProvider; - private static IEnumerable GetTypesWithInterface(Assembly asm) + public WinFormsGuiComponentsProvider() { - Type it = typeof(T); - return asm.GetLoadableTypes().Where(it.IsAssignableFrom).ToList(); + ServiceCollection serviceCollection = new(); + FormServices.RegisterServices(serviceCollection); + serviceProvider = serviceCollection.BuildServiceProvider(); } - public T GetClassImplementing() => GetClassImplementing(GuiDllAssembly); - - private static T GetClassImplementing(Assembly asm) - { - foreach (Type tt in GetTypesWithInterface(asm)) - { - return (T)Activator.CreateInstance(tt); - } - - return default; - } + public T GetClassImplementing() => serviceProvider.GetRequiredService(); } \ No newline at end of file diff --git a/CollectionManager.Common/BeatmapListingAction.cs b/CollectionManager.Common/BeatmapListingAction.cs index 77acf94..67d8617 100644 --- a/CollectionManager.Common/BeatmapListingAction.cs +++ b/CollectionManager.Common/BeatmapListingAction.cs @@ -6,6 +6,7 @@ public enum BeatmapListingAction DownloadBeatmaps, // Open download links using (?)default browser DownloadBeatmapsManaged, // Download selected beatmaps using internal downloader OpenBeatmapPages, // Open beatmap pages in (?)default browser + OpenInOsu, // Open selected beatmap in osu! via osu:// URI scheme OpenBeatmapFolder, //Open currently selected beatmap folder(s?) CopyBeatmapsAsText, //Copy text representation of selected beatmaps CopyBeatmapsAsUrls, //Copy map links of selected beatmaps diff --git a/CollectionManager.Common/Interfaces/IUserDialogs.cs b/CollectionManager.Common/Interfaces/IUserDialogs.cs index a2d01fd..f8f9aa3 100644 --- a/CollectionManager.Common/Interfaces/IUserDialogs.cs +++ b/CollectionManager.Common/Interfaces/IUserDialogs.cs @@ -13,6 +13,6 @@ public interface IUserDialogs Task YesNoMessageBoxAsync(string text, string caption, MessageBoxType messageBoxType = MessageBoxType.Info); Task<(bool Result, bool doNotAskAgain)> YesNoMessageBoxAsync(string text, string caption, MessageBoxType messageBoxType = MessageBoxType.Info, string doNotAskAgainText = null); Task CreateProgressFormAsync(Progress userProgressMessage, Progress completionPercentage); - Task OkMessageBoxAsync(string text, string caption, MessageBoxType messageBoxType = MessageBoxType.Info); + Task OkMessageBoxAsync(string text, string caption, MessageBoxType messageBoxType = MessageBoxType.Info, TimeSpan? autoCloseAfter = null, string? doNotInformAgainText = null); Task TextMessageBoxAsync(string text, string caption); } diff --git a/CollectionManager.Common/MRUFileCache.cs b/CollectionManager.Common/MRUFileCache.cs index 4c95fc6..d5746a8 100644 --- a/CollectionManager.Common/MRUFileCache.cs +++ b/CollectionManager.Common/MRUFileCache.cs @@ -45,7 +45,7 @@ public string DownloadAndAdd(string url) { try { - ws.DownloadFile(url, filePath); + ws.DownloadFile(url, tempFilePath); } catch (WebException) { diff --git a/CollectionManager.Common/MainSidePanelActions.cs b/CollectionManager.Common/MainSidePanelActions.cs index 5ca2ea6..c6b3737 100644 --- a/CollectionManager.Common/MainSidePanelActions.cs +++ b/CollectionManager.Common/MainSidePanelActions.cs @@ -5,7 +5,8 @@ public enum MainSidePanelActions LoadCollection, LoadDefaultCollection, ClearCollections, - SaveCollections, + SaveCollectionsAsDb, + SaveCollectionsAsOsdb, SaveIndividualCollections, ListMissingMaps, ShowBeatmapListing, diff --git a/CollectionManager.Core/Example.cs b/CollectionManager.Core/Example.cs index 3db4afc..465e9f7 100644 --- a/CollectionManager.Core/Example.cs +++ b/CollectionManager.Core/Example.cs @@ -3,6 +3,7 @@ using CollectionManager.Core.Modules.Collection; using CollectionManager.Core.Modules.FileIo; using CollectionManager.Core.Types; +using System.Threading; internal class Class1 { @@ -17,7 +18,7 @@ internal async Task Run() string osuDbFileName = "osu!.db"; string ExampleCollectionFileLocation = @"E:\osuCollections\SomeCollectionThatExists.db"; - osuFileIo.OsuDatabase.Load(osuPath + osuDbFileName); + _ = osuFileIo.OsuDatabase.Load(osuPath + osuDbFileName, null, CancellationToken.None); //osu! configuration file is currently only used for getting a songs folder location osuFileIo.OsuSettings.Load(osuPath); diff --git a/CollectionManager.Core/Extensions/OsuTypeExtensions.cs b/CollectionManager.Core/Extensions/OsuTypeExtensions.cs new file mode 100644 index 0000000..009ce77 --- /dev/null +++ b/CollectionManager.Core/Extensions/OsuTypeExtensions.cs @@ -0,0 +1,15 @@ +namespace CollectionManager.Core.Extensions; + +using CollectionManager.Core.Types; + +public static class OsuTypeExtensions +{ + public static string GetDatabaseFileName(this OsuType osuType) + => osuType switch + { + OsuType.Lazer => "client.realm", + OsuType.Stable => "osu!.db", + OsuType.None => null, + _ => throw new NotImplementedException() + }; +} diff --git a/CollectionManager.Core/Interfaces/ICollectionEditor.cs b/CollectionManager.Core/Interfaces/ICollectionEditor.cs index 78970ef..6bbbffb 100644 --- a/CollectionManager.Core/Interfaces/ICollectionEditor.cs +++ b/CollectionManager.Core/Interfaces/ICollectionEditor.cs @@ -5,6 +5,19 @@ public interface ICollectionEditor { + /// + /// Performs a single edit on the collections. + /// void EditCollection(CollectionEditArgs args); + + /// + /// Performs multiple edits on the collections in bulk. + /// + /// List of arguments containing the actions to perform. + void EditCollection(IReadOnlyList args); + + /// + /// Gets the collections that contain any of the specified beatmaps. + /// OsuCollections GetCollectionsForBeatmaps(Beatmaps beatmaps); } \ No newline at end of file diff --git a/CollectionManager.Core/Modules/Collection/CollectionsManager.cs b/CollectionManager.Core/Modules/Collection/CollectionsManager.cs index ad7c8ef..12dc4a8 100644 --- a/CollectionManager.Core/Modules/Collection/CollectionsManager.cs +++ b/CollectionManager.Core/Modules/Collection/CollectionsManager.cs @@ -1,4 +1,5 @@ namespace CollectionManager.Core.Modules.Collection; + using CollectionManager.Core.Enums; using CollectionManager.Core.Interfaces; using CollectionManager.Core.Modules.Collection.Strategies; @@ -65,8 +66,24 @@ public void EditCollection(CollectionEditArgs args, bool suspendRefresh = false) AfterCollectionsEdit(); } } + public void EditCollection(CollectionEditArgs args) => EditCollection(args, false); + public void EditCollection(IReadOnlyList args) + { + if (args is null || args.Count is 0) + { + return; + } + + foreach (CollectionEditArgs arg in args) + { + EditCollection(arg, true); + } + + AfterCollectionsEdit(); + } + public OsuCollections GetCollectionsForBeatmaps(Beatmaps beatmaps) { OsuCollections collections = []; @@ -87,7 +104,7 @@ public string GetValidCollectionName(string desiredName, IReadOnlyList a string newName = desiredName; IReadOnlyList reservedNames = additionalReservedNames ?? Array.Empty(); - while (CollectionNameExists(newName) || reservedNames.Contains(newName)) + while (!IsCollectionNameValid(newName) || reservedNames.Contains(newName)) { newName = $"{desiredName}_{c++}"; } @@ -108,7 +125,7 @@ public bool CollectionNameExists(string name) return false; } - public bool IsCollectionNameValid(string name) => !CollectionNameExists(name); + public bool IsCollectionNameValid(string name) => !(string.IsNullOrEmpty(name) || CollectionNameExists(name)); protected virtual void AfterCollectionsEdit() => LoadedCollections.CallReset(); } \ No newline at end of file diff --git a/CollectionManager.Core/Modules/Collection/Strategies/RemoveBeatmapsStrategy.cs b/CollectionManager.Core/Modules/Collection/Strategies/RemoveBeatmapsStrategy.cs index ee30210..1696ff9 100644 --- a/CollectionManager.Core/Modules/Collection/Strategies/RemoveBeatmapsStrategy.cs +++ b/CollectionManager.Core/Modules/Collection/Strategies/RemoveBeatmapsStrategy.cs @@ -11,10 +11,7 @@ public void Execute(CollectionsManager manager, CollectionEditArgs args) if (collection is not null) { - foreach (Beatmap beatmap in args.Beatmaps) - { - _ = collection.RemoveBeatmap(beatmap.Md5); - } + _ = collection.RemoveBeatmaps(args.Beatmaps.Select(b => b.Md5)); } } } \ No newline at end of file diff --git a/CollectionManager.Core/Modules/FileIO/FileCollections/CollectionLoader.cs b/CollectionManager.Core/Modules/FileIO/FileCollections/CollectionLoader.cs index e40df8b..06d7f7e 100644 --- a/CollectionManager.Core/Modules/FileIO/FileCollections/CollectionLoader.cs +++ b/CollectionManager.Core/Modules/FileIO/FileCollections/CollectionLoader.cs @@ -1,4 +1,5 @@ namespace CollectionManager.Core.Modules.FileIo.FileCollections; + using CollectionManager.Core.Modules.FileIo.OsuDb; using CollectionManager.Core.Types; using System.IO; @@ -6,7 +7,6 @@ public class CollectionLoader { private readonly MapCacher _mapCacher; - private readonly OsdbCollectionHandler OsdbCollectionHandler = new(null); private readonly OsuCollectionHandler OsuCollectionHandler = new(null); private readonly LazerCollectionHandler LazerCollectionHandler = new(); @@ -48,23 +48,23 @@ public OsuCollections LoadCollection(string fileLocation) }; } - public void SaveCollection(OsuCollections collections, string fileLocation) + public void SaveCollection(OsuCollections collections, string filePath) { - string ext = Path.GetExtension(fileLocation); + string ext = Path.GetExtension(filePath); switch (ext.ToLower(System.Globalization.CultureInfo.CurrentCulture)) { case ".db": - SaveOsuCollection(collections, fileLocation); + SaveOsuCollection(collections, filePath); break; case ".osdb": - SaveOsdbCollection(collections, fileLocation); + SaveOsdbCollection(collections, filePath); break; case ".realm": - SaveOsuLazerCollection(collections, fileLocation); + SaveOsuLazerCollection(collections, filePath); break; default: - return; + throw new InvalidOperationException($"Provided file path did not contain valid file extension. filePath: `{filePath}`"); } } diff --git a/CollectionManager.Core/Modules/FileIO/FileCollections/OsdbCollectionHandler.cs b/CollectionManager.Core/Modules/FileIO/FileCollections/OsdbCollectionHandler.cs index 9605c6c..325769c 100644 --- a/CollectionManager.Core/Modules/FileIO/FileCollections/OsdbCollectionHandler.cs +++ b/CollectionManager.Core/Modules/FileIO/FileCollections/OsdbCollectionHandler.cs @@ -2,7 +2,6 @@ using CollectionManager.Core.Enums; using CollectionManager.Core.Exceptions; -using CollectionManager.Core.Interfaces; using CollectionManager.Core.Modules.FileIo.OsuDb; using CollectionManager.Core.Types; using SharpCompress.Archives; @@ -17,8 +16,6 @@ public class OsdbCollectionHandler { - private readonly ILogger _logger; - private static readonly Dictionary _versions = new() { {"o!dm", 1}, @@ -33,11 +30,6 @@ public class OsdbCollectionHandler {"o!dm8min", 1008}, }; - public OsdbCollectionHandler(ILogger logger) - { - _logger = logger; - } - public static string CurrentVersion(bool minimalWrite = false) => "o!dm8" + (minimalWrite ? "min" : ""); private static bool IsFullCollection(string versionString) @@ -269,5 +261,4 @@ private static BinaryReader StartReadingFirstFileInArchive(Stream baseStream) memStream.Position = 0; return new BinaryReader(memStream); } - } \ No newline at end of file diff --git a/CollectionManager.Core/Modules/FileIO/OsuBinaryReader.cs b/CollectionManager.Core/Modules/FileIO/OsuBinaryReader.cs index b270be2..eaa3b33 100644 --- a/CollectionManager.Core/Modules/FileIO/OsuBinaryReader.cs +++ b/CollectionManager.Core/Modules/FileIO/OsuBinaryReader.cs @@ -14,5 +14,99 @@ public OsuBinaryReader([NotNull] Stream input, [NotNull] Encoding encoding) : ba { } - public override string ReadString() => ReadByte() == 11 ? base.ReadString() : ""; + public override string ReadString() => ReadByte() == 11 ? base.ReadString() : null; + + public DateTime ReadDateTime() + { + long ticks = ReadInt64(); + return ticks < 0L || ticks > DateTime.MaxValue.Ticks || ticks < DateTime.MinValue.Ticks + ? DateTime.MinValue + : new DateTime(ticks, DateTimeKind.Utc); + } + + public object OsuConditionalRead() + { + switch (ReadByte()) + { + case 1: + { + return ReadBoolean(); + } + case 2: + { + return ReadByte(); + } + case 3: + { + return ReadUInt16(); + } + case 4: + { + return ReadUInt32(); + } + case 5: + { + return ReadUInt64(); + } + case 6: + { + return ReadSByte(); + } + case 7: + { + return ReadInt16(); + } + case 8: + { + return ReadInt32(); + } + case 9: + { + return ReadInt64(); + } + case 10: + { + return ReadChar(); + } + case 11: + { + return ReadString(); + } + case 12: + { + return ReadSingle(); + } + case 13: + { + return ReadDouble(); + } + case 14: + { + return ReadDecimal(); + } + case 15: + { + return ReadDateTime(); + } + case 16: + { + int num = ReadInt32(); + return num > 0 ? ReadBytes(num) : num < 0 ? null : (object)Array.Empty(); + + } + case 17: + { + int num = ReadInt32(); + return num > 0 ? ReadChars(num) : num < 0 ? null : (object)Array.Empty(); + } + case 18: + { + throw new NotImplementedException("Unused in db."); + } + default: + { + return null; + } + } + } } \ No newline at end of file diff --git a/CollectionManager.Core/Modules/FileIO/OsuBinaryWriter.cs b/CollectionManager.Core/Modules/FileIO/OsuBinaryWriter.cs index 8bdc1cb..097c730 100644 --- a/CollectionManager.Core/Modules/FileIO/OsuBinaryWriter.cs +++ b/CollectionManager.Core/Modules/FileIO/OsuBinaryWriter.cs @@ -15,7 +15,7 @@ public OsuBinaryWriter(Stream output, Encoding encoding) : base(output, encoding public override void Write(string value) { - if (string.IsNullOrEmpty(value)) + if (value is null) { Write((byte)0); } @@ -25,4 +25,6 @@ public override void Write(string value) base.Write(value); } } + + public void Write(DateTimeOffset? value) => Write(value?.DateTime.ToBinary() ?? 0L); } \ No newline at end of file diff --git a/CollectionManager.Core/Modules/FileIO/OsuDb/InvalidStableOsuDatabaseException.cs b/CollectionManager.Core/Modules/FileIO/OsuDb/InvalidStableOsuDatabaseException.cs new file mode 100644 index 0000000..821c2e9 --- /dev/null +++ b/CollectionManager.Core/Modules/FileIO/OsuDb/InvalidStableOsuDatabaseException.cs @@ -0,0 +1,9 @@ +namespace CollectionManager.Core.Modules.FileIo.OsuDb; +using System; + +public class InvalidStableOsuDatabaseException : Exception +{ + public InvalidStableOsuDatabaseException(string message) : base(message) + { + } +} diff --git a/CollectionManager.Core/Modules/FileIO/OsuDb/LOsuDatabaseLoader.cs b/CollectionManager.Core/Modules/FileIO/OsuDb/LOsuDatabaseLoader.cs deleted file mode 100644 index 0d385f9..0000000 --- a/CollectionManager.Core/Modules/FileIO/OsuDb/LOsuDatabaseLoader.cs +++ /dev/null @@ -1,48 +0,0 @@ -namespace CollectionManager.Core.Modules.FileIo.OsuDb; - -using CollectionManager.Core.Interfaces; -using CollectionManager.Core.Types; -using System; -using System.Threading; - -public class LOsuDatabaseLoader : OsuDatabaseLoader -{ - private readonly ILogger logger; - public string status { get; private set; } - public LOsuDatabaseLoader(IMapDataManager mapDataStorer, Beatmap tempBeatmap) : base(mapDataStorer, tempBeatmap) - { - } - - protected override bool FileExists(string fullPath) - { - bool result = base.FileExists(fullPath); - if (!result) - { - logger?.Log($"File \"{fullPath}\" doesn't exist!"); - } - - return result; - } - - public override void LoadDatabase(string fullOsuDbPath, IProgress progress, CancellationToken cancellationToken) - { - status = "Loading database"; - base.LoadDatabase(fullOsuDbPath, progress, cancellationToken); - status = $"Loaded {NumberOfLoadedBeatmaps} beatmaps"; - } - - protected override void ReadDatabaseEntries(IProgress progress, CancellationToken cancellationToken) - { - try - { - base.ReadDatabaseEntries(progress, cancellationToken); - } - catch (Exception e) - { - logger?.Log("Something went wrong while processing beatmaps(database is corrupt or its format changed)"); - logger?.Log("Try restarting your osu! first before reporting this problem."); - logger?.Log("Exception: {0},{1}", e.Message, e.StackTrace); - status = "Failed with exception " + $"Exception: {e.Message},{e.StackTrace}"; - } - } -} diff --git a/CollectionManager.Core/Modules/FileIO/OsuDb/MapCacher.cs b/CollectionManager.Core/Modules/FileIO/OsuDb/MapCacher.cs index bde0841..e1c360b 100644 --- a/CollectionManager.Core/Modules/FileIO/OsuDb/MapCacher.cs +++ b/CollectionManager.Core/Modules/FileIO/OsuDb/MapCacher.cs @@ -7,11 +7,14 @@ public class MapCacher : IMapDataManager { + public const int InvalidMapIdThreshold = 10; + private readonly Dictionary LoadedBeatmapsHashDict = []; private readonly Dictionary LoadedBeatmapsMapIdDict = []; - private bool _massStoring; private readonly HashSet MassStoringBeatmapHashes = []; + private bool _massStoring; + public object LockingObject { get; } = new(); public Beatmaps Beatmaps { get; } = []; public event EventHandler BeatmapsModified; @@ -68,19 +71,31 @@ public void EndMassStoring() OnBeatmapsModified(); } + public void StoreBeatmaps(Beatmaps beatmaps) + { + StartMassStoring(); + + foreach (Beatmap beatmap in beatmaps) + { + StoreBeatmap(beatmap); + } + + EndMassStoring(); + } + public void StoreBeatmap(Beatmap beatmap) { if (_massStoring) { if (MassStoringBeatmapHashes.Add(beatmap.Md5)) { - Beatmaps.Add((Beatmap)beatmap.Clone()); + Beatmaps.Add(beatmap); } return; } - Beatmaps.Add((Beatmap)beatmap.Clone()); + Beatmaps.Add(beatmap); UpdateLookupDicts(beatmap); OnBeatmapsModified(); } @@ -89,7 +104,19 @@ public void StoreBeatmap(Beatmap beatmap) private void OnBeatmapsModified() => BeatmapsModified?.Invoke(this, EventArgs.Empty); - public Beatmap GetByHash(string hash) => LoadedBeatmapsHashDict.TryGetValue(hash, out Beatmap value) ? value : null; + public Beatmap? GetByHash(string hash) => LoadedBeatmapsHashDict.TryGetValue(hash, out Beatmap value) ? value : null; + + public Beatmap? GetByMapId(int mapId) => LoadedBeatmapsMapIdDict.TryGetValue(mapId, out Beatmap value) ? value : null; + + public Beatmap? Get(string hash, int mapId) + { + Beatmap beatmap = GetByHash(hash); - public Beatmap GetByMapId(int mapId) => LoadedBeatmapsMapIdDict.TryGetValue(mapId, out Beatmap value) ? value : null; + if (beatmap is not null || mapId <= InvalidMapIdThreshold) + { + return beatmap; + } + + return GetByMapId(mapId); + } } diff --git a/CollectionManager.Core/Modules/FileIO/OsuDb/OsuDatabase.cs b/CollectionManager.Core/Modules/FileIO/OsuDb/OsuDatabase.cs index 2a79a4b..a639674 100644 --- a/CollectionManager.Core/Modules/FileIO/OsuDb/OsuDatabase.cs +++ b/CollectionManager.Core/Modules/FileIO/OsuDb/OsuDatabase.cs @@ -4,53 +4,54 @@ using CollectionManager.Core.Modules.FileIo.OsuLazerDb; using CollectionManager.Core.Types; using System; +using System.Globalization; using System.IO; using System.Threading; public class OsuDatabase { - public MapCacher LoadedMaps = new(); - private readonly OsuDatabaseLoader _osuDatabaseLoader; + public MapCacher LoadedMaps { get; } = new(); private readonly OsuLazerDatabase _lazerDatabaseLoader; private readonly IScoreDataManager _scoresDatabase; - public string OsuFileLocation { get; private set; } - public string SongsFolderLocation { get; set; } - public int NumberOfBeatmapsWithoutId { get; set; } - - public bool DatabaseIsLoaded => _osuDatabaseLoader.LoadedSuccessfully; - public string Status => ((LOsuDatabaseLoader)_osuDatabaseLoader).status; - public int NumberOfBeatmaps => LoadedMaps.Beatmaps.Count; - public string Username => _osuDatabaseLoader.Username; + public StableOsuDatabaseData StableOsuDatabaseData { get; private set; } public OsuDatabase(Beatmap beatmapBase, IScoreDataManager scoresDatabase) { - _osuDatabaseLoader = new LOsuDatabaseLoader(LoadedMaps, beatmapBase); _lazerDatabaseLoader = new OsuLazerDatabase(LoadedMaps, scoresDatabase); _scoresDatabase = scoresDatabase; } - public bool Load(string fileDir, IProgress progress, CancellationToken cancellationToken) + public bool Load(string filePath, IProgress progress, CancellationToken cancellationToken) { - string fileExtension = Path.GetExtension(fileDir)?.ToLower(); - OsuFileLocation = fileDir; + string fileExtension = Path.GetExtension(filePath)?.ToLower(CultureInfo.InvariantCulture); switch (fileExtension) { case ".db": - _osuDatabaseLoader.LoadDatabase(fileDir, progress, cancellationToken); + LoadStableBeatmaps(filePath, progress, cancellationToken); break; case ".realm": - _lazerDatabaseLoader.Load(fileDir, progress, cancellationToken); + _lazerDatabaseLoader.Load(filePath, progress, cancellationToken); break; default: - OsuFileLocation = null; - return false; + throw new InvalidOperationException($"Provided file path did not contain valid file extension. filePath: `{filePath}`"); } return true; } - public void Load(string fileDir, IProgress progress = null) => Load(fileDir, progress, CancellationToken.None); - + private void LoadStableBeatmaps(string fileDir, IProgress progress, CancellationToken cancellationToken) + { + try + { + StableOsuDatabaseData = StableOsuDatabaseReader.ReadDatabase(fileDir, cancellationToken, progress); + LoadedMaps.StoreBeatmaps(StableOsuDatabaseData.Beatmaps); + } + catch (Exception exception) + { + progress?.Report($"Something went wrong while processing beatmaps(database is corrupt or its format changed). {exception.Message}; {exception.StackTrace}"); + throw; + } + } } diff --git a/CollectionManager.Core/Modules/FileIO/OsuDb/OsuDatabaseReader.cs b/CollectionManager.Core/Modules/FileIO/OsuDb/OsuDatabaseReader.cs deleted file mode 100644 index f8549db..0000000 --- a/CollectionManager.Core/Modules/FileIO/OsuDb/OsuDatabaseReader.cs +++ /dev/null @@ -1,416 +0,0 @@ -namespace CollectionManager.Core.Modules.FileIo.OsuDb; - -using CollectionManager.Core.Enums; -using CollectionManager.Core.Interfaces; -using CollectionManager.Core.Types; -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Threading; - -//TODO: refactor to allow for read/write access. -public class OsuDatabaseLoader : IDisposable -{ - private readonly IMapDataManager _mapDataStorer; - private BinaryReader _binaryReader; - private readonly Beatmap _tempBeatmap; - - public bool LoadedSuccessfully { get; private set; } - - public int MapsWithNoId { get; private set; } - public string Username { get; private set; } - public int FileDate { get; private set; } - public int ExpectedNumberOfMapSets { get; private set; } - public int ExpectedNumOfBeatmaps { get; private set; } = -1; - public int NumberOfLoadedBeatmaps { get; private set; } - - public OsuDatabaseLoader(IMapDataManager mapDataStorer, Beatmap tempBeatmap) - { - _tempBeatmap = tempBeatmap; - _mapDataStorer = mapDataStorer; - } - - public virtual void LoadDatabase(string fullOsuDbPath, IProgress progress, CancellationToken cancellationToken) - { - if (FileExists(fullOsuDbPath)) - { - using FileStream fileStream = new(fullOsuDbPath, FileMode.Open, FileAccess.Read); - - try - { - _binaryReader = new BinaryReader(fileStream); - if (DatabaseContainsData(progress)) - { - ReadDatabaseEntries(progress, cancellationToken); - } - - } - finally - { - _binaryReader.Dispose(); - } - - } - - GC.Collect(); - } - - protected virtual void ReadDatabaseEntries(IProgress progress, CancellationToken cancellationToken) - { - progress?.Report($"Starting load of {ExpectedNumOfBeatmaps} beatmaps"); - - _mapDataStorer.StartMassStoring(); - for (NumberOfLoadedBeatmaps = 0; NumberOfLoadedBeatmaps < ExpectedNumOfBeatmaps; NumberOfLoadedBeatmaps++) - { - if (NumberOfLoadedBeatmaps % 100 == 0) - { - progress?.Report($"Loading {NumberOfLoadedBeatmaps} of {ExpectedNumOfBeatmaps}"); - } - - if (cancellationToken.IsCancellationRequested) - { - break; - } - - ReadNextBeatmap(); - } - - progress?.Report($"Loaded {NumberOfLoadedBeatmaps} beatmaps"); - _mapDataStorer.EndMassStoring(); - if (!cancellationToken.IsCancellationRequested) - { - LoadedSuccessfully = true; - } - } - private void ReadNextBeatmap() - { - _tempBeatmap.InitEmptyValues(); - - ReadMapHeader(_tempBeatmap); - ReadMapInfo(_tempBeatmap); - ReadTimingPoints(_tempBeatmap); - ReadMapMetaData(_tempBeatmap); - - _mapDataStorer.StoreBeatmap(_tempBeatmap); - } - - private void ReadMapMetaData(Beatmap beatmap) - { - beatmap.MapId = Math.Abs(_binaryReader.ReadInt32()); - if (beatmap.MapId == 0) - { - MapsWithNoId++; - } - - beatmap.MapSetId = Math.Abs(_binaryReader.ReadInt32()); - beatmap.ThreadId = Math.Abs(_binaryReader.ReadInt32()); - - beatmap.OsuGrade = (OsuGrade)_binaryReader.ReadByte(); - beatmap.TaikoGrade = (OsuGrade)_binaryReader.ReadByte(); - beatmap.CatchGrade = (OsuGrade)_binaryReader.ReadByte(); - beatmap.ManiaGrade = (OsuGrade)_binaryReader.ReadByte(); - - beatmap.Offset = _binaryReader.ReadInt16(); - beatmap.StackLeniency = _binaryReader.ReadSingle(); - beatmap.PlayMode = (PlayMode)_binaryReader.ReadByte(); - beatmap.Source = ReadString(); - beatmap.Tags = ReadString(); - beatmap.AudioOffset = _binaryReader.ReadInt16(); - beatmap.LetterBox = ReadString(); - beatmap.Played = !_binaryReader.ReadBoolean(); - beatmap.LastPlayed = GetDate(); - beatmap.IsOsz2 = _binaryReader.ReadBoolean(); - beatmap.Dir = ReadString(); - beatmap.LastSync = GetDate(); - beatmap.DisableHitsounds = _binaryReader.ReadBoolean(); - beatmap.DisableSkin = _binaryReader.ReadBoolean(); - beatmap.DisableSb = _binaryReader.ReadBoolean(); - _ = _binaryReader.ReadBoolean(); - _ = _binaryReader.ReadBoolean(); - if (FileDate < 20140609) - { - beatmap.BgDim = _binaryReader.ReadInt16(); - } - //bytes not analysed. - _ = _binaryReader.ReadInt32(); - _ = _binaryReader.ReadByte(); - } - - private class TimingPoint - { - public bool InheritsBpm; - public double Offset; - public double BpmDuration; - - public TimingPoint(double bpmDuration, double offset, bool inheritsBpm) - { - Offset = offset; - BpmDuration = bpmDuration; - InheritsBpm = inheritsBpm; - } - } - private void ReadTimingPoints(Beatmap beatmap) - { - int amountOfTimingPoints = _binaryReader.ReadInt32(); - - List timingPoints = new(amountOfTimingPoints); - for (int i = 0; i < amountOfTimingPoints; i++) - { - timingPoints.Add(new TimingPoint(_binaryReader.ReadDouble(), _binaryReader.ReadDouble(), _binaryReader.ReadBoolean())); - } - - double minBpm = double.MinValue, - maxBpm = double.MaxValue, - currentBpmLength = 0, - lastTime = beatmap.TotalTime; - Dictionary bpmTimes = []; - for (int i = timingPoints.Count - 1; i >= 0; i--) - { - TimingPoint tp = timingPoints[i]; - - if (tp.InheritsBpm) - { - currentBpmLength = tp.BpmDuration; - } - - if (currentBpmLength == 0 || tp.Offset > lastTime || (!tp.InheritsBpm && i > 0)) - { - continue; - } - - if (currentBpmLength > minBpm) - { - minBpm = currentBpmLength; - } - - if (currentBpmLength < maxBpm) - { - maxBpm = currentBpmLength; - } - - if (!bpmTimes.ContainsKey(currentBpmLength)) - { - bpmTimes[currentBpmLength] = 0; - } - - bpmTimes[currentBpmLength] += (int)(lastTime - (i == 0 ? 0 : tp.Offset)); - - lastTime = tp.Offset; - } - - beatmap.MaxBpm = Math.Round(60000 / maxBpm); - beatmap.MinBpm = Math.Round(60000 / minBpm); - - if (Math.Abs(beatmap.MaxBpm - beatmap.MinBpm) < double.Epsilon) - { - beatmap.MainBpm = beatmap.MaxBpm; - } - else if (bpmTimes.Count != 0) - { - beatmap.MainBpm = Math.Round(60000 / bpmTimes.Aggregate((i1, i2) => i1.Value > i2.Value ? i1 : i2).Key); - } - } - private void ReadMapInfo(Beatmap beatmap) - { - beatmap.State = _binaryReader.ReadByte(); - beatmap.Circles = _binaryReader.ReadInt16(); - beatmap.Sliders = _binaryReader.ReadInt16(); - beatmap.Spinners = _binaryReader.ReadInt16(); - beatmap.EditDate = GetDate(); - beatmap.ApproachRate = _binaryReader.ReadSingle(); - beatmap.CircleSize = _binaryReader.ReadSingle(); - beatmap.HpDrainRate = _binaryReader.ReadSingle(); - beatmap.OverallDifficulty = _binaryReader.ReadSingle(); - beatmap.SliderVelocity = _binaryReader.ReadDouble(); - - for (int j = 0; j < 4; j++) - { - ReadStarsData(beatmap, (PlayMode)j); - } - - beatmap.DrainingTime = _binaryReader.ReadInt32(); - beatmap.TotalTime = _binaryReader.ReadInt32(); - beatmap.PreviewTime = _binaryReader.ReadInt32(); - } - - private void ReadStarsData(Beatmap beatmap, PlayMode playMode) - { - int num = _binaryReader.ReadInt32(); - if (num <= 0) - { - return; - } - - if (!beatmap.ModPpStars.ContainsKey(playMode)) - { - beatmap.ModPpStars.Add(playMode, []); - } - - StarRating modPpStars = beatmap.ModPpStars[playMode]; - for (int j = 0; j < num; j++) - { - int modEnum = (int)ConditionalRead(); - float stars = (float)ConditionalRead(); - - if (!modPpStars.ContainsKey(modEnum)) - { - modPpStars.Add(modEnum, stars); - } - else - { - float star = stars; - if (modPpStars[modEnum] < star) - { - modPpStars[modEnum] = star; - } - } - } - } - private object ConditionalRead() - { - switch (_binaryReader.ReadByte()) - { - case 1: - { - return _binaryReader.ReadBoolean(); - } - case 2: - { - return _binaryReader.ReadByte(); - } - case 3: - { - return _binaryReader.ReadUInt16(); - } - case 4: - { - return _binaryReader.ReadUInt32(); - } - case 5: - { - return _binaryReader.ReadUInt64(); - } - case 6: - { - return _binaryReader.ReadSByte(); - } - case 7: - { - return _binaryReader.ReadInt16(); - } - case 8: - { - return _binaryReader.ReadInt32(); - } - case 9: - { - return _binaryReader.ReadInt64(); - } - case 10: - { - return _binaryReader.ReadChar(); - } - case 11: - { - return _binaryReader.ReadString(); - } - case 12: - { - return _binaryReader.ReadSingle(); - } - case 13: - { - return _binaryReader.ReadDouble(); - } - case 14: - { - return _binaryReader.ReadDecimal(); - } - case 15: - { - return GetDate(); - } - case 16: - { - int num = _binaryReader.ReadInt32(); - return num > 0 ? _binaryReader.ReadBytes(num) : num < 0 ? null : (object)Array.Empty(); - - } - case 17: - { - int num = _binaryReader.ReadInt32(); - return num > 0 ? _binaryReader.ReadChars(num) : num < 0 ? null : (object)Array.Empty(); - } - case 18: - { - throw new NotImplementedException("Unused in db."); - } - default: - { - return null; - } - } - } - private DateTime GetDate() - { - long ticks = _binaryReader.ReadInt64(); - return ticks < 0L || ticks > DateTime.MaxValue.Ticks || ticks < DateTime.MinValue.Ticks - ? DateTime.MinValue - : new DateTime(ticks, DateTimeKind.Utc); - } - private void ReadMapHeader(Beatmap beatmap) - { - beatmap.ArtistRoman = ReadString().Trim(); - beatmap.ArtistUnicode = ReadString().Trim(); - beatmap.TitleRoman = ReadString().Trim(); - beatmap.TitleUnicode = ReadString().Trim(); - beatmap.Creator = ReadString().Trim(); - beatmap.DiffName = ReadString().Trim(); - beatmap.Mp3Name = ReadString().Trim(); - beatmap.Md5 = ReadString().Trim(); - beatmap.OsuFileName = ReadString().Trim(); - - } - private string ReadString() => _binaryReader.ReadByte() == 11 ? _binaryReader.ReadString() : ""; - private bool DatabaseContainsData(IProgress progress) - { - FileDate = _binaryReader.ReadInt32(); - if (FileDate < 20191105) - { - progress?.Report($"Outdated osu!.db version ({FileDate}). Load failed."); - return false; - } - - ExpectedNumberOfMapSets = _binaryReader.ReadInt32(); - progress?.Report($"Expected number of mapSets: {ExpectedNumberOfMapSets}"); - try - { - bool something = _binaryReader.ReadBoolean(); - DateTime a = GetDate().ToLocalTime(); - _ = _binaryReader.BaseStream.Seek(1, SeekOrigin.Current); - Username = _binaryReader.ReadString(); - ExpectedNumOfBeatmaps = _binaryReader.ReadInt32(); - progress?.Report($"Expected number of beatmaps: {ExpectedNumOfBeatmaps}"); - - if (ExpectedNumOfBeatmaps < 0) - { - return false; - } - } - catch - { - return false; - } - - return true; - } - - protected virtual bool FileExists(string fullPath) => !string.IsNullOrEmpty(fullPath) && File.Exists(fullPath); - - public void Dispose() - { - _binaryReader?.Dispose(); - GC.SuppressFinalize(this); - } -} diff --git a/CollectionManager.Core/Modules/FileIO/OsuDb/StableOsuDatabaseData.cs b/CollectionManager.Core/Modules/FileIO/OsuDb/StableOsuDatabaseData.cs new file mode 100644 index 0000000..0264553 --- /dev/null +++ b/CollectionManager.Core/Modules/FileIO/OsuDb/StableOsuDatabaseData.cs @@ -0,0 +1,18 @@ +namespace CollectionManager.Core.Modules.FileIo.OsuDb; +using CollectionManager.Core.Types; +using System; + +public record StableOsuDatabaseData( + int FileDate, + int FolderCount, + bool AccountUnlocked, + DateTime UnlockDate, + string Username, + int NumberOfBeatmaps, + Beatmaps Beatmaps, + int Permissions) +{ + public bool IsValid => FileDate > 0 + && Beatmaps is not null + && Beatmaps.Count == NumberOfBeatmaps; +} diff --git a/CollectionManager.Core/Modules/FileIO/OsuDb/StableOsuDatabaseReader.cs b/CollectionManager.Core/Modules/FileIO/OsuDb/StableOsuDatabaseReader.cs new file mode 100644 index 0000000..9229379 --- /dev/null +++ b/CollectionManager.Core/Modules/FileIO/OsuDb/StableOsuDatabaseReader.cs @@ -0,0 +1,271 @@ +namespace CollectionManager.Core.Modules.FileIo.OsuDb; + +using CollectionManager.Core.Enums; +using CollectionManager.Core.Types; +using System; +using System.IO; +using System.Threading; + +public sealed class StableOsuDatabaseReader +{ + public const int LatestOsuDbVersion = 20191105; + + public static StableOsuDatabaseData ReadDatabase(string filePath, CancellationToken cancellationToken, IProgress progress = null) + { + if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath)) + { + throw new FileNotFoundException("The specified osu! database file does not exist.", filePath); + } + + using FileStream fileStream = new(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); + + return ReadDatabase(fileStream, cancellationToken, progress); + } + + public static StableOsuDatabaseData ReadDatabase(Stream inputStream, CancellationToken cancellationToken, IProgress progress = null) + { + using OsuBinaryReader binaryReader = new(inputStream); + + StableOsuDatabaseData stableDatabaseData = ReadDatabaseHeader(binaryReader); + + if (stableDatabaseData.FolderCount <= 0 || stableDatabaseData.NumberOfBeatmaps <= 0) + { + return stableDatabaseData; + } + + ReadAllBeatmaps(stableDatabaseData, binaryReader, cancellationToken, progress); + + int permissions = binaryReader.ReadInt32(); + + return stableDatabaseData with { Permissions = permissions }; + } + + private static void ReadAllBeatmaps(StableOsuDatabaseData databaseHeader, OsuBinaryReader binaryReader, CancellationToken cancellationToken, IProgress progress = null) + { + int numberOfLoadedBeatmaps = 0; + for (; numberOfLoadedBeatmaps < databaseHeader.NumberOfBeatmaps; numberOfLoadedBeatmaps++) + { + if (numberOfLoadedBeatmaps % 100 == 0) + { + cancellationToken.ThrowIfCancellationRequested(); + + progress?.Report($"Loading {numberOfLoadedBeatmaps} of {databaseHeader.NumberOfBeatmaps} beatmaps ({databaseHeader.FolderCount} beatmap sets)"); + + } + + databaseHeader.Beatmaps.Add(ReadNextBeatmap(binaryReader)); + } + + progress?.Report($"Loaded {numberOfLoadedBeatmaps} of {databaseHeader.NumberOfBeatmaps} beatmaps ({databaseHeader.FolderCount} beatmap sets)"); + + } + + private static BeatmapExtension ReadNextBeatmap(OsuBinaryReader binaryReader) + { +#pragma warning disable IDE0017 // Simplify object initialization + BeatmapExtension beatmap = new(); +#pragma warning restore IDE0017 // Simplify object initialization + + // Header + beatmap.ArtistRoman = binaryReader.ReadString(); + beatmap.ArtistUnicode = binaryReader.ReadString(); + beatmap.TitleRoman = binaryReader.ReadString(); + beatmap.TitleUnicode = binaryReader.ReadString(); + beatmap.Creator = binaryReader.ReadString(); + beatmap.DiffName = binaryReader.ReadString(); + beatmap.Mp3Name = binaryReader.ReadString(); + beatmap.Md5 = binaryReader.ReadString(); + beatmap.OsuFileName = binaryReader.ReadString(); + beatmap.State = binaryReader.ReadByte(); + beatmap.Circles = binaryReader.ReadInt16(); + beatmap.Sliders = binaryReader.ReadInt16(); + beatmap.Spinners = binaryReader.ReadInt16(); + beatmap.EditDate = binaryReader.ReadDateTime(); + beatmap.ApproachRate = binaryReader.ReadSingle(); + beatmap.CircleSize = binaryReader.ReadSingle(); + beatmap.HpDrainRate = binaryReader.ReadSingle(); + beatmap.OverallDifficulty = binaryReader.ReadSingle(); + beatmap.SliderVelocity = binaryReader.ReadDouble(); + + const int playModeCount = 4; + for (int playMode = 0; playMode < playModeCount; playMode++) + { + StarRating stars = ReadStars(binaryReader); + beatmap.ModPpStars[(PlayMode)playMode] = stars; + } + + beatmap.DrainingTime = binaryReader.ReadInt32(); + beatmap.TotalTime = binaryReader.ReadInt32(); + beatmap.PreviewTime = binaryReader.ReadInt32(); + + beatmap.TimingPoints = ReadTimingPoints(binaryReader, beatmap); + if (beatmap.TimingPoints is not null) + { + (beatmap.MinBpm, beatmap.MaxBpm, beatmap.MainBpm) = CalculateBpmFromTimingPoints(beatmap.TimingPoints, beatmap.TotalTime); + } + + beatmap.MapId = binaryReader.ReadInt32(); + beatmap.MapSetId = binaryReader.ReadInt32(); + beatmap.ThreadId = binaryReader.ReadInt32(); + beatmap.OsuGrade = (OsuGrade)binaryReader.ReadByte(); + beatmap.TaikoGrade = (OsuGrade)binaryReader.ReadByte(); + beatmap.CatchGrade = (OsuGrade)binaryReader.ReadByte(); + beatmap.ManiaGrade = (OsuGrade)binaryReader.ReadByte(); + beatmap.Offset = binaryReader.ReadInt16(); + beatmap.StackLeniency = binaryReader.ReadSingle(); + beatmap.PlayMode = (PlayMode)binaryReader.ReadByte(); + beatmap.Source = binaryReader.ReadString(); + beatmap.Tags = binaryReader.ReadString(); + beatmap.AudioOffset = binaryReader.ReadInt16(); + beatmap.LetterBox = binaryReader.ReadString(); + beatmap.Played = !binaryReader.ReadBoolean(); + beatmap.LastPlayed = binaryReader.ReadDateTime(); + beatmap.IsOsz2 = binaryReader.ReadBoolean(); + beatmap.Dir = binaryReader.ReadString(); + beatmap.LastSync = binaryReader.ReadDateTime(); + beatmap.DisableHitsounds = binaryReader.ReadBoolean(); + beatmap.DisableSkin = binaryReader.ReadBoolean(); + beatmap.DisableSb = binaryReader.ReadBoolean(); + beatmap.DisableVideo = binaryReader.ReadBoolean(); + beatmap.VisualOverride = binaryReader.ReadBoolean(); + beatmap.LastModification = binaryReader.ReadInt32(); + beatmap.ManiaScrollSpeed = binaryReader.ReadByte(); + + return beatmap; + } + + private static TimingPoint[] ReadTimingPoints(OsuBinaryReader binaryReader, Beatmap beatmap) + { + int amountOfTimingPoints = binaryReader.ReadInt32(); + + if (amountOfTimingPoints < 0) + { + return null; + } + + TimingPoint[] timingPoints = new TimingPoint[amountOfTimingPoints]; + + for (int i = 0; i < amountOfTimingPoints; i++) + { + timingPoints[i] = new TimingPoint(binaryReader.ReadDouble(), binaryReader.ReadDouble(), binaryReader.ReadBoolean()); + } + + return timingPoints; + } + + private static StarRating ReadStars(OsuBinaryReader binaryReader) + { + int combinationsCount = binaryReader.ReadInt32(); + + if (combinationsCount <= 0) + { + return []; + } + + StarRating starRating = []; + + for (int j = 0; j < combinationsCount; j++) + { + int modEnum = (int)binaryReader.OsuConditionalRead(); + float stars = (float)binaryReader.OsuConditionalRead(); + + if (!starRating.ContainsKey(modEnum)) + { + starRating.Add(modEnum, stars); + } + else + { + if (starRating[modEnum] < stars) + { + starRating[modEnum] = stars; + } + } + } + + return starRating; + } + + private static StableOsuDatabaseData ReadDatabaseHeader(OsuBinaryReader binaryReader) + { + int fileDate = binaryReader.ReadInt32(); + + if (fileDate < LatestOsuDbVersion) + { + throw new InvalidStableOsuDatabaseException("osu! stable database is outdated. Please update your osu! client to version after 20191105."); + } + + int folderCount = binaryReader.ReadInt32(); + bool accountUnlocked = binaryReader.ReadBoolean(); + DateTime unlockDate = DateTime.FromBinary(binaryReader.ReadInt64()); + string username = binaryReader.ReadString(); + int numberOfBeatmaps = binaryReader.ReadInt32(); + + return new StableOsuDatabaseData( + fileDate, + folderCount, + accountUnlocked, + unlockDate, + username, + numberOfBeatmaps, + [], + -1); + } + + public static (double Min, double Max, double Main) CalculateBpmFromTimingPoints(TimingPoint[] timingPoints, int totalBeatmapTime) + { + double minBpm = double.MinValue, + maxBpm = double.MaxValue, + currentBpmLength = 0, + lastTime = totalBeatmapTime; + Dictionary bpmTimes = []; + + for (int i = timingPoints.Length - 1; i >= 0; i--) + { + TimingPoint tp = timingPoints[i]; + + if (tp.InheritsBpm) + { + currentBpmLength = tp.BpmDuration; + } + + if (currentBpmLength == 0 || tp.Offset > lastTime || (!tp.InheritsBpm && i > 0)) + { + continue; + } + + if (currentBpmLength > minBpm) + { + minBpm = currentBpmLength; + } + + if (currentBpmLength < maxBpm) + { + maxBpm = currentBpmLength; + } + + if (!bpmTimes.ContainsKey(currentBpmLength)) + { + bpmTimes[currentBpmLength] = 0; + } + + bpmTimes[currentBpmLength] += (int)(lastTime - (i == 0 ? 0 : tp.Offset)); + + lastTime = tp.Offset; + } + + maxBpm = Math.Round(60000 / maxBpm); + minBpm = Math.Round(60000 / minBpm); + double mainBpm = 0; + + if (Math.Abs(maxBpm - minBpm) < double.Epsilon) + { + mainBpm = maxBpm; + } + else if (bpmTimes.Count != 0) + { + mainBpm = Math.Round(60000 / bpmTimes.Aggregate((i1, i2) => i1.Value > i2.Value ? i1 : i2).Key); + } + + return (minBpm, maxBpm, mainBpm); + } +} diff --git a/CollectionManager.Core/Modules/FileIO/OsuDb/StableOsuDatabaseWriter.cs b/CollectionManager.Core/Modules/FileIO/OsuDb/StableOsuDatabaseWriter.cs new file mode 100644 index 0000000..8dbb254 --- /dev/null +++ b/CollectionManager.Core/Modules/FileIO/OsuDb/StableOsuDatabaseWriter.cs @@ -0,0 +1,177 @@ +namespace CollectionManager.Core.Modules.FileIo.OsuDb; + +using CollectionManager.Core.Enums; +using CollectionManager.Core.Types; +using System; +using System.IO; + +public sealed class StableOsuDatabaseWriter +{ + private static readonly int[] _osuModsDbOrder = [0, 64, 256, 2, 66, 258, 16, 80, 272]; + + public static void WriteDatabase(StableOsuDatabaseData stableOsuDatabase, string filePath) + { + if (stableOsuDatabase is null) + { + throw new ArgumentNullException(nameof(stableOsuDatabase), "stableOsuDatabase cannot be null."); + } + + if (!stableOsuDatabase.IsValid) + { + throw new InvalidStableOsuDatabaseException("Provided metadata would create unreadable database."); + } + + using FileStream fileStream = new(filePath, FileMode.Create, FileAccess.Write); + WriteDatabase(stableOsuDatabase, fileStream); + } + + public static void WriteDatabase(StableOsuDatabaseData stableOsuDatabase, Stream outputStream) + { + if (stableOsuDatabase is null) + { + throw new ArgumentNullException(nameof(stableOsuDatabase), "stableOsuDatabase cannot be null."); + } + + if (!stableOsuDatabase.IsValid) + { + throw new InvalidStableOsuDatabaseException("Provided metadata would create unreadable database."); + } + + using OsuBinaryWriter binaryWriter = new(outputStream); + + WriteDatabaseHeader(binaryWriter, stableOsuDatabase); + + foreach (Beatmap beatmap in stableOsuDatabase.Beatmaps) + { + WriteBeatmap(beatmap, binaryWriter); + } + + binaryWriter.Write(stableOsuDatabase.Permissions); + } + + private static void WriteDatabaseHeader(OsuBinaryWriter binaryWriter, StableOsuDatabaseData stableOsuDatabase) + { + binaryWriter.Write(stableOsuDatabase.FileDate); + binaryWriter.Write(stableOsuDatabase.FolderCount); + binaryWriter.Write(stableOsuDatabase.AccountUnlocked); + binaryWriter.Write(stableOsuDatabase.UnlockDate.ToBinary()); + binaryWriter.Write(stableOsuDatabase.Username); + binaryWriter.Write(stableOsuDatabase.NumberOfBeatmaps); + } + + private static void WriteBeatmap(Beatmap beatmap, OsuBinaryWriter binaryWriter) + { + binaryWriter.Write(beatmap.ArtistRoman); + binaryWriter.Write(beatmap.ArtistUnicode); + binaryWriter.Write(beatmap.TitleRoman); + binaryWriter.Write(beatmap.TitleUnicode); + binaryWriter.Write(beatmap.Creator); + binaryWriter.Write(beatmap.DiffName); + binaryWriter.Write(beatmap.Mp3Name); + binaryWriter.Write(beatmap.Md5); + binaryWriter.Write(beatmap.OsuFileName); + binaryWriter.Write(beatmap.State); + binaryWriter.Write(beatmap.Circles); + binaryWriter.Write(beatmap.Sliders); + binaryWriter.Write(beatmap.Spinners); + binaryWriter.Write(beatmap.EditDate); + binaryWriter.Write(beatmap.ApproachRate); + binaryWriter.Write(beatmap.CircleSize); + binaryWriter.Write(beatmap.HpDrainRate); + binaryWriter.Write(beatmap.OverallDifficulty); + binaryWriter.Write(beatmap.SliderVelocity); + + const int playModeCount = 4; + for (int playMode = 0; playMode < playModeCount; playMode++) + { + WriteStars(beatmap.ModPpStars[(PlayMode)playMode], binaryWriter); + } + + binaryWriter.Write(beatmap.DrainingTime); + binaryWriter.Write(beatmap.TotalTime); + binaryWriter.Write(beatmap.PreviewTime); + + WriteTimingPoints(beatmap.TimingPoints, binaryWriter); + + binaryWriter.Write(beatmap.MapId); + binaryWriter.Write(beatmap.MapSetId); + binaryWriter.Write(beatmap.ThreadId); + binaryWriter.Write((byte)beatmap.OsuGrade); + binaryWriter.Write((byte)beatmap.TaikoGrade); + binaryWriter.Write((byte)beatmap.CatchGrade); + binaryWriter.Write((byte)beatmap.ManiaGrade); + binaryWriter.Write((short)beatmap.Offset); + binaryWriter.Write(beatmap.StackLeniency); + binaryWriter.Write((byte)beatmap.PlayMode); + binaryWriter.Write(beatmap.Source); + binaryWriter.Write(beatmap.Tags); + binaryWriter.Write(beatmap.AudioOffset); + binaryWriter.Write(beatmap.LetterBox); + binaryWriter.Write(!beatmap.Played); + binaryWriter.Write(beatmap.LastPlayed); + binaryWriter.Write(beatmap.IsOsz2); + binaryWriter.Write(beatmap.Dir); + binaryWriter.Write(beatmap.LastSync); + binaryWriter.Write(beatmap.DisableHitsounds); + binaryWriter.Write(beatmap.DisableSkin); + binaryWriter.Write(beatmap.DisableSb); + binaryWriter.Write(beatmap.DisableVideo); + binaryWriter.Write(beatmap.VisualOverride); + binaryWriter.Write(beatmap.LastModification); + binaryWriter.Write(beatmap.ManiaScrollSpeed); + } + + private static void WriteStars(StarRating starRating, OsuBinaryWriter binaryWriter) + { + if (starRating is null || !starRating.Any()) + { + binaryWriter.Write(0); + return; + } + + binaryWriter.Write(starRating.Count()); + + foreach (int modNumber in _osuModsDbOrder) + { + if (starRating.ContainsKey(modNumber)) + { + WriteStarRating(binaryWriter, modNumber, starRating[modNumber]); + } + } + + // & leftovers in case we have made some manual changes + foreach (KeyValuePair kvp in starRating) + { + if (!_osuModsDbOrder.Contains(kvp.Key)) + { + WriteStarRating(binaryWriter, kvp.Key, kvp.Value); + } + } + + static void WriteStarRating(OsuBinaryWriter binaryWriter, int modNumber, float stars) + { + binaryWriter.Write((byte)8); + binaryWriter.Write(modNumber); + binaryWriter.Write((byte)12); + binaryWriter.Write(stars); + } + } + + private static void WriteTimingPoints(TimingPoint[] timingPoints, OsuBinaryWriter binaryWriter) + { + if (timingPoints is null) + { + binaryWriter.Write(-1); + return; + } + + binaryWriter.Write(timingPoints.Length); + + foreach (TimingPoint tp in timingPoints) + { + binaryWriter.Write(tp.BpmDuration); + binaryWriter.Write(tp.Offset); + binaryWriter.Write(tp.InheritsBpm); + } + } +} \ No newline at end of file diff --git a/CollectionManager.Core/Modules/FileIO/OsuDb/TimingPoint.cs b/CollectionManager.Core/Modules/FileIO/OsuDb/TimingPoint.cs new file mode 100644 index 0000000..69332f2 --- /dev/null +++ b/CollectionManager.Core/Modules/FileIO/OsuDb/TimingPoint.cs @@ -0,0 +1,3 @@ +namespace CollectionManager.Core.Modules.FileIo.OsuDb; + +public record TimingPoint(double BpmDuration, double Offset, bool InheritsBpm); diff --git a/CollectionManager.Core/Modules/FileIO/OsuPathResolver.cs b/CollectionManager.Core/Modules/FileIO/OsuPathResolver.cs index 0086309..cc0d11d 100644 --- a/CollectionManager.Core/Modules/FileIO/OsuPathResolver.cs +++ b/CollectionManager.Core/Modules/FileIO/OsuPathResolver.cs @@ -1,4 +1,4 @@ -namespace CollectionManager.Core.Modules.FileIo; +namespace CollectionManager.Core.Modules.FileIo; using CollectionManager.Core.Types; using Microsoft.Win32; @@ -9,43 +9,43 @@ public sealed class OsuPathResolver { public static async Task GetOsuPathAsync(Func> thisPathIsCorrect, Func> selectDirectoryDialog) { - string path = GetOsuOrLazerPath(); + OsuPathResult result = GetOsuOrLazerPath(); - if (string.IsNullOrWhiteSpace(path)) + if (string.IsNullOrWhiteSpace(result.Path)) { return await GetManualOsuPathAsync(selectDirectoryDialog); } if (thisPathIsCorrect is null) { - return path; + return result.Path; } - bool result = await thisPathIsCorrect(path); + bool isCorrect = await thisPathIsCorrect(result.Path); - return result - ? path + return isCorrect + ? result.Path : await GetManualOsuPathAsync(selectDirectoryDialog); } - public static string GetOsuOrLazerPath() + public static OsuPathResult GetOsuOrLazerPath() { if (TryGetRunningOsuPath(out string path)) { - return path; + return new OsuPathResult(path, OsuType.Stable); } if (TryGetLazerDataPath(out path)) { - return path; + return new OsuPathResult(path, OsuType.Lazer); } - if (TryGetOsuPathFromRegistry(out path)) + if (TryGetOsuPathFromRegistry(out path, out OsuType foundType, OsuType.Any)) { - return path; + return new OsuPathResult(path, foundType); } - return string.Empty; + return new OsuPathResult(string.Empty, OsuType.None); } public static async Task GetManualOsuPathAsync(Func> selectDirectoryDialog) @@ -65,7 +65,7 @@ public static bool TryGetStablePath(out string path) return true; } - if (TryGetOsuPathFromRegistry(out path, OsuType.Stable) && IsOsuStableDirectory(path)) + if (TryGetOsuPathFromRegistry(out path, out _, OsuType.Stable) && IsOsuStableDirectory(path)) { return true; } @@ -136,8 +136,9 @@ public static bool TryGetRunningOsuPath(out string path) /// Attempts to retrieve osu! stable or lazer path from windows registry. /// /// - private static bool TryGetOsuPathFromRegistry(out string path, OsuType osuType = OsuType.Any) + private static bool TryGetOsuPathFromRegistry(out string path, out OsuType foundType, OsuType osuType = OsuType.Any) { + foundType = OsuType.None; if (!OperatingSystem.IsWindows()) { path = null; @@ -149,15 +150,15 @@ private static bool TryGetOsuPathFromRegistry(out string path, OsuType osuType = const string lazerKey = "osu.File.osz\\Shell\\Open\\Command"; const string stableKey = "osustable.File.osz\\Shell\\Open\\Command"; - string[] keys = osuType switch + (string key, OsuType type)[] keys = osuType switch { - OsuType.Any => [lazerKey, stableKey], - OsuType.Stable => [stableKey], - OsuType.Lazer => [lazerKey], + OsuType.Any => [(lazerKey, OsuType.Lazer), (stableKey, OsuType.Stable)], + OsuType.Stable => [(stableKey, OsuType.Stable)], + OsuType.Lazer => [(lazerKey, OsuType.Lazer)], OsuType unknown => throw new InvalidOperationException($"OsuType {unknown} is not valid.") }; - foreach (string key in keys) + foreach ((string key, OsuType type) in keys) { using RegistryKey osuRegistryKey = Registry.ClassesRoot.OpenSubKey(key); @@ -172,6 +173,7 @@ private static bool TryGetOsuPathFromRegistry(out string path, OsuType osuType = path = Path.GetDirectoryName(exePath); if (IsOsuUserDataDirectory(path)) { + foundType = type; return true; } } diff --git a/CollectionManager.Core/Modules/FileIO/OsuPathResult.cs b/CollectionManager.Core/Modules/FileIO/OsuPathResult.cs new file mode 100644 index 0000000..382eaed --- /dev/null +++ b/CollectionManager.Core/Modules/FileIO/OsuPathResult.cs @@ -0,0 +1,5 @@ +namespace CollectionManager.Core.Modules.FileIo; + +using CollectionManager.Core.Types; + +public sealed record OsuPathResult(string Path, OsuType Type); diff --git a/CollectionManager.Core/Types/Beatmap.cs b/CollectionManager.Core/Types/Beatmap.cs index fb3a3ad..cb80e99 100644 --- a/CollectionManager.Core/Types/Beatmap.cs +++ b/CollectionManager.Core/Types/Beatmap.cs @@ -1,19 +1,13 @@ namespace CollectionManager.Core.Types; using CollectionManager.Core.Enums; +using CollectionManager.Core.Modules.FileIo.OsuDb; using System; +using System.Globalization; public abstract class Beatmap : ICloneable { - private string _titleUnicode; - public string TitleUnicode - { - get => _titleUnicode == string.Empty ? TitleRoman : _titleUnicode; set => _titleUnicode = value; - } - private string _artistUnicode; - public string ArtistUnicode - { - get => _artistUnicode == string.Empty ? ArtistRoman : _artistUnicode; set => _artistUnicode = value; - } + public string TitleUnicode { get; set; } + public string ArtistUnicode { get; set; } public string TitleRoman { get; set; } public string ArtistRoman { get; set; } public string Artist => !string.IsNullOrEmpty(ArtistRoman) ? ArtistRoman : !string.IsNullOrEmpty(ArtistUnicode) ? ArtistUnicode : ""; @@ -25,10 +19,9 @@ public string ArtistUnicode public string Md5 { get; set; } public abstract string Hash { get; set; } public string OsuFileName { get; set; } - public string MapLink => MapId == 0 ? MapSetLink : @"https://osu.ppy.sh/b/" + MapId; - public string MapSetLink => MapSetId == 0 ? string.Empty : @"https://osu.ppy.sh/s/" + MapSetId; - //TODO: add helper functions for adding/removing star values - public PlayModeStars ModPpStars = []; + public string MapLink => MapId <= 0 ? MapSetLink : @"https://osu.ppy.sh/b/" + MapId; + public string MapSetLink => MapSetId <= 0 ? string.Empty : @"https://osu.ppy.sh/s/" + MapSetId; + public PlayModeStars ModPpStars { get; private set; } = []; public double StarsNomod => Stars(PlayMode); public float Stars(PlayMode playMode, Mods mods = Mods.Nm) @@ -59,7 +52,7 @@ public byte State 4 => "Ranked", 5 => "Approved", 7 => "Loved", - _ => "??" + value.ToString(), + _ => "??" + value.ToString(CultureInfo.InvariantCulture), }; StateStr = val; } @@ -72,7 +65,7 @@ public byte State public float CircleSize { get; set; } public float HpDrainRate { get; set; } public float OverallDifficulty { get; set; } - public double? SliderVelocity { get; set; } + public double SliderVelocity { get; set; } public int DrainingTime { get; set; } public int TotalTime { get; set; } public int PreviewTime { get; set; } @@ -85,7 +78,7 @@ public byte State public OsuGrade ManiaGrade { get; set; } = OsuGrade.Null; public double Offset { get; set; } - public float? StackLeniency { get; set; } + public float StackLeniency { get; set; } private PlayMode _playMode; public PlayMode PlayMode { @@ -109,7 +102,11 @@ public PlayMode PlayMode public bool DisableSkin { get; set; } public bool DisableSb { get; set; } public short BgDim { get; set; } - public int Somestuff { get; set; } + public TimingPoint[] TimingPoints { get; set; } = []; + public bool DisableVideo { get; set; } + public bool VisualOverride { get; set; } + public int LastModification { get; set; } + public byte ManiaScrollSpeed { get; set; } public Beatmap() { @@ -118,6 +115,7 @@ public Beatmap() public void CloneValues(Beatmap b) { + TitleUnicode = b.TitleUnicode; TitleRoman = b.TitleRoman; ArtistUnicode = b.ArtistUnicode; @@ -128,7 +126,6 @@ public void CloneValues(Beatmap b) Md5 = b.Md5; OsuFileName = b.OsuFileName; Tags = b.Tags; - Somestuff = b.Somestuff; State = b.State; Circles = b.Circles; Sliders = b.Sliders; @@ -168,6 +165,11 @@ public void CloneValues(Beatmap b) MaxBpm = b.MaxBpm; MinBpm = b.MinBpm; MainBpm = b.MainBpm; + TimingPoints = b.TimingPoints; + DisableVideo = b.DisableVideo; + VisualOverride = b.VisualOverride; + LastModification = b.LastModification; + ManiaScrollSpeed = b.ManiaScrollSpeed; } public Beatmap(Beatmap b) { @@ -181,7 +183,6 @@ public Beatmap(Beatmap b) Md5 = b.Md5; OsuFileName = b.OsuFileName; Tags = b.Tags; - Somestuff = b.Somestuff; State = b.State; Circles = b.Circles; Sliders = b.Sliders; @@ -221,6 +222,11 @@ public Beatmap(Beatmap b) MaxBpm = b.MaxBpm; MinBpm = b.MinBpm; MainBpm = b.MainBpm; + TimingPoints = b.TimingPoints; + DisableVideo = b.DisableVideo; + VisualOverride = b.VisualOverride; + LastModification = b.LastModification; + ManiaScrollSpeed = b.ManiaScrollSpeed; } public Beatmap(string artist) { @@ -245,7 +251,6 @@ public void InitEmptyValues() Md5 = string.Empty; OsuFileName = string.Empty; Tags = string.Empty; - Somestuff = 0; State = 0; Circles = 0; Sliders = 0; @@ -285,10 +290,148 @@ public void InitEmptyValues() MinBpm = 0.0f; MaxBpm = 0.0f; MainBpm = 0.0f; + TimingPoints = []; + DisableVideo = false; + VisualOverride = false; + LastModification = 0; + ManiaScrollSpeed = 0; } public object Clone() => MemberwiseClone(); + public override int GetHashCode() + { + HashCode hash = new(); + hash.Add(TitleUnicode); + hash.Add(TitleRoman); + hash.Add(ArtistUnicode); + hash.Add(ArtistRoman); + hash.Add(Creator); + hash.Add(DiffName); + hash.Add(Mp3Name); + hash.Add(Md5); + hash.Add(OsuFileName); + hash.Add(Tags); + hash.Add(_state); + hash.Add(Circles); + hash.Add(Sliders); + hash.Add(Spinners); + hash.Add(EditDate); + hash.Add(ApproachRate); + hash.Add(CircleSize); + hash.Add(HpDrainRate); + hash.Add(OverallDifficulty); + hash.Add(SliderVelocity); + hash.Add(DrainingTime); + hash.Add(TotalTime); + hash.Add(PreviewTime); + hash.Add(MapId); + hash.Add(MapSetId); + hash.Add(ThreadId); + hash.Add(OsuGrade); + hash.Add(TaikoGrade); + hash.Add(CatchGrade); + hash.Add(ManiaGrade); + hash.Add(Offset); + hash.Add(StackLeniency); + hash.Add(PlayMode); + hash.Add(Source); + hash.Add(AudioOffset); + hash.Add(LetterBox); + hash.Add(Played); + hash.Add(LastPlayed); + hash.Add(IsOsz2); + hash.Add(Dir); + hash.Add(DisableHitsounds); + hash.Add(DisableSkin); + hash.Add(DisableSb); + hash.Add(BgDim); + hash.Add(ModPpStars); + hash.Add(MaxBpm); + hash.Add(MinBpm); + hash.Add(MainBpm); + hash.Add(TimingPoints); + hash.Add(DisableVideo); + hash.Add(VisualOverride); + hash.Add(LastModification); + hash.Add(ManiaScrollSpeed); + + return hash.ToHashCode(); + } + + public override bool Equals(object obj) + { + if (obj is null) + { + return false; + } + + if (ReferenceEquals(this, obj)) + { + return true; + } + + if (obj is not Beatmap b) + { + return false; + } + + return TitleUnicode == b.TitleUnicode + && TitleRoman == b.TitleRoman + && ArtistUnicode == b.ArtistUnicode + && ArtistRoman == b.ArtistRoman + && Creator == b.Creator + && DiffName == b.DiffName + && Mp3Name == b.Mp3Name + && Md5 == b.Md5 + && OsuFileName == b.OsuFileName + && Tags == b.Tags + && State == b.State + && Circles == b.Circles + && Sliders == b.Sliders + && Spinners == b.Spinners + && EditDate == b.EditDate + && ApproachRate == b.ApproachRate + && CircleSize == b.CircleSize + && HpDrainRate == b.HpDrainRate + && OverallDifficulty == b.OverallDifficulty + && SliderVelocity == b.SliderVelocity + && DrainingTime == b.DrainingTime + && TotalTime == b.TotalTime + && PreviewTime == b.PreviewTime + && MapId == b.MapId + && MapSetId == b.MapSetId + && ThreadId == b.ThreadId + && OsuGrade == b.OsuGrade + && TaikoGrade == b.TaikoGrade + && CatchGrade == b.CatchGrade + && ManiaGrade == b.ManiaGrade + && Offset == b.Offset + && StackLeniency == b.StackLeniency + && PlayMode == b.PlayMode + && Source == b.Source + && AudioOffset == b.AudioOffset + && LetterBox == b.LetterBox + && Played == b.Played + && LastPlayed == b.LastPlayed + && IsOsz2 == b.IsOsz2 + && Dir == b.Dir + && LastSync == b.LastSync + && DisableHitsounds == b.DisableHitsounds + && DisableSkin == b.DisableSkin + && DisableSb == b.DisableSb + && BgDim == b.BgDim + && ModPpStars == b.ModPpStars + && MaxBpm == b.MaxBpm + && MinBpm == b.MinBpm + && MainBpm == b.MainBpm + && TimingPoints == b.TimingPoints + && DisableVideo == b.DisableVideo + && VisualOverride == b.VisualOverride + && LastModification == b.LastModification + && ManiaScrollSpeed == b.ManiaScrollSpeed; + } + public override string ToString() { if (string.IsNullOrEmpty(Artist) && string.IsNullOrEmpty(Title)) diff --git a/CollectionManager.Core/Types/IOsuCollection.cs b/CollectionManager.Core/Types/IOsuCollection.cs index 613e148..df8a1fa 100644 --- a/CollectionManager.Core/Types/IOsuCollection.cs +++ b/CollectionManager.Core/Types/IOsuCollection.cs @@ -66,5 +66,6 @@ public interface IOsuCollection void ReplaceBeatmap(string hash, Beatmap newBeatmap); void ReplaceBeatmap(int mapId, Beatmap newBeatmap); bool RemoveBeatmap(string hash); + int RemoveBeatmaps(IEnumerable hashes); IEnumerator GetEnumerator(); } \ No newline at end of file diff --git a/CollectionManager.Core/Types/OsuCollection.cs b/CollectionManager.Core/Types/OsuCollection.cs index 6a17d64..ef9caf9 100644 --- a/CollectionManager.Core/Types/OsuCollection.cs +++ b/CollectionManager.Core/Types/OsuCollection.cs @@ -12,29 +12,109 @@ public class OsuCollection : IEnumerable, IOsuCollection private MapCacher LoadedMaps; /// - /// Contains all beatmap hashes contained in this collection + /// Contains all beatmaps in this collection indexed by their hash /// - private readonly HashSet _beatmapHashes = []; + private readonly Dictionary _beatmaps = []; + private bool _beatmapCachesValid; /// - /// + /// Contains all beatmap hashes contained in this collection /// - public IReadOnlyCollection BeatmapHashes => _beatmapHashes; + public IReadOnlyCollection BeatmapHashes => _beatmaps.Keys; + /// /// Contains beatmaps that did not find a match in LoadedMaps /// nor had additional data(MapSetId) /// - public Beatmaps UnknownBeatmaps { get; } = []; + public Beatmaps UnknownBeatmaps + { + get + { + EnsureCacheValid(); + return field; + } + + private set; + } + /// /// Contains beatmaps that did not find a match in LoadedMaps /// but contain enough information(MapSetId) to be able to issue new download /// /// .osdb files contain this data since v2 - public Beatmaps DownloadableBeatmaps { get; } = []; + public Beatmaps DownloadableBeatmaps + { + get + { + EnsureCacheValid(); + return field; + } + + private set; + } + /// /// Contains beatmap with data from LoadedMaps /// - public Beatmaps KnownBeatmaps { get; } = []; + public Beatmaps KnownBeatmaps + { + get + { + EnsureCacheValid(); + return field; + } + + private set; + } + + private void EnsureCacheValid() + { + if (_beatmapCachesValid) + { + return; + } + + Beatmaps known = []; + Beatmaps downloadable = []; + Beatmaps unknown = []; + + using RangeObservableCollection.SuspendContext __ = known.SuspendCollectionChangedEvents(); + using RangeObservableCollection.SuspendContext ___ = downloadable.SuspendCollectionChangedEvents(); + using RangeObservableCollection.SuspendContext ____ = unknown.SuspendCollectionChangedEvents(); + + foreach (BeatmapExtension beatmap in _beatmaps.Values) + { + if (!beatmap.LocalBeatmapMissing) + { + known.Add(beatmap); + } + else if (beatmap.MapSetId is not 0) + { + downloadable.Add(beatmap); + } + else + { + unknown.Add(beatmap); + } + } + + KnownBeatmaps = known; + DownloadableBeatmaps = downloadable; + UnknownBeatmaps = unknown; + + _beatmapCachesValid = true; + } + + /// + /// Invalidates all cached views + /// + private void InvalidateCache() + { + _beatmapCachesValid = false; + KnownBeatmaps = null; + DownloadableBeatmaps = null; + UnknownBeatmaps = null; + } public override string ToString() => $"osu! map Collection: \"{Name}\" Count: {BeatmapHashes.Count}"; @@ -43,11 +123,12 @@ public class OsuCollection : IEnumerable, IOsuCollection /// public virtual int NumberOfBeatmaps { - get => UnknownBeatmaps.Count + KnownBeatmaps.Count + DownloadableBeatmaps.Count; - set { } + get => _beatmaps.Count; + set => throw new InvalidOperationException("Number of beatmaps can not be set in base collections."); } - public virtual int NumberOfMissingBeatmaps => UnknownBeatmaps.Count + DownloadableBeatmaps.Count; + public virtual int NumberOfMissingBeatmaps => _beatmaps.Values.Count(b => b.LocalBeatmapMissing); + /// /// Username of last person editing this collection /// @@ -59,14 +140,19 @@ public virtual int NumberOfBeatmaps public Guid LazerId { get; set; } + public OsuCollection(MapCacher instance) + { + SetLoadedMaps(instance); + } + public void SetLoadedMaps(MapCacher instance) { - if (instance == null) + if (instance is null) { throw new BeatmapCacherNotInitalizedException(); } - if (LoadedMaps != null) + if (LoadedMaps is not null) { LoadedMaps.BeatmapsModified -= LoadedMaps_BeatmapsModified; } @@ -80,53 +166,30 @@ public void SetLoadedMaps(MapCacher instance) private void ReprocessBeatmaps() { - if (_beatmapHashes.Count <= 0) + if (_beatmaps.Count is 0) { + InvalidateCache(); + return; } - Beatmaps tempBeatmaps = [.. AllBeatmaps()]; - UnknownBeatmaps.Clear(); - KnownBeatmaps.Clear(); - DownloadableBeatmaps.Clear(); - - foreach (Beatmap beatmap in tempBeatmaps) - { - ProcessNewlyAddedMap((BeatmapExtension)beatmap); - } - } + List tempBeatmaps = [.. _beatmaps.Values]; + _beatmaps.Clear(); - public IEnumerable AllBeatmaps() - { - for (int i = 0; i < KnownBeatmaps.Count; i++) + foreach (BeatmapExtension beatmap in tempBeatmaps) { - yield return (BeatmapExtension)KnownBeatmaps[i]; + ProcessNewlyAddedMap(beatmap); } - foreach (BeatmapExtension beatmap in NotKnownBeatmaps()) - { - yield return beatmap; - } + InvalidateCache(); } - public IEnumerable NotKnownBeatmaps() - { - for (int i = 0; i < DownloadableBeatmaps.Count; i++) - { - yield return (BeatmapExtension)DownloadableBeatmaps[i]; - } - for (int i = 0; i < UnknownBeatmaps.Count; i++) - { - yield return (BeatmapExtension)UnknownBeatmaps[i]; - } - } + public IEnumerable AllBeatmaps() => _beatmaps.Values; + + public IEnumerable NotKnownBeatmaps() => _beatmaps.Values.Where(b => b.LocalBeatmapMissing); public string Name { get; set; } = "."; - public OsuCollection(MapCacher instance) - { - SetLoadedMaps(instance); - } public void AddBeatmap(Beatmap map) { BeatmapExtension exMap = new(); @@ -136,32 +199,33 @@ public void AddBeatmap(Beatmap map) public void AddBeatmap(BeatmapExtension map) { - if (string.IsNullOrEmpty(map.Md5)) + if (string.IsNullOrEmpty(map.Hash)) { - map.Md5 = "semiRandomHash:" + map.MapId + "|" + map.MapSetId; + map.Hash = "semiRandomHash:" + map.MapId + "|" + map.MapSetId; } - if (_beatmapHashes.Contains(map.Md5)) + if (_beatmaps.ContainsKey(map.Hash)) { return; } ProcessNewlyAddedMap(map); + InvalidateCache(); } public void AddBeatmapByHash(string hash) { - if (_beatmapHashes.Contains(hash)) + if (_beatmaps.ContainsKey(hash)) { return; } - AddBeatmap(new BeatmapExtension() { Md5 = hash }); + AddBeatmap(new BeatmapExtension() { Hash = hash }); } public void AddBeatmapByMapId(int mapId) { - if (AllBeatmaps().Any(m => m.MapId == mapId)) + if (_beatmaps.Values.Any(m => m.MapId == mapId)) { return; } @@ -173,43 +237,27 @@ protected virtual void ProcessNewlyAddedMap(BeatmapExtension map) { lock (LoadedMaps.LockingObject) { - _ = _beatmapHashes.Add(map.Md5); + Beatmap knownMap = LoadedMaps.Get(map.Hash, map.MapId); - Beatmap knownMap = LoadedMaps.GetByHash(map.Md5); - if (knownMap != null) + if (knownMap is null) { - KnownBeatmaps.Add(knownMap); - return; - } - - knownMap = LoadedMaps.GetByMapId(map.MapId); - if (map.MapId > 10 && knownMap != null) - { - //Remove previously added map hash - _ = _beatmapHashes.Remove(map.Md5); - //And add our local version of the map that instead. - _ = _beatmapHashes.Add(knownMap.Md5); - KnownBeatmaps.Add(knownMap); - - if (knownMap is BeatmapExtension knownMapEx) - { - knownMapEx.LocalVersionDiffers = true; - } + map.LocalBeatmapMissing = true; + _beatmaps[map.Hash] = map; return; } - if (map.MapSetId != 0) - { - DownloadableBeatmaps.Add(map); - - } - else + if (knownMap is not BeatmapExtension beatmapToStore) { - UnknownBeatmaps.Add(map); + beatmapToStore = new BeatmapExtension(); + beatmapToStore.CloneValues(knownMap); } - map.LocalBeatmapMissing = true; + beatmapToStore.LocalBeatmapMissing = false; + beatmapToStore.LocalVersionDiffers = knownMap.Hash != map.Hash; + _beatmaps[knownMap.Hash] = beatmapToStore; + + return; } } @@ -220,34 +268,42 @@ public void ReplaceBeatmap(string hash, Beatmap newBeatmap) AddBeatmap(newBeatmap); } } + public void ReplaceBeatmap(int mapId, Beatmap newBeatmap) { - BeatmapExtension map = AllBeatmaps().FirstOrDefault(m => m.MapId == mapId); + BeatmapExtension map = _beatmaps.Values.FirstOrDefault(m => m.MapId == mapId); - if (map != null && RemoveBeatmap(map.Md5)) + if (map is not null && RemoveBeatmap(map.Hash)) { AddBeatmap(newBeatmap); } } - public virtual bool RemoveBeatmap(string hash) + + public virtual bool RemoveBeatmap(string hash) => RemoveBeatmaps([hash]) is 1; + + public virtual int RemoveBeatmaps(IEnumerable hashes) { - if (_beatmapHashes.Contains(hash)) + if (hashes is null) { - foreach (BeatmapExtension map in AllBeatmaps()) - { - if (map.Md5 == hash) - { - _ = UnknownBeatmaps.Remove(map); - _ = KnownBeatmaps.Remove(map); - _ = DownloadableBeatmaps.Remove(map); - _ = _beatmapHashes.Remove(hash); - return true; - } - } + return 0; + } + + HashSet hashesToRemove = [.. hashes]; + hashesToRemove.IntersectWith(_beatmaps.Keys); + + if (hashesToRemove.Count is 0) + { + return 0; } - return false; + foreach (string hash in hashesToRemove) + { + _ = _beatmaps.Remove(hash); + } + + InvalidateCache(); + return hashesToRemove.Count; } - public IEnumerator GetEnumerator() => AllBeatmaps().GetEnumerator(); + public IEnumerator GetEnumerator() => AllBeatmaps().GetEnumerator(); } \ No newline at end of file diff --git a/CollectionManager.Core/Types/OsuType.cs b/CollectionManager.Core/Types/OsuType.cs index ca70afa..97a715d 100644 --- a/CollectionManager.Core/Types/OsuType.cs +++ b/CollectionManager.Core/Types/OsuType.cs @@ -4,5 +4,6 @@ public enum OsuType { Any, Stable, - Lazer + Lazer, + None = -1 } \ No newline at end of file diff --git a/CollectionManager.Core/Types/RangeObservableCollection.cs b/CollectionManager.Core/Types/RangeObservableCollection.cs index 0a68691..67ebe80 100644 --- a/CollectionManager.Core/Types/RangeObservableCollection.cs +++ b/CollectionManager.Core/Types/RangeObservableCollection.cs @@ -7,7 +7,7 @@ public class RangeObservableCollection : ObservableCollection { - private bool _suppressNotification = false; + private bool _suppressNotifications; public RangeObservableCollection() { @@ -20,7 +20,7 @@ public RangeObservableCollection(IEnumerable collection) protected override void OnCollectionChanged(NotifyCollectionChangedEventArgs e) { - if (!_suppressNotification) + if (!_suppressNotifications) { base.OnCollectionChanged(e); } @@ -28,42 +28,50 @@ protected override void OnCollectionChanged(NotifyCollectionChangedEventArgs e) public void SilentRemove(T item) { - _suppressNotification = true; + _suppressNotifications = true; _ = Remove(item); - _suppressNotification = false; + _suppressNotifications = false; } public void SilentAdd(T item) { - _suppressNotification = true; + _suppressNotifications = true; Add(item); - _suppressNotification = false; + _suppressNotifications = false; } public void CallReset() => OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); - public void SuspendEvents(bool suspend = true) - { - _suppressNotification = suspend; - if (!suspend) - { - CallReset(); - } - } + public void AddRange(IEnumerable list) { - if (list == null) - { - throw new ArgumentNullException("list"); - } + ArgumentNullException.ThrowIfNull(list); - _suppressNotification = true; + _suppressNotifications = true; foreach (T item in list) { Add(item); } - _suppressNotification = false; - //OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); + _suppressNotifications = false; + } + + /// + /// Temporarily suspends collection changed events until the returned context is disposed. + /// + /// The suspend context. + public SuspendContext SuspendCollectionChangedEvents() => new(this); + + public sealed class SuspendContext : IDisposable + { + private readonly RangeObservableCollection _collection; + + public SuspendContext(RangeObservableCollection collection) + { + _collection = collection; + _collection._suppressNotifications = true; + } + + public void Dispose() => _collection._suppressNotifications = false; } } diff --git a/CollectionManager.Core/Types/WebCollection.cs b/CollectionManager.Core/Types/WebCollection.cs index f0dfe78..c45785f 100644 --- a/CollectionManager.Core/Types/WebCollection.cs +++ b/CollectionManager.Core/Types/WebCollection.cs @@ -81,6 +81,13 @@ public override bool RemoveBeatmap(string hash) return base.RemoveBeatmap(hash); } + + public override int RemoveBeatmaps(IEnumerable hashes) + { + Modified = true; + + return base.RemoveBeatmaps(hashes); + } } public interface IWebCollectionProvider diff --git a/CollectionManager.Extensions/Modules/CollectionApiGenerator/UserTopGenerator.cs b/CollectionManager.Extensions/Modules/CollectionApiGenerator/UserTopGenerator.cs index d996ed3..998b028 100644 --- a/CollectionManager.Extensions/Modules/CollectionApiGenerator/UserTopGenerator.cs +++ b/CollectionManager.Extensions/Modules/CollectionApiGenerator/UserTopGenerator.cs @@ -18,9 +18,10 @@ public sealed partial class UserTopGenerator : IDisposable { private readonly string StartingProcessing = "Preparing..."; private readonly string ParsingUser = "Processing \"{0}\" | {1}"; - private readonly string GettingScores = "Getting scores from api...(try {0} of 5)"; - private readonly string GettingBeatmaps = "Getting missing beatmaps data from api... {0}"; - private readonly string ParsingFinished = "Done processing {0} users! - Close this window to add created collections"; + private readonly string GettingScores = "Getting scores from api... (try {0} of 5)"; + private readonly string GettingBeatmaps = "Getting missing beatmaps data from api... {0}."; + private readonly string ParsingUserFinished = "Done."; + private readonly string ParsingFinished = "Done processing {0} users! - Close this window to add created collections."; private readonly string GettingUserFailed = "FAILED | Waiting {1}s and trying again."; private readonly string GettingBeatmapFailed = "FAILED | Waiting {1}s and trying again."; private readonly string Aborted = "FAILED | User aborted."; @@ -67,13 +68,13 @@ public async Task GetPlayersCollectionsAsync(CollectionGenerator OsuCollections collections = await GetPlayerCollectionsAsync(username, configuration.CollectionNameSavePattern, (PlayMode)configuration.Gamemode, configuration.ScoreSaveConditions, cancellationToken); - Log(username, ParsingFinished, ++processedCounter / (double)totalUsernames * 100); + Log(username, ParsingUserFinished, ++processedCounter / (double)totalUsernames * 100); _collectionManager.EditCollection(CollectionEditArgs.AddOrMergeCollections(collections)); } newCollections.AddRange(_collectionManager.LoadedCollections); - _logger?.Invoke(string.Format(CultureInfo.InvariantCulture, ParsingFinished, configuration.Usernames.Count, cancellationToken), 100); + _logger?.Invoke(string.Format(CultureInfo.InvariantCulture, ParsingFinished, configuration.Usernames.Count), 100); return newCollections; } @@ -190,7 +191,6 @@ private async Task GetBeatmapFromId(int beatmapId, PlayMode gamemode, C private async Task> GetPlayerScores(string username, PlayMode gamemode, ScoreSaveConditions configuration, CancellationToken cancellationToken) { - Log(username, string.Format(CultureInfo.InvariantCulture, GettingScores, 1)); IList scoresFromCache = _scoreCache.FirstOrDefault(s => s.Key.Username == username & s.Key.PlayMode == gamemode).Value; if (scoresFromCache != null) diff --git a/CollectionManager.Extensions/Modules/CollectionListGenerator/ListTypes/GenericGenerator.cs b/CollectionManager.Extensions/Modules/CollectionListGenerator/ListTypes/GenericGenerator.cs index dee6225..90be613 100644 --- a/CollectionManager.Extensions/Modules/CollectionListGenerator/ListTypes/GenericGenerator.cs +++ b/CollectionManager.Extensions/Modules/CollectionListGenerator/ListTypes/GenericGenerator.cs @@ -74,7 +74,7 @@ protected virtual void GetMapSetList(int mapSetId, Beatmaps beatmaps, ref String } } - _ = sb.Append(_md5Output.ToString()); + _ = sb.Append(_md5Output); _ = _md5Output.Clear(); } } \ No newline at end of file diff --git a/CollectionManager.Extensions/Modules/CollectionListGenerator/ListTypes/UserListGenerator.cs b/CollectionManager.Extensions/Modules/CollectionListGenerator/ListTypes/UserListGenerator.cs index a8f72f8..5631592 100644 --- a/CollectionManager.Extensions/Modules/CollectionListGenerator/ListTypes/UserListGenerator.cs +++ b/CollectionManager.Extensions/Modules/CollectionListGenerator/ListTypes/UserListGenerator.cs @@ -10,36 +10,31 @@ namespace CollectionManager.Extensions.Modules.CollectionListGenerator.ListTypes public class UserListGenerator : GenericGenerator { public static string NewLine = "|NL|"; - private string _mainHeader = ""; - private string _mainFooter = ""; - private string _collectionBodyFormat = ""; - private string _collectionFooter = ""; - private string _collectionHeaderTemplate = ""; public string mainHeader { - get => _mainHeader; set => _mainHeader = value.Replace(NewLine, Environment.NewLine); - } + get; set => field = value.Replace(NewLine, Environment.NewLine); + } = ""; public string mainFooter { - get => _mainFooter; set => _mainFooter = value.Replace(NewLine, Environment.NewLine); - } + get; set => field = value.Replace(NewLine, Environment.NewLine); + } = ""; public string collectionBodyFormat { - get => _collectionBodyFormat; set => _collectionBodyFormat = value.Replace(NewLine, Environment.NewLine); - } + get; set => field = value.Replace(NewLine, Environment.NewLine); + } = ""; public string collectionFooter { - get => _collectionFooter; set => _collectionFooter = value.Replace(NewLine, Environment.NewLine); - } + get; set => field = value.Replace(NewLine, Environment.NewLine); + } = ""; public string collectionHeaderTemplate { - get => _collectionHeaderTemplate; set => _collectionHeaderTemplate = value.Replace(NewLine, Environment.NewLine); - } + get; set => field = value.Replace(NewLine, Environment.NewLine); + } = ""; protected override string MainHeader => mainHeader; protected override string MainFooter => mainFooter; @@ -67,8 +62,7 @@ protected override void GetMapSetList(int mapSetId, Beatmaps beatmaps, ref Strin { _ = map.MapId > 0 ? sb.Append(CollectionBodyFormat.RobotoFormat(map)) - : _md5Output.AppendFormat(CollectionBodyFormat, "http://osu.ppy.sh/b/0", map.Md5, "", "", "", - map.Md5); + : _md5Output.Append($"unsubmitted: {map.Artist} - {map.Title} [{map.DiffName}]{Environment.NewLine}"); } } else @@ -79,7 +73,7 @@ protected override void GetMapSetList(int mapSetId, Beatmaps beatmaps, ref Strin } } - _ = sb.Append(_md5Output.ToString()); + _ = sb.Append(_md5Output); _ = _md5Output.Clear(); } diff --git a/CollectionManager.Extensions/Utils/BeatmapUtils.cs b/CollectionManager.Extensions/Utils/BeatmapUtils.cs index 2654876..325c576 100644 --- a/CollectionManager.Extensions/Utils/BeatmapUtils.cs +++ b/CollectionManager.Extensions/Utils/BeatmapUtils.cs @@ -9,6 +9,8 @@ namespace CollectionManager.Extensions.Utils; public static class BeatmapUtils { + private static readonly string[] _backgroundFileFormats = [".jpg", ".jpeg", ".png"]; + public static string OsuSongsDirectory { get; set; } = ""; public static Dictionary GetMapSets(this IOsuCollection collection, BeatmapListType beatmapListType) @@ -95,13 +97,21 @@ public static string GetImageLocation(this Beatmap beatmap) string line; while ((line = file.ReadLine()) != null) { - if (line.Contains(".jpg", StringComparison.CurrentCultureIgnoreCase) || line.Contains(".png", StringComparison.CurrentCultureIgnoreCase)) + if (_backgroundFileFormats.Any(fileFormat => line.Contains(fileFormat, StringComparison.InvariantCultureIgnoreCase))) { - string[] splited = line.Split(','); - imageLocation = Path.Combine(beatmap.BeatmapDirectory(), splited[2].Trim('"')); + string[] split = line.Split(','); + + if (split.Length < 3) + { + continue; + } + + imageLocation = Path.Combine(beatmap.BeatmapDirectory(), split[2].Trim('"')); + if (!File.Exists(imageLocation)) { - return string.Empty; + imageLocation = string.Empty; + continue; } break; @@ -126,9 +136,19 @@ public static string FullAudioFileLocation(this Beatmap beatmap) if (beatmap is LazerBeatmap lazerBeatmap) { + if (string.IsNullOrWhiteSpace(lazerBeatmap.AudioRelativeFilePath)) + { + return string.Empty; + } + return Path.Combine(OsuSongsDirectory, lazerBeatmap.AudioRelativeFilePath); } + if (string.IsNullOrWhiteSpace(beatmap.Mp3Name)) + { + return string.Empty; + } + return Path.Combine(beatmap.BeatmapDirectory(), beatmap.Mp3Name); } diff --git a/CollectionManager.WinForms/CollectionManager.WinForms.csproj b/CollectionManager.WinForms/CollectionManager.WinForms.csproj index 79d6d7f..69bddd2 100644 --- a/CollectionManager.WinForms/CollectionManager.WinForms.csproj +++ b/CollectionManager.WinForms/CollectionManager.WinForms.csproj @@ -1,6 +1,7 @@  + diff --git a/CollectionManager.WinForms/Controls/BeatmapListingView.Designer.cs b/CollectionManager.WinForms/Controls/BeatmapListingView.Designer.cs index 0790337..ad8b542 100644 --- a/CollectionManager.WinForms/Controls/BeatmapListingView.Designer.cs +++ b/CollectionManager.WinForms/Controls/BeatmapListingView.Designer.cs @@ -33,6 +33,7 @@ private void InitializeComponent() textBox_beatmapSearch = new System.Windows.Forms.TextBox(); BeatmapsContextMenuStrip = new System.Windows.Forms.ContextMenuStrip(components); OpenDlMapMenuStrip = new System.Windows.Forms.ToolStripMenuItem(); + OpenInOsuMenuStrip = new System.Windows.Forms.ToolStripMenuItem(); OpenBeatmapPageMapMenuStrip = new System.Windows.Forms.ToolStripMenuItem(); OpenBeatmapDownloadMapMenuStrip = new System.Windows.Forms.ToolStripMenuItem(); DownloadMapManagedMenuStrip = new System.Windows.Forms.ToolStripMenuItem(); @@ -107,11 +108,18 @@ private void InitializeComponent() // // OpenDlMapMenuStrip // - OpenDlMapMenuStrip.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { OpenBeatmapPageMapMenuStrip, OpenBeatmapDownloadMapMenuStrip, OpenBeatmapFolderMenuStrip }); + OpenDlMapMenuStrip.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { OpenInOsuMenuStrip, OpenBeatmapPageMapMenuStrip, OpenBeatmapDownloadMapMenuStrip, OpenBeatmapFolderMenuStrip }); OpenDlMapMenuStrip.Name = "OpenDlMapMenuStrip"; OpenDlMapMenuStrip.Size = new System.Drawing.Size(180, 22); OpenDlMapMenuStrip.Text = "Open"; // + // OpenInOsuMenuStrip + // + OpenInOsuMenuStrip.Name = "OpenInOsuMenuStrip"; + OpenInOsuMenuStrip.Size = new System.Drawing.Size(191, 22); + OpenInOsuMenuStrip.Text = "In osu!"; + OpenInOsuMenuStrip.Click += OpenInOsuMenuStrip_Click; + // // OpenBeatmapPageMapMenuStrip // OpenBeatmapPageMapMenuStrip.Name = "OpenBeatmapPageMapMenuStrip"; @@ -492,6 +500,7 @@ private void InitializeComponent() public System.Windows.Forms.TextBox textBox_beatmapSearch; private System.Windows.Forms.ContextMenuStrip BeatmapsContextMenuStrip; private System.Windows.Forms.ToolStripMenuItem OpenDlMapMenuStrip; + private System.Windows.Forms.ToolStripMenuItem OpenInOsuMenuStrip; private System.Windows.Forms.ToolStripMenuItem OpenBeatmapPageMapMenuStrip; private System.Windows.Forms.ToolStripMenuItem OpenBeatmapDownloadMapMenuStrip; private System.Windows.Forms.ToolStripMenuItem DownloadMapManagedMenuStrip; diff --git a/CollectionManager.WinForms/Controls/BeatmapListingView.cs b/CollectionManager.WinForms/Controls/BeatmapListingView.cs index 919707c..ad82381 100644 --- a/CollectionManager.WinForms/Controls/BeatmapListingView.cs +++ b/CollectionManager.WinForms/Controls/BeatmapListingView.cs @@ -17,10 +17,9 @@ namespace GuiComponents.Controls; public partial class BeatmapListingView : UserControl, IBeatmapListingView, IDisposable { - private bool _allowForDeletion; private Mods _currentMods = Mods.Nm; private PlayMode _currentPlayMode = PlayMode.Osu; - private DifficultyCalculator _difficultyCalculator = new(); + private readonly DifficultyCalculator _difficultyCalculator = new(); private List _displayColumns = []; private readonly Dictionary _menuStripClickActions; private SortableFastListBeatmapGroupingStrategy _groupingStrategy; @@ -41,10 +40,10 @@ public partial class BeatmapListingView : UserControl, IBeatmapListingView, IDis [Description("Should user be able to delete beatmaps from the list?"), Category("Layout")] public bool AllowForDeletion { - get => _allowForDeletion; + get; set { - _allowForDeletion = value; + field = value; DeleteMapMenuStrip.Enabled = value; } } @@ -122,6 +121,7 @@ public BeatmapListingView() [DeleteMapMenuStrip] = BeatmapListingAction.DeleteBeatmapsFromCollection, [DownloadMapInBrowserMenuStrip] = BeatmapListingAction.DownloadBeatmaps, [DownloadMapManagedMenuStrip] = BeatmapListingAction.DownloadBeatmapsManaged, + [OpenInOsuMenuStrip] = BeatmapListingAction.OpenInOsu, [OpenBeatmapPageMapMenuStrip] = BeatmapListingAction.OpenBeatmapPages, [copyAsTextMenuStrip] = BeatmapListingAction.CopyBeatmapsAsText, [copyUrlMenuStrip] = BeatmapListingAction.CopyBeatmapsAsUrls, @@ -320,15 +320,6 @@ private void InitListViewGrouping() // user group selection comboBox_grouping.DisplayMember = nameof(BeatmapGroupColumn.Text); - List excludedGroupingColumns = - [ - column_Comment, - column_LastPlayed, - column_LastScoreDate, - column_EditDate, - column_MapId, - column_LocalVersionDiffers, - ]; _displayColumns = [ new(null, "No grouping"), @@ -445,6 +436,8 @@ private void ListViewBeatmaps_KeyUp(object sender, KeyEventArgs e) private void button_searchHelp_Click(object sender, EventArgs e) => BeatmapSearchHelpClicked?.Invoke(this, EventArgs.Empty); + private void OpenInOsuMenuStrip_Click(object sender, EventArgs e) => MenuStripClick(sender, e); + private void comboBox_grouping_SelectedIndexChanged(object sender, EventArgs e) { if (comboBox_grouping.SelectedItem is not BeatmapGroupColumn selectedColumn) diff --git a/CollectionManager.WinForms/Controls/InfoTextView.Designer.cs b/CollectionManager.WinForms/Controls/InfoTextView.Designer.cs index c25f4e6..2d0fc61 100644 --- a/CollectionManager.WinForms/Controls/InfoTextView.Designer.cs +++ b/CollectionManager.WinForms/Controls/InfoTextView.Designer.cs @@ -57,8 +57,8 @@ private void InitializeComponent() // AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F); AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; - Controls.Add(label_CollectionManagerStatus); Controls.Add(label_UpdateText); + Controls.Add(label_CollectionManagerStatus); Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); Name = "InfoTextView"; Size = new System.Drawing.Size(1178, 20); diff --git a/CollectionManager.WinForms/Controls/MainSidePanelView.Designer.cs b/CollectionManager.WinForms/Controls/MainSidePanelView.Designer.cs index 040f3fe..7513e25 100644 --- a/CollectionManager.WinForms/Controls/MainSidePanelView.Designer.cs +++ b/CollectionManager.WinForms/Controls/MainSidePanelView.Designer.cs @@ -34,7 +34,8 @@ private void InitializeComponent() Menu_loadCollection = new System.Windows.Forms.ToolStripMenuItem(); Menu_loadDefaultCollection = new System.Windows.Forms.ToolStripMenuItem(); saveToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); - Menu_saveAllCollections = new System.Windows.Forms.ToolStripMenuItem(); + Menu_saveAllCollectionsAsDb = new System.Windows.Forms.ToolStripMenuItem(); + Menu_saveAllCollectionsAsOsdb = new System.Windows.Forms.ToolStripMenuItem(); Menu_saveOsuCollection = new System.Windows.Forms.ToolStripMenuItem(); Menu_collectionsSplit = new System.Windows.Forms.ToolStripMenuItem(); listingToolStripMenuItem1 = new System.Windows.Forms.ToolStripMenuItem(); @@ -60,12 +61,14 @@ private void InitializeComponent() toolStripSeparator1 = new System.Windows.Forms.ToolStripSeparator(); Menu_discord = new System.Windows.Forms.ToolStripMenuItem(); Menu_github = new System.Windows.Forms.ToolStripMenuItem(); + toolStripSeparator2 = new System.Windows.Forms.ToolStripSeparator(); + toolStripSeparator3 = new System.Windows.Forms.ToolStripSeparator(); menuStrip1.SuspendLayout(); SuspendLayout(); // // menuStrip1 // - menuStrip1.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { fileToolStripMenuItem, onlineToolStripMenuItem, osustatsCollectionsToolStripMenuItem, Menu_beatmapListing, settingsToolStripMenuItem, helpToolStripMenuItem }); + menuStrip1.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { fileToolStripMenuItem, Menu_beatmapListing, onlineToolStripMenuItem, osustatsCollectionsToolStripMenuItem, settingsToolStripMenuItem, helpToolStripMenuItem }); menuStrip1.Location = new System.Drawing.Point(0, 0); menuStrip1.Name = "menuStrip1"; menuStrip1.Padding = new System.Windows.Forms.Padding(7, 2, 0, 2); @@ -96,39 +99,45 @@ private void InitializeComponent() // Menu_loadDefaultCollection // Menu_loadDefaultCollection.Name = "Menu_loadDefaultCollection"; - Menu_loadDefaultCollection.Size = new System.Drawing.Size(187, 22); - Menu_loadDefaultCollection.Text = "osu! collection"; + Menu_loadDefaultCollection.Size = new System.Drawing.Size(192, 22); + Menu_loadDefaultCollection.Text = "Default osu! collection"; // // saveToolStripMenuItem // - saveToolStripMenuItem.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { Menu_saveAllCollections, Menu_saveOsuCollection, Menu_collectionsSplit }); + saveToolStripMenuItem.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { Menu_saveAllCollectionsAsDb, Menu_saveAllCollectionsAsOsdb, toolStripSeparator3, Menu_saveOsuCollection, toolStripSeparator2, Menu_collectionsSplit }); saveToolStripMenuItem.Name = "saveToolStripMenuItem"; saveToolStripMenuItem.Size = new System.Drawing.Size(109, 22); saveToolStripMenuItem.Text = "Save"; // - // Menu_saveAllCollections + // Menu_saveAllCollectionsAsDb // - Menu_saveAllCollections.Name = "Menu_saveAllCollections"; - Menu_saveAllCollections.Size = new System.Drawing.Size(217, 22); - Menu_saveAllCollections.Text = "Collection(.db/.osdb)"; + Menu_saveAllCollectionsAsDb.Name = "Menu_saveAllCollectionsAsDb"; + Menu_saveAllCollectionsAsDb.Size = new System.Drawing.Size(220, 22); + Menu_saveAllCollectionsAsDb.Text = "osu! collection (.db)"; + // + // Menu_saveAllCollectionsAsOsdb + // + Menu_saveAllCollectionsAsOsdb.Name = "Menu_saveAllCollectionsAsOsdb"; + Menu_saveAllCollectionsAsOsdb.Size = new System.Drawing.Size(220, 22); + Menu_saveAllCollectionsAsOsdb.Text = "Shareable collection (.osdb)"; // // Menu_saveOsuCollection // Menu_saveOsuCollection.Name = "Menu_saveOsuCollection"; - Menu_saveOsuCollection.Size = new System.Drawing.Size(217, 22); - Menu_saveOsuCollection.Text = "osu! collection"; + Menu_saveOsuCollection.Size = new System.Drawing.Size(220, 22); + Menu_saveOsuCollection.Text = "Default osu! collection"; // // Menu_collectionsSplit // Menu_collectionsSplit.Name = "Menu_collectionsSplit"; - Menu_collectionsSplit.Size = new System.Drawing.Size(217, 22); + Menu_collectionsSplit.Size = new System.Drawing.Size(220, 22); Menu_collectionsSplit.Text = "Collections in separate files"; // // listingToolStripMenuItem1 // listingToolStripMenuItem1.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { Menu_listAllCollections, Menu_listMissingMaps }); listingToolStripMenuItem1.Name = "listingToolStripMenuItem1"; - listingToolStripMenuItem1.Size = new System.Drawing.Size(109, 22); + listingToolStripMenuItem1.Size = new System.Drawing.Size(180, 22); listingToolStripMenuItem1.Text = "Listing"; // // Menu_listAllCollections @@ -146,7 +155,7 @@ private void InitializeComponent() // Menu_unloadCollections // Menu_unloadCollections.Name = "Menu_unloadCollections"; - Menu_unloadCollections.Size = new System.Drawing.Size(109, 22); + Menu_unloadCollections.Size = new System.Drawing.Size(180, 22); Menu_unloadCollections.Text = "Clear"; // // onlineToolStripMenuItem @@ -249,26 +258,36 @@ private void InitializeComponent() // Menu_searchSyntax // Menu_searchSyntax.Name = "Menu_searchSyntax"; - Menu_searchSyntax.Size = new System.Drawing.Size(180, 22); + Menu_searchSyntax.Size = new System.Drawing.Size(146, 22); Menu_searchSyntax.Text = "Search Syntax"; // // toolStripSeparator1 // toolStripSeparator1.Name = "toolStripSeparator1"; - toolStripSeparator1.Size = new System.Drawing.Size(177, 6); + toolStripSeparator1.Size = new System.Drawing.Size(143, 6); // // Menu_discord // Menu_discord.Name = "Menu_discord"; - Menu_discord.Size = new System.Drawing.Size(180, 22); + Menu_discord.Size = new System.Drawing.Size(146, 22); Menu_discord.Text = "Discord"; // // Menu_github // Menu_github.Name = "Menu_github"; - Menu_github.Size = new System.Drawing.Size(180, 22); + Menu_github.Size = new System.Drawing.Size(146, 22); Menu_github.Text = "Github"; // + // toolStripSeparator2 + // + toolStripSeparator2.Name = "toolStripSeparator2"; + toolStripSeparator2.Size = new System.Drawing.Size(217, 6); + // + // toolStripSeparator3 + // + toolStripSeparator3.Name = "toolStripSeparator3"; + toolStripSeparator3.Size = new System.Drawing.Size(217, 6); + // // MainSidePanelView // AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F); @@ -300,7 +319,8 @@ private void InitializeComponent() private System.Windows.Forms.ToolStripMenuItem toolStripMenuItem2; private System.Windows.Forms.ToolStripMenuItem Menu_loadCollection; private System.Windows.Forms.ToolStripMenuItem Menu_loadDefaultCollection; - private System.Windows.Forms.ToolStripMenuItem Menu_saveAllCollections; + private System.Windows.Forms.ToolStripMenuItem Menu_saveAllCollectionsAsDb; + private System.Windows.Forms.ToolStripMenuItem Menu_saveAllCollectionsAsOsdb; private System.Windows.Forms.ToolStripMenuItem Menu_collectionsSplit; private System.Windows.Forms.ToolStripMenuItem Menu_unloadCollections; private System.Windows.Forms.ToolStripMenuItem Menu_beatmapListing; @@ -317,5 +337,7 @@ private void InitializeComponent() private System.Windows.Forms.ToolStripSeparator toolStripSeparator1; private System.Windows.Forms.ToolStripMenuItem Menu_discord; private System.Windows.Forms.ToolStripMenuItem Menu_github; + private System.Windows.Forms.ToolStripSeparator toolStripSeparator3; + private System.Windows.Forms.ToolStripSeparator toolStripSeparator2; } } diff --git a/CollectionManager.WinForms/Controls/MainSidePanelView.cs b/CollectionManager.WinForms/Controls/MainSidePanelView.cs index 166390f..b10530f 100644 --- a/CollectionManager.WinForms/Controls/MainSidePanelView.cs +++ b/CollectionManager.WinForms/Controls/MainSidePanelView.cs @@ -32,31 +32,20 @@ public IUserInformation UserInformation public MainSidePanelView() { InitializeComponent(); - Menu_loadCollection.Click += delegate - { OnLoadCollection(); }; - Menu_loadDefaultCollection.Click += delegate - { OnLoadDefaultCollection(); }; - Menu_unloadCollections.Click += delegate - { OnClearCollections(); }; - Menu_saveAllCollections.Click += delegate - { OnSaveCollections(); }; - Menu_collectionsSplit.Click += delegate - { OnSaveIndividualCollections(); }; - Menu_listAllCollections.Click += delegate - { OnListAllMaps(); }; - Menu_listMissingMaps.Click += delegate - { OnListMissingMaps(); }; - Menu_beatmapListing.Click += delegate - { OnShowBeatmapListing(); }; - Menu_mapDownloads.Click += delegate - { OnShowDownloadManager(); }; - Menu_downloadAllMissing.Click += delegate - { OnDownloadAllMissing(); }; - Menu_GenerateCollections.Click += delegate - { OnGenerateCollections(); }; - Menu_GetMissingMapData.Click += delegate - { OnGetMissingMapData(); }; + Menu_loadCollection.Click += (_, _) => SidePanelOperation?.Invoke(this, MainSidePanelActions.LoadCollection); + Menu_loadDefaultCollection.Click += (_, _) => SidePanelOperation?.Invoke(this, MainSidePanelActions.LoadDefaultCollection); + Menu_unloadCollections.Click += (_, _) => SidePanelOperation?.Invoke(this, MainSidePanelActions.ClearCollections); + Menu_saveAllCollectionsAsDb.Click += (_, _) => SidePanelOperation?.Invoke(this, MainSidePanelActions.SaveCollectionsAsDb); + Menu_saveAllCollectionsAsOsdb.Click += (_, _) => SidePanelOperation?.Invoke(this, MainSidePanelActions.SaveCollectionsAsOsdb); + Menu_collectionsSplit.Click += (_, _) => SidePanelOperation?.Invoke(this, MainSidePanelActions.SaveIndividualCollections); + Menu_listAllCollections.Click += (_, _) => SidePanelOperation?.Invoke(this, MainSidePanelActions.ListAllBeatmaps); + Menu_listMissingMaps.Click += (_, _) => SidePanelOperation?.Invoke(this, MainSidePanelActions.ListMissingMaps); + Menu_beatmapListing.Click += (_, _) => SidePanelOperation?.Invoke(this, MainSidePanelActions.ShowBeatmapListing); + Menu_mapDownloads.Click += (_, _) => SidePanelOperation?.Invoke(this, MainSidePanelActions.ShowDownloadManager); + Menu_downloadAllMissing.Click += (_, _) => SidePanelOperation?.Invoke(this, MainSidePanelActions.DownloadAllMissing); + Menu_GenerateCollections.Click += (_, _) => SidePanelOperation?.Invoke(this, MainSidePanelActions.GenerateCollections); + Menu_GetMissingMapData.Click += (_, _) => SidePanelOperation?.Invoke(this, MainSidePanelActions.GetMissingMapData); Menu_osustatsLogin.Click += (_, _) => SidePanelOperation?.Invoke(this, MainSidePanelActions.OsustatsLogin); Menu_saveOsuCollection.Click += (_, _) => SidePanelOperation?.Invoke(this, MainSidePanelActions.SaveDefaultCollection); Menu_resetSettings.Click += (_, _) => SidePanelOperation?.Invoke(this, MainSidePanelActions.ResetApplicationSettings); @@ -136,27 +125,4 @@ private ToolStripItem[] GetCollectionSubmenus(WebCollection webCollection) return new ToolStripItem[] { loadCollection, uploadChanges, deleteCollection, openOnWeb }; } - - private void OnLoadCollection() => SidePanelOperation?.Invoke(this, MainSidePanelActions.LoadCollection); - - private void OnLoadDefaultCollection() => SidePanelOperation?.Invoke(this, MainSidePanelActions.LoadDefaultCollection); - - private void OnClearCollections() => SidePanelOperation?.Invoke(this, MainSidePanelActions.ClearCollections); - - private void OnSaveCollections() => SidePanelOperation?.Invoke(this, MainSidePanelActions.SaveCollections); - - private void OnSaveIndividualCollections() => SidePanelOperation?.Invoke(this, MainSidePanelActions.SaveIndividualCollections); - - private void OnListAllMaps() => SidePanelOperation?.Invoke(this, MainSidePanelActions.ListAllBeatmaps); - - private void OnListMissingMaps() => SidePanelOperation?.Invoke(this, MainSidePanelActions.ListMissingMaps); - - private void OnShowBeatmapListing() => SidePanelOperation?.Invoke(this, MainSidePanelActions.ShowBeatmapListing); - - private void OnShowDownloadManager() => SidePanelOperation?.Invoke(this, MainSidePanelActions.ShowDownloadManager); - - protected virtual void OnDownloadAllMissing() => SidePanelOperation?.Invoke(this, MainSidePanelActions.DownloadAllMissing); - - protected virtual void OnGenerateCollections() => SidePanelOperation?.Invoke(this, MainSidePanelActions.GenerateCollections); - protected virtual void OnGetMissingMapData() => SidePanelOperation?.Invoke(this, MainSidePanelActions.GetMissingMapData); } diff --git a/CollectionManager.WinForms/FormServices.cs b/CollectionManager.WinForms/FormServices.cs new file mode 100644 index 0000000..ba20e86 --- /dev/null +++ b/CollectionManager.WinForms/FormServices.cs @@ -0,0 +1,37 @@ +namespace CollectionManager.WinForms; + +using CollectionManager.Common.Interfaces; +using CollectionManager.Common.Interfaces.Forms; +using Microsoft.Extensions.DependencyInjection; +using System; +using System.Collections.Generic; + +public sealed class FormServices +{ + public static void RegisterServices(ServiceCollection serviceCollection) + { + _ = serviceCollection.AddSingleton(); + + Dictionary> formTypes = typeof(FormServices) + .Assembly + .GetTypes() + .Where(x => + !x.IsAbstract + && x.IsClass + && x.GetInterface(nameof(IForm)) == typeof(IForm)) + .ToDictionary( + formType => formType, + (formType) => formType + .GetInterfaces() + .Where(interfaceType => interfaceType.Namespace.StartsWith(nameof(CollectionManager), StringComparison.InvariantCulture)) + .Except([typeof(IForm)])); + + foreach ((Type formType, IEnumerable interfaces) in formTypes) + { + foreach (Type interfaceType in interfaces) + { + _ = serviceCollection.AddTransient(interfaceType, formType); + } + } + } +} diff --git a/CollectionManager.WinForms/OkForm.Designer.cs b/CollectionManager.WinForms/OkForm.Designer.cs new file mode 100644 index 0000000..96d3eaf --- /dev/null +++ b/CollectionManager.WinForms/OkForm.Designer.cs @@ -0,0 +1,93 @@ +namespace GuiComponents +{ + partial class OkForm + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + label_text = new System.Windows.Forms.Label(); + checkBox_doNotAskAgain = new System.Windows.Forms.CheckBox(); + button_Ok = new System.Windows.Forms.Button(); + SuspendLayout(); + // + // label_text + // + label_text.Location = new System.Drawing.Point(14, 15); + label_text.MaximumSize = new System.Drawing.Size(415, 91); + label_text.Name = "label_text"; + label_text.Size = new System.Drawing.Size(415, 81); + label_text.TabIndex = 1; + label_text.Text = "Text\r\n\r\nText\r\n\r\nText\r\n\r\nText\r\n"; + label_text.TextAlign = System.Drawing.ContentAlignment.MiddleCenter; + // + // checkBox_doNotAskAgain + // + checkBox_doNotAskAgain.AutoSize = true; + checkBox_doNotAskAgain.Location = new System.Drawing.Point(15, 110); + checkBox_doNotAskAgain.Name = "checkBox_doNotAskAgain"; + checkBox_doNotAskAgain.Size = new System.Drawing.Size(229, 19); + checkBox_doNotAskAgain.TabIndex = 2; + checkBox_doNotAskAgain.Text = "Don't inform me again in this session"; + checkBox_doNotAskAgain.UseVisualStyleBackColor = true; + checkBox_doNotAskAgain.CheckedChanged += CheckBox_doNotAskAgain_CheckedChanged; + // + // button_Ok + // + button_Ok.Anchor = System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left; + button_Ok.DialogResult = System.Windows.Forms.DialogResult.OK; + button_Ok.Location = new System.Drawing.Point(163, 136); + button_Ok.Name = "button_Ok"; + button_Ok.Size = new System.Drawing.Size(117, 27); + button_Ok.TabIndex = 0; + button_Ok.Text = "OK"; + button_Ok.UseVisualStyleBackColor = true; + // + // OkForm + // + AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F); + AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + AutoSize = true; + ClientSize = new System.Drawing.Size(443, 177); + Controls.Add(label_text); + Controls.Add(checkBox_doNotAskAgain); + Controls.Add(button_Ok); + FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedDialog; + MaximizeBox = false; + MinimizeBox = false; + Name = "OkForm"; + Text = "Collection Manager - OkForm"; + ResumeLayout(false); + PerformLayout(); + + } + + #endregion + + private System.Windows.Forms.Button button_Ok; + public System.Windows.Forms.CheckBox checkBox_doNotAskAgain; + public System.Windows.Forms.Label label_text; + } +} diff --git a/CollectionManager.WinForms/OkForm.cs b/CollectionManager.WinForms/OkForm.cs new file mode 100644 index 0000000..ef499c5 --- /dev/null +++ b/CollectionManager.WinForms/OkForm.cs @@ -0,0 +1,104 @@ +namespace GuiComponents; + +using CollectionManager.WinForms.Forms; +using System; +using System.Windows.Forms; + +public sealed partial class OkForm : BaseForm +{ + private const string OkButtonText = "OK"; + private const string DefaultDoNotInformAgainText = "Don't inform me again in this session"; + + public bool DoNotAskAgainInThisSession { get; private set; } + + private DateTime? _autoCloseAtUtc; + private Timer? _autoCloseTimer; + + public OkForm() + { + InitializeComponent(); + } + + internal static (DialogResult dialogResult, bool doNotAskAgainInThisSession) ShowDialog(string text, string caption, TimeSpan? autoCloseAfter = null, string? doNotInformAgainText = null) + { + using OkForm f = new(); + f.PrepareForm(text, caption, autoCloseAfter, doNotInformAgainText); + + return (f.ShowDialog(), f.DoNotAskAgainInThisSession); + } + + private void PrepareForm(string text, string caption, TimeSpan? autoCloseAfter, string? doNotInformAgainText) + { + label_text.Text = text; + Text = $"Collection Manager - {caption}"; + button_Ok.Text = OkButtonText; + + checkBox_doNotAskAgain.Text = string.IsNullOrWhiteSpace(doNotInformAgainText) + ? DefaultDoNotInformAgainText + : doNotInformAgainText; + + checkBox_doNotAskAgain.Checked = false; + DoNotAskAgainInThisSession = false; + if (autoCloseAfter is null) + { + return; + } + + if (autoCloseAfter.Value < TimeSpan.Zero) + { + autoCloseAfter = TimeSpan.Zero; + } + + _autoCloseAtUtc = DateTime.UtcNow + autoCloseAfter.Value; + _autoCloseTimer = new Timer { Interval = 250 }; + _autoCloseTimer.Tick += (_, _) => UpdateAutoCloseCountdownAndMaybeClose(); + + FormClosed += (_, _) => + { + _autoCloseTimer?.Stop(); + _autoCloseTimer?.Dispose(); + _autoCloseTimer = null; + }; + + Shown += (_, _) => + { + UpdateAutoCloseCountdownAndMaybeClose(); + _autoCloseTimer?.Start(); + }; + } + + private void UpdateAutoCloseCountdownAndMaybeClose() + { + if (_autoCloseAtUtc is null) + { + return; + } + + int secondsLeft = (int)Math.Ceiling((_autoCloseAtUtc.Value - DateTime.UtcNow).TotalSeconds); + if (secondsLeft <= 0) + { + DialogResult = DialogResult.OK; + Close(); + return; + } + + button_Ok.Text = $"{OkButtonText} ({secondsLeft}s)"; + } + + private void CheckBox_doNotAskAgain_CheckedChanged(object sender, EventArgs e) + { + DoNotAskAgainInThisSession = checkBox_doNotAskAgain.Checked; + + if (_autoCloseTimer is null) + { + return; + } + + _autoCloseTimer.Stop(); + _autoCloseTimer.Dispose(); + _autoCloseTimer = null; + _autoCloseAtUtc = null; + + button_Ok.Text = OkButtonText; + } +} diff --git a/CollectionManager.WinForms/OkForm.resx b/CollectionManager.WinForms/OkForm.resx new file mode 100644 index 0000000..a213fbb --- /dev/null +++ b/CollectionManager.WinForms/OkForm.resx @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=9.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=9.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/CollectionManager.WinForms/UserDialogs.cs b/CollectionManager.WinForms/UserDialogs.cs index a6f4ed4..1b0aec2 100644 --- a/CollectionManager.WinForms/UserDialogs.cs +++ b/CollectionManager.WinForms/UserDialogs.cs @@ -12,6 +12,7 @@ public class UserDialogs : IUserDialogs { private readonly char[] _fileFilterSeparator = ['|']; + private static readonly HashSet _sessionSuppressedDialogs = []; public Task IsThisPathCorrectAsync(string path) { @@ -97,13 +98,30 @@ public Task YesNoMessageBoxAsync(string text, string caption, MessageBoxTy public Task CreateProgressFormAsync(Progress userProgressMessage, Progress completionPercentage) => Task.FromResult(GuiComponents.ProgressForm.ShowDialog(userProgressMessage, completionPercentage)); - public Task OkMessageBoxAsync(string text, string caption, MessageBoxType messageBoxType = MessageBoxType.Info) + public Task OkMessageBoxAsync(string text, string caption, MessageBoxType messageBoxType = MessageBoxType.Info, TimeSpan? autoCloseAfter = null, string? doNotInformAgainText = null) { MessageBoxIcon icon = GetMessageBoxIcon(messageBoxType); - _ = MessageBox.Show(null, text, caption, MessageBoxButtons.OK, icon); + string suppressionId = $"OK|{messageBoxType}|{caption}|{text}"; + if (_sessionSuppressedDialogs.Contains(suppressionId)) + { + return Task.FromResult(false); + } - return Task.CompletedTask; + if (autoCloseAfter is null) + { + _ = MessageBox.Show(null, text, caption, MessageBoxButtons.OK, icon); + + return Task.FromResult(true); + } + + (DialogResult _, bool suppressInSession) = OkForm.ShowDialog(text, caption, autoCloseAfter.Value, doNotInformAgainText); + if (suppressInSession) + { + _ = _sessionSuppressedDialogs.Add(suppressionId); + } + + return Task.FromResult(true); } public Task TextMessageBoxAsync(string text, string caption) diff --git a/CollectionManager.sln b/CollectionManager.sln index d8a82a7..0e878f0 100644 --- a/CollectionManager.sln +++ b/CollectionManager.sln @@ -1,6 +1,6 @@ Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.2.32526.322 +# Visual Studio Version 18 +VisualStudioVersion = 18.3.11408.92 d18.3 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CollectionManager.Core", "CollectionManager.Core\CollectionManager.Core.csproj", "{533AB47A-D1B5-45DB-A37E-F053FA3699C4}" EndProject @@ -36,6 +36,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "InnoSetup", "InnoSetup", "{ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CollectionManager.App.Shared", "CollectionManager.App.Shared\CollectionManager.App.Shared.csproj", "{F1F1300C-B403-49E1-8F1A-528D5BD7B03A}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CollectionManager.App.Cli", "CollectionManager.App.Cli\CollectionManager.App.Cli.csproj", "{026CE98B-C4C6-8384-F835-779DAE26F2DD}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -103,6 +105,12 @@ Global {F1F1300C-B403-49E1-8F1A-528D5BD7B03A}.Release|Any CPU.Build.0 = Release|Any CPU {F1F1300C-B403-49E1-8F1A-528D5BD7B03A}.Remote Debug|Any CPU.ActiveCfg = Debug|Any CPU {F1F1300C-B403-49E1-8F1A-528D5BD7B03A}.Remote Debug|Any CPU.Build.0 = Debug|Any CPU + {026CE98B-C4C6-8384-F835-779DAE26F2DD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {026CE98B-C4C6-8384-F835-779DAE26F2DD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {026CE98B-C4C6-8384-F835-779DAE26F2DD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {026CE98B-C4C6-8384-F835-779DAE26F2DD}.Release|Any CPU.Build.0 = Release|Any CPU + {026CE98B-C4C6-8384-F835-779DAE26F2DD}.Remote Debug|Any CPU.ActiveCfg = Debug|Any CPU + {026CE98B-C4C6-8384-F835-779DAE26F2DD}.Remote Debug|Any CPU.Build.0 = Debug|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Directory.Build.props b/Directory.Build.props index 91bd905..0bbb289 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -32,8 +32,10 @@ https://github.com/Piotrekol/CollectionManager true snupkg - 1.1.0 - 1.1.0 + 69.68.0 + 69.68.0 + 69.68.0 + false diff --git a/Directory.Packages.props b/Directory.Packages.props index 4a5836b..e39915f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,6 +8,7 @@ + diff --git a/InnoSetup/script.iss b/InnoSetup/script.iss index cdeb01d..3cafd6f 100644 --- a/InnoSetup/script.iss +++ b/InnoSetup/script.iss @@ -2,11 +2,20 @@ ; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! #include ReadReg(HKLM, 'Software\WOW6432Node\Mitrich Software\Inno Download Plugin', 'InstallDir') + '\idp.iss' +; Version can be passed via command line: iscc /DAppVersion=1.5.8 script.iss +#ifndef AppVersion + #define AppVersion "4.2" +#endif + #define MyAppName "Collection Manager" -#define MyAppVersion "4.2" #define MyAppPublisher "Piotrekol" #define MyAppURL "http://osustats.ppy.sh" #define MyAppExeName "CollectionManager.App.WinForms.exe" +#define CliExeName "CollectionManager.App.Cli.exe" + +; Build configuration paths (using publish profiles - single-file executables) +#define WinFormsPublishPath "..\CollectionManager.App.WinForms\bin\publish" +#define CliPublishPath "..\CollectionManager.App.Cli\bin\publish" [Setup] ; NOTE: The value of AppId uniquely identifies this application. @@ -14,13 +23,13 @@ ; (To generate a new GUID, click Tools | Generate GUID inside the IDE.) AppId={{53A1BDF1-29D1-47BC-BB12-C48B0AC2C636} AppName={#MyAppName} -AppVersion={#MyAppVersion} -;AppVerName={#MyAppName} {#MyAppVersion} +AppVersion={#AppVersion} +;AppVerName={#MyAppName} {#AppVersion} AppPublisher={#MyAppPublisher} AppPublisherURL={#MyAppURL} AppSupportURL={#MyAppURL} AppUpdatesURL={#MyAppURL} -DefaultDirName={pf}\{#MyAppName} +DefaultDirName={autopf}\{#MyAppName} DefaultGroupName={#MyAppName} AllowNoIcons=no LicenseFile=.\license.txt @@ -30,6 +39,8 @@ SetupIconFile=..\CollectionManager.App.WinForms\Resources\logo.ico Compression=lzma SolidCompression=yes ChangesAssociations=yes +PrivilegesRequired=lowest + [Registry] Root: HKCR; Subkey: ".osdb"; ValueData: "{#MyAppName}"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Tasks: osdbAssociation @@ -56,49 +67,22 @@ Name: "english"; MessagesFile: "compiler:Default.isl" Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked Name: osdbAssociation; Description: "Associate "".osdb"" extension (Collection files)"; GroupDescription: File extensions: -Name: "quicklaunchicon"; Description: "{cm:CreateQuickLaunchIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked; OnlyBelowVersion: 0,6.1 - [Files] -Source: "..\CollectionManager.App.WinForms\bin\Release\net9.0-windows\runtimes\win-x64\native\realm-wrappers.dll"; DestDir: "{app}\runtimes\win-x64\native\"; Flags: ignoreversion -Source: "..\CollectionManager.App.WinForms\bin\Release\net9.0-windows\NAudio.WinMM.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\CollectionManager.App.WinForms\bin\Release\net9.0-windows\Newtonsoft.Json.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\CollectionManager.App.WinForms\bin\Release\net9.0-windows\NVorbis.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\CollectionManager.App.WinForms\bin\Release\net9.0-windows\ObjectListView.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\CollectionManager.App.WinForms\bin\Release\net9.0-windows\ObjectListView.xml"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\CollectionManager.App.WinForms\bin\Release\net9.0-windows\Realm.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\CollectionManager.App.WinForms\bin\Release\net9.0-windows\Remotion.Linq.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\CollectionManager.App.WinForms\bin\Release\net9.0-windows\SharpCompress.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\CollectionManager.App.WinForms\bin\Release\net9.0-windows\ZstdSharp.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\CollectionManager.App.WinForms\bin\Release\net9.0-windows\CollectionManager.App.Shared.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\CollectionManager.App.WinForms\bin\Release\net9.0-windows\CollectionManager.App.WinForms.deps.json"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\CollectionManager.App.WinForms\bin\Release\net9.0-windows\CollectionManager.App.WinForms.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\CollectionManager.App.WinForms\bin\Release\net9.0-windows\CollectionManager.App.WinForms.dll.config"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\CollectionManager.App.WinForms\bin\Release\net9.0-windows\CollectionManager.App.WinForms.exe"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\CollectionManager.App.WinForms\bin\Release\net9.0-windows\CollectionManager.App.WinForms.runtimeconfig.json"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\CollectionManager.App.WinForms\bin\Release\net9.0-windows\CollectionManager.Audio.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\CollectionManager.App.WinForms\bin\Release\net9.0-windows\CollectionManager.Common.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\CollectionManager.App.WinForms\bin\Release\net9.0-windows\CollectionManager.Core.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\CollectionManager.App.WinForms\bin\Release\net9.0-windows\CollectionManager.Extensions.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\CollectionManager.App.WinForms\bin\Release\net9.0-windows\CollectionManager.WinForms.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\CollectionManager.App.WinForms\bin\Release\net9.0-windows\CommandLine.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\CollectionManager.App.WinForms\bin\Release\net9.0-windows\downloadSources.json"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\CollectionManager.App.WinForms\bin\Release\net9.0-windows\Microsoft.WindowsAPICodePack.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\CollectionManager.App.WinForms\bin\Release\net9.0-windows\Microsoft.WindowsAPICodePack.Shell.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\CollectionManager.App.WinForms\bin\Release\net9.0-windows\MongoDB.Bson.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\CollectionManager.App.WinForms\bin\Release\net9.0-windows\NAudio.Asio.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\CollectionManager.App.WinForms\bin\Release\net9.0-windows\NAudio.Core.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\CollectionManager.App.WinForms\bin\Release\net9.0-windows\NAudio.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\CollectionManager.App.WinForms\bin\Release\net9.0-windows\NAudio.Midi.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\CollectionManager.App.WinForms\bin\Release\net9.0-windows\NAudio.Vorbis.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\CollectionManager.App.WinForms\bin\Release\net9.0-windows\NAudio.Wasapi.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\CollectionManager.App.WinForms\bin\Release\net9.0-windows\NAudio.WinForms.dll"; DestDir: "{app}"; Flags: ignoreversion +; WinForms app (single-file executable) +Source: "{#WinFormsPublishPath}\CollectionManager.App.WinForms.exe"; DestDir: "{app}"; Flags: ignoreversion +Source: "{#WinFormsPublishPath}\CollectionManager.App.WinForms.dll.config"; DestDir: "{app}"; Flags: ignoreversion +Source: "{#WinFormsPublishPath}\downloadSources.json"; DestDir: "{app}"; Flags: ignoreversion +Source: "{#WinFormsPublishPath}\realm-wrappers.dll"; DestDir: "{app}"; Flags: ignoreversion + +; CLI app (single-file executable) +Source: "{#CliPublishPath}\CollectionManager.App.Cli.exe"; DestDir: "{app}"; Flags: ignoreversion + ; NOTE: Don't use "Flags: ignoreversion" on any shared system files [Icons] Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" Name: "{group}\{cm:UninstallProgram,{#MyAppName}}"; Filename: "{uninstallexe}" Name: "{commondesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon -Name: "{userappdata}\Microsoft\Internet Explorer\Quick Launch\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: quicklaunchicon [InstallDelete] Type: files; Name: "{app}\App.exe" diff --git a/README.md b/README.md index bf5adc1..0f6ec9e 100644 --- a/README.md +++ b/README.md @@ -144,18 +144,84 @@ If you have a collection with missing maps, osu!Stats may be able to repair the ## CLI Usage -The following command-line options are supported: +CLI is provided with main Installer or as standalone exe in `CollectionManager-CLI.zip`. -`-o` / `--Output`: Required. Output filename with or without a path. The filename extension will specify which format to save in: `.db` or `.osdb`. +CLI uses sub-commands for different operations: -`-b` / `--BeatmapIds`: Comma or whitespace separated list of beatmap ids. This can also be a path to a file containing this list. +* `convert` - Convert collection files between formats (.db/.osdb). -`-i` / `--Input`: Input .db/.osdb collection file. + * `-i` / `--Input`: Required. Input .db/.osdb collection file. -`-l` / `--OsuLocation`: The location of your osu! directory or a directory containing a valid osu!.db. If not provided, Collection Manager will attempt to find it automatically. +* `create` - Create collection from beatmap IDs or hashes. -`-s` / `--SkipOsuLocation`: Skip loading of osu! database. + * `-b` / `--BeatmapIds`: Comma or whitespace separated list of beatmap ids. This can also be a path to a file containing this list. -`--help`: Display the help screen. + * `-h` / `--Hashes`: Comma or whitespace separated list of beatmap hashes (MD5). This can also be a path to a file containing this list. -`--version`: Display version information. +* `generate` - Generate collections from user top scores using the osu! API. + + * `-u` / `--Usernames`: Required. Comma or whitespace separated list of usernames. This can also be a path to a file containing this list. + + * `-k` / `--ApiKey`: Required. osu! API key for accessing user data. Create one in your osu! settings, under `Legacy API` section. + + * `-p` / `--CollectionNamePattern`: Optional. Collection name format pattern. Default: `"{0} - {1}"` where `{0}` is username and `{1}` is mods. + + * `-g` / `--Gamemode`: Optional. Game mode: `0`=Osu, `1`=Taiko, `2`=Catch, `3`=Mania. Default: `0`. + + * `--MinPp`: Optional. Minimum PP required for a score. Default: `0`. + + * `--MaxPp`: Optional. Maximum PP allowed for a score. Default: `5000`. + + * `--MinAcc`: Optional. Minimum accuracy required for a score (0-100). Default: `0`. + + * `--MaxAcc`: Optional. Maximum accuracy allowed for a score (0-100). Default: `100`. + + * `-r` / `--Ranks`: Optional. Rank filter: `0`=S and better, `1`=A and worse, `2`=All. Default: `2`. + + * `-m` / `--Mods`: Optional. Comma separated list of required mods (e.g., `Hd,Hr`). If empty, all mods are included. + +Common options: + +* `-o` / `--Output`: Required. Output filename with or without a path. The filename extension will specify which format to save in: `.db` or `.osdb`. + +* `-l` / `--OsuLocation`: The location of your osu! directory or a directory containing a valid osu!.db or client.realm. If not provided, Collection Manager will attempt to find it automatically. + +* `-s` / `--SkipOsuLocation`: Skip loading of osu! database. + +* `--version`: Display version information. + +* `--help`: Display help for specific command. + +### Examples + +**Convert collection format:** +```bash +CollectionManager.App.Cli.exe convert -i input.db -o output.osdb +#or +CollectionManager.App.Cli.exe convert -i input.osdb -o output.db +``` + +**Create collection from beatmap IDs or hashes:** +```bash +CollectionManager.App.Cli.exe create -b "1 2 3 4 5" -o mycollection.osdb +#or +CollectionManager.App.Cli.exe create -h "hash1 hash2 hash3" -o mycollection.osdb +#or using file contents +CollectionManager.App.Cli.exe create -b C:\path\to\ids-or-hashes.txt -o mycollection.osdb +``` + +**Specify osu! location or path to database file manually, instead of using auto detection:** +```bash +CollectionManager.App.Cli.exe create -b "1 2 3" -o output.osdb -l "C:\osu!\osu!.db" +``` + +**Generate collections from user top scores:** +```bash +CollectionManager.App.Cli.exe generate -u "Piotrekol" -k "YOUR_API_KEY" -o "top_plays.osdb" +#or for multiple users +CollectionManager.App.Cli.exe generate -u "player1,player2,player3" -k "YOUR_API_KEY" -o "top_plays.osdb" +#or using file contents +CollectionManager.App.Cli.exe generate -u C:\path\to\usernames.txt -k "YOUR_API_KEY" -o "top_plays.osdb" +#or with mods filter and minimum PP +CollectionManager.App.Cli.exe generate -u "player1" -k "YOUR_API_KEY" -o "hdhr_plays.osdb" -m "HR,HD" --MinPp 500 +```