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
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
+```