diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..add57be
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+bin/
+obj/
+/packages/
+riderModule.iml
+/_ReSharper.Caches/
\ No newline at end of file
diff --git a/.idea/.idea.BatchBeatmapDownloader/.idea/.gitignore b/.idea/.idea.BatchBeatmapDownloader/.idea/.gitignore
new file mode 100644
index 0000000..7bca872
--- /dev/null
+++ b/.idea/.idea.BatchBeatmapDownloader/.idea/.gitignore
@@ -0,0 +1,13 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Rider ignored files
+/modules.xml
+/.idea.BatchBeatmapDownloader.iml
+/contentModel.xml
+/projectSettingsUpdater.xml
+# Editor-based HTTP Client requests
+/httpRequests/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
diff --git a/.idea/.idea.BatchBeatmapDownloader/.idea/avalonia.xml b/.idea/.idea.BatchBeatmapDownloader/.idea/avalonia.xml
new file mode 100644
index 0000000..e4922d7
--- /dev/null
+++ b/.idea/.idea.BatchBeatmapDownloader/.idea/avalonia.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+ <component name="AvaloniaProject">
+ <option name="projectPerEditor">
+ <map>
+ <entry key="BatchBeatmapDownloader/Views/MainWindow.axaml" value="BatchBeatmapDownloader/BatchBeatmapDownloader.csproj" />
+ </map>
+ </option>
+ </component>
+</project>
\ No newline at end of file
diff --git a/.idea/.idea.BatchBeatmapDownloader/.idea/encodings.xml b/.idea/.idea.BatchBeatmapDownloader/.idea/encodings.xml
new file mode 100644
index 0000000..df87cf9
--- /dev/null
+++ b/.idea/.idea.BatchBeatmapDownloader/.idea/encodings.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+ <component name="Encoding" addBOMForNewFiles="with BOM under Windows, with no BOM otherwise" />
+</project>
\ No newline at end of file
diff --git a/.idea/.idea.BatchBeatmapDownloader/.idea/indexLayout.xml b/.idea/.idea.BatchBeatmapDownloader/.idea/indexLayout.xml
new file mode 100644
index 0000000..7b08163
--- /dev/null
+++ b/.idea/.idea.BatchBeatmapDownloader/.idea/indexLayout.xml
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+ <component name="UserContentModel">
+ <attachedFolders />
+ <explicitIncludes />
+ <explicitExcludes />
+ </component>
+</project>
\ No newline at end of file
diff --git a/.idea/.idea.BatchBeatmapDownloader/.idea/vcs.xml b/.idea/.idea.BatchBeatmapDownloader/.idea/vcs.xml
new file mode 100644
index 0000000..94a25f7
--- /dev/null
+++ b/.idea/.idea.BatchBeatmapDownloader/.idea/vcs.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+ <component name="VcsDirectoryMappings">
+ <mapping directory="$PROJECT_DIR$" vcs="Git" />
+ </component>
+</project>
\ No newline at end of file
diff --git a/BatchBeatmapDownloader.sln b/BatchBeatmapDownloader.sln
new file mode 100644
index 0000000..82783c5
--- /dev/null
+++ b/BatchBeatmapDownloader.sln
@@ -0,0 +1,22 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BatchBeatmapDownloader", "BatchBeatmapDownloader\BatchBeatmapDownloader.csproj", "{71940BDB-FA45-4F13-BAF0-285079C103D0}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LibBeatmapDownload", "LibBeatmapDownload\LibBeatmapDownload.csproj", "{2E2E3BBD-8D47-4554-A8CD-1A51428383BD}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {71940BDB-FA45-4F13-BAF0-285079C103D0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {71940BDB-FA45-4F13-BAF0-285079C103D0}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {71940BDB-FA45-4F13-BAF0-285079C103D0}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {71940BDB-FA45-4F13-BAF0-285079C103D0}.Release|Any CPU.Build.0 = Release|Any CPU
+ {2E2E3BBD-8D47-4554-A8CD-1A51428383BD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {2E2E3BBD-8D47-4554-A8CD-1A51428383BD}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {2E2E3BBD-8D47-4554-A8CD-1A51428383BD}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {2E2E3BBD-8D47-4554-A8CD-1A51428383BD}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+EndGlobal
diff --git a/BatchBeatmapDownloader/App.axaml b/BatchBeatmapDownloader/App.axaml
new file mode 100644
index 0000000..0f4f164
--- /dev/null
+++ b/BatchBeatmapDownloader/App.axaml
@@ -0,0 +1,15 @@
+<Application xmlns="https://github.com/avaloniaui"
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ x:Class="BatchBeatmapDownloader.App"
+ xmlns:local="using:BatchBeatmapDownloader"
+ RequestedThemeVariant="Default">
+ <!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
+
+ <Application.DataTemplates>
+ <local:ViewLocator/>
+ </Application.DataTemplates>
+
+ <Application.Styles>
+ <FluentTheme />
+ </Application.Styles>
+</Application>
\ No newline at end of file
diff --git a/BatchBeatmapDownloader/App.axaml.cs b/BatchBeatmapDownloader/App.axaml.cs
new file mode 100644
index 0000000..0a1b270
--- /dev/null
+++ b/BatchBeatmapDownloader/App.axaml.cs
@@ -0,0 +1,23 @@
+using Avalonia;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Markup.Xaml;
+using BatchBeatmapDownloader.ViewModels;
+using BatchBeatmapDownloader.Views;
+
+namespace BatchBeatmapDownloader;
+
+public partial class App : Application {
+ public override void Initialize() {
+ AvaloniaXamlLoader.Load(this);
+ }
+
+ public override void OnFrameworkInitializationCompleted() {
+ if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) {
+ desktop.MainWindow = new MainWindow {
+ DataContext = new MainWindowViewModel(),
+ };
+ }
+
+ base.OnFrameworkInitializationCompleted();
+ }
+}
diff --git a/BatchBeatmapDownloader/Assets/avalonia-logo.ico b/BatchBeatmapDownloader/Assets/avalonia-logo.ico
new file mode 100644
index 0000000..da8d49f
--- /dev/null
+++ b/BatchBeatmapDownloader/Assets/avalonia-logo.ico
Binary files differdiff --git a/BatchBeatmapDownloader/BatchBeatmapDownloader.csproj b/BatchBeatmapDownloader/BatchBeatmapDownloader.csproj
new file mode 100644
index 0000000..0e104cf
--- /dev/null
+++ b/BatchBeatmapDownloader/BatchBeatmapDownloader.csproj
@@ -0,0 +1,32 @@
+<Project Sdk="Microsoft.NET.Sdk">
+ <PropertyGroup>
+ <OutputType>WinExe</OutputType>
+ <TargetFramework>net7.0</TargetFramework>
+ <Nullable>enable</Nullable>
+ <BuiltInComInteropSupport>true</BuiltInComInteropSupport>
+ <ApplicationManifest>app.manifest</ApplicationManifest>
+ <AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
+ <LangVersion>preview</LangVersion>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <Folder Include="Models\"/>
+ <AvaloniaResource Include="Assets\**"/>
+ </ItemGroup>
+
+
+ <ItemGroup>
+ <PackageReference Include="Avalonia" Version="11.0.0"/>
+ <PackageReference Include="Avalonia.Desktop" Version="11.0.0"/>
+ <PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.0"/>
+ <PackageReference Include="Avalonia.Fonts.Inter" Version="11.0.0"/>
+ <!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
+ <PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.0.0"/>
+ <PackageReference Include="Avalonia.ReactiveUI" Version="11.0.0"/>
+ </ItemGroup>
+
+
+ <ItemGroup>
+ <ProjectReference Include="..\LibBeatmapDownload\LibBeatmapDownload.csproj" />
+ </ItemGroup>
+</Project>
diff --git a/BatchBeatmapDownloader/ObjectCollectionWrapper.cs b/BatchBeatmapDownloader/ObjectCollectionWrapper.cs
new file mode 100644
index 0000000..2171969
--- /dev/null
+++ b/BatchBeatmapDownloader/ObjectCollectionWrapper.cs
@@ -0,0 +1,7 @@
+using System.Collections.Generic;
+
+namespace BatchBeatmapDownloader;
+
+public class ObjectCollectionWrapper<T>(IEnumerable<T> items) {
+ public List<T> Items { get; set; } = new(items);
+}
diff --git a/BatchBeatmapDownloader/Program.cs b/BatchBeatmapDownloader/Program.cs
new file mode 100644
index 0000000..87236fd
--- /dev/null
+++ b/BatchBeatmapDownloader/Program.cs
@@ -0,0 +1,22 @@
+using System;
+using Avalonia;
+using Avalonia.ReactiveUI;
+
+namespace BatchBeatmapDownloader;
+
+internal class Program {
+ // Initialization code. Don't use any Avalonia, third-party APIs or any
+ // SynchronizationContext-reliant code before AppMain is called: things aren't initialized
+ // yet and stuff might break.
+ [STAThread]
+ public static void Main(string[] args) => BuildAvaloniaApp()
+ .StartWithClassicDesktopLifetime(args);
+
+ // Avalonia configuration, don't remove; also used by visual designer.
+ public static AppBuilder BuildAvaloniaApp()
+ => AppBuilder.Configure<App>()
+ .UsePlatformDetect()
+ .WithInterFont()
+ .LogToTrace()
+ .UseReactiveUI();
+}
diff --git a/BatchBeatmapDownloader/ViewLocator.cs b/BatchBeatmapDownloader/ViewLocator.cs
new file mode 100644
index 0000000..e22d652
--- /dev/null
+++ b/BatchBeatmapDownloader/ViewLocator.cs
@@ -0,0 +1,23 @@
+using System;
+using Avalonia.Controls;
+using Avalonia.Controls.Templates;
+using BatchBeatmapDownloader.ViewModels;
+
+namespace BatchBeatmapDownloader;
+
+public class ViewLocator : IDataTemplate {
+ public Control Build(object data) {
+ var name = data.GetType().FullName!.Replace("ViewModel", "View");
+ var type = Type.GetType(name);
+
+ if (type != null) {
+ return (Control)Activator.CreateInstance(type)!;
+ }
+
+ return new TextBlock { Text = "Not Found: " + name };
+ }
+
+ public bool Match(object data) {
+ return data is ViewModelBase;
+ }
+}
diff --git a/BatchBeatmapDownloader/ViewModels/MainWindowViewModel.cs b/BatchBeatmapDownloader/ViewModels/MainWindowViewModel.cs
new file mode 100644
index 0000000..1e5d23a
--- /dev/null
+++ b/BatchBeatmapDownloader/ViewModels/MainWindowViewModel.cs
@@ -0,0 +1,31 @@
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using LibBeatmapDownload;
+using ReactiveUI;
+
+namespace BatchBeatmapDownloader.ViewModels;
+
+public class MainWindowViewModel : ViewModelBase {
+ // public List<DownloadTask> DownloadTasks { get; set; } = new();
+ public DownloadTaskList DownloadTasks { get; set; } = new();
+
+ private int _windowWidth = 800;
+
+ public int WindowWidth {
+ set {
+ _windowWidth = value;
+ Debug.WriteLine($"Window width: {_windowWidth}");
+ DomainStatsChunked = DownloadTask.MirrorStats.Select(x => x.Value).ToList().Chunk(value/300)
+ .Select(x => new ObjectCollectionWrapper<DomainStats>(x)).ToList();
+ this.RaisePropertyChanged(nameof(DomainStatsChunked));
+ }
+ }
+
+ public List<ObjectCollectionWrapper<DomainStats>> DomainStatsChunked { get; set; } = DownloadTask.MirrorStats.Select(x => x.Value).ToList().Chunk(2)
+ .Select(x => new ObjectCollectionWrapper<DomainStats>(x)).ToList();
+
+ public void RaiseDownloadListChanged() {
+ this.RaisePropertyChanged(nameof(DownloadTasks));
+ }
+}
diff --git a/BatchBeatmapDownloader/ViewModels/ViewModelBase.cs b/BatchBeatmapDownloader/ViewModels/ViewModelBase.cs
new file mode 100644
index 0000000..e0f04e3
--- /dev/null
+++ b/BatchBeatmapDownloader/ViewModels/ViewModelBase.cs
@@ -0,0 +1,5 @@
+using ReactiveUI;
+
+namespace BatchBeatmapDownloader.ViewModels;
+
+public class ViewModelBase : ReactiveObject { }
diff --git a/BatchBeatmapDownloader/Views/MainWindow.axaml b/BatchBeatmapDownloader/Views/MainWindow.axaml
new file mode 100644
index 0000000..d6ed94f
--- /dev/null
+++ b/BatchBeatmapDownloader/Views/MainWindow.axaml
@@ -0,0 +1,93 @@
+<Window xmlns="https://github.com/avaloniaui"
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:vm="using:BatchBeatmapDownloader.ViewModels"
+ xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+ xmlns:batchBeatmapDownloader="clr-namespace:BatchBeatmapDownloader"
+ xmlns:generic="clr-namespace:System.Collections.Generic;assembly=System.Collections"
+ xmlns:libBeatmapDownload="clr-namespace:LibBeatmapDownload;assembly=LibBeatmapDownload"
+ mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
+ x:Class="BatchBeatmapDownloader.Views.MainWindow"
+ x:DataType="vm:MainWindowViewModel"
+ Icon="/Assets/avalonia-logo.ico"
+ Title="BatchBeatmapDownloader">
+
+ <Design.DataContext>
+ <!-- This only sets the DataContext for the previewer in an IDE,
+ to set the actual DataContext for runtime, set the DataContext property in code (look at App.axaml.cs) -->
+ <vm:MainWindowViewModel/>
+ </Design.DataContext>
+ <StackPanel Orientation="Vertical">
+ <!-- horizontal listbox with progress bars -->
+ <ListBox ItemsSource="{Binding DomainStatsChunked}">
+ <ListBox.ItemsPanel>
+ <ItemsPanelTemplate>
+ <UniformGrid Rows="{Binding DomainStatsChunked.Count}"/>
+ </ItemsPanelTemplate>
+ </ListBox.ItemsPanel>
+ <ListBox.ItemTemplate>
+ <DataTemplate>
+ <ListBox ItemsSource="{Binding Items}">
+ <ListBox.ItemsPanel>
+ <ItemsPanelTemplate>
+ <UniformGrid Columns="{Binding Items.Count}"/>
+ </ItemsPanelTemplate>
+ </ListBox.ItemsPanel>
+ <ListBox.ItemTemplate>
+ <DataTemplate DataType="libBeatmapDownload:DomainStats">
+ <Grid>
+ <Grid.RowDefinitions>
+ <RowDefinition Height="Auto"/>
+ <RowDefinition Height="Auto"/>
+ </Grid.RowDefinitions>
+ <ProgressBar Value="{Binding Progress, Mode=OneWay}" />
+ <TextBlock Grid.Row="1" Text="{Binding ProgressString, Mode=OneWay}" HorizontalAlignment="Center" VerticalAlignment="Center" />
+ </Grid>
+ </DataTemplate>
+ </ListBox.ItemTemplate>
+ </ListBox>
+ </DataTemplate>
+ </ListBox.ItemTemplate>
+ </ListBox>
+ <!--
+ <ListBox ItemsSource="{Binding DomainStats}">
+ <ListBox.ItemsPanel>
+ <ItemsPanelTemplate>
+ <UniformGrid Columns="{Binding DomainStats.Count}"/>
+ </ItemsPanelTemplate>
+ </ListBox.ItemsPanel>
+ <ListBox.ItemTemplate>
+ <DataTemplate DataType="batchBeatmapDownloader:DomainStats">
+ <Grid>
+ <Grid.RowDefinitions>
+ <RowDefinition Height="Auto"/>
+ <RowDefinition Height="Auto"/>
+ </Grid.RowDefinitions>
+ <ProgressBar Value="{Binding Progress, Mode=OneWay}" />
+ <TextBlock Grid.Row="1" Text="{Binding ProgressString, Mode=OneWay}" HorizontalAlignment="Center" VerticalAlignment="Center" />
+ </Grid>
+ </DataTemplate>
+ </ListBox.ItemTemplate>
+ </ListBox>
+ -->
+
+ <ListBox ItemsSource="{Binding DownloadTasks.Tasks}">
+ <ListBox.ItemsPanel>
+ <ItemsPanelTemplate>
+ <VirtualizingStackPanel></VirtualizingStackPanel>
+ </ItemsPanelTemplate>
+ </ListBox.ItemsPanel>
+ <ListBox.ItemTemplate>
+ <DataTemplate DataType="libBeatmapDownload:DownloadTask">
+ <!-- Progress bar with text overlayed -->
+ <Grid>
+ <ProgressBar Value="{Binding Progress, Mode=OneWay}" />
+ <TextBlock Text="{Binding ProgressString, Mode=OneWay}" HorizontalAlignment="Center" VerticalAlignment="Center" />
+ </Grid>
+
+ </DataTemplate>
+ </ListBox.ItemTemplate>
+ </ListBox>
+ </StackPanel>
+
+</Window>
diff --git a/BatchBeatmapDownloader/Views/MainWindow.axaml.cs b/BatchBeatmapDownloader/Views/MainWindow.axaml.cs
new file mode 100644
index 0000000..d062f92
--- /dev/null
+++ b/BatchBeatmapDownloader/Views/MainWindow.axaml.cs
@@ -0,0 +1,34 @@
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using ArcaneLibs.Extensions;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using BatchBeatmapDownloader.ViewModels;
+using LibBeatmapDownload;
+
+namespace BatchBeatmapDownloader.Views;
+
+public partial class MainWindow : Window {
+ public MainWindow() {
+ InitializeComponent();
+ }
+
+ protected override async void OnLoaded(RoutedEventArgs e) {
+ base.OnLoaded(e);
+ var ctx = DataContext as MainWindowViewModel;
+ Resized += (_, args) => ctx.WindowWidth = (int)args.ClientSize.Width;
+ var lines = File.ReadLinesAsync("/home/root@Rory/Downloads/maps.tsv");
+ await foreach (var line in lines) {
+ var parts = line.Split('\t');
+ var downloadTask = new DownloadTask(int.Parse(parts[0]), parts.Length > 1 ? parts[1] : null);
+ ctx?.DownloadTasks.Tasks.Add(downloadTask);
+ if (ctx!.DownloadTasks.Tasks.Count > 100) break;
+ }
+
+ var tasks = ctx.DownloadTasks.Tasks.Select(x => x.Download()).ToAsyncEnumerable();
+ await foreach (var result in tasks) {
+ Debug.WriteLine($"Downloaded {result}");
+ }
+ }
+}
diff --git a/BatchBeatmapDownloader/app.manifest b/BatchBeatmapDownloader/app.manifest
new file mode 100644
index 0000000..265e183
--- /dev/null
+++ b/BatchBeatmapDownloader/app.manifest
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="utf-8"?>
+<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
+ <!-- This manifest is used on Windows only.
+ Don't remove it as it might cause problems with window transparency and embeded controls.
+ For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
+ <assemblyIdentity version="1.0.0.0" name="BatchBeatmapDownloader.Desktop"/>
+
+ <compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
+ <application>
+ <!-- A list of the Windows versions that this application has been tested on
+ and is designed to work with. Uncomment the appropriate elements
+ and Windows will automatically select the most compatible environment. -->
+
+ <!-- Windows 10 -->
+ <supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
+ </application>
+ </compatibility>
+</assembly>
diff --git a/LibBeatmapDownload/DomainStats.cs b/LibBeatmapDownload/DomainStats.cs
new file mode 100644
index 0000000..a026b7d
--- /dev/null
+++ b/LibBeatmapDownload/DomainStats.cs
@@ -0,0 +1,46 @@
+using System.ComponentModel;
+using System.Diagnostics;
+using System.Runtime.CompilerServices;
+
+namespace LibBeatmapDownload;
+
+public class DomainStats(string url) : INotifyPropertyChanged {
+ public string Domain { get; } = new Uri(url).Host;
+
+ private int _total = 0;
+
+ public int Total => Success + Failed;
+
+ private int _success = 0;
+
+ public int Success {
+ get => _success;
+ set => SetField(ref _success, value);
+ }
+
+ private int _failed = 0;
+
+ public int Failed {
+ get => _failed;
+ set => SetField(ref _failed, value);
+ }
+
+ public double Progress => Total == 0 ? 100 : (double)Success / Total * 100;
+ public string ProgressString => $"{Domain} {Progress/100:P0} ({Failed}/{Total} failed)";
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ private readonly Stopwatch sw = Stopwatch.StartNew();
+ protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null) {
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Progress)));
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ProgressString)));
+ }
+
+ protected bool SetField<T>(ref T field, T value, [CallerMemberName] string? propertyName = null) {
+ if (EqualityComparer<T>.Default.Equals(field, value)) return false;
+ field = value;
+ OnPropertyChanged(propertyName);
+ return true;
+ }
+}
diff --git a/LibBeatmapDownload/DownloadTask.cs b/LibBeatmapDownload/DownloadTask.cs
new file mode 100644
index 0000000..2e9222f
--- /dev/null
+++ b/LibBeatmapDownload/DownloadTask.cs
@@ -0,0 +1,201 @@
+using System.ComponentModel;
+using System.Diagnostics;
+using System.Reflection;
+using System.Runtime.CompilerServices;
+using ArcaneLibs;
+
+namespace LibBeatmapDownload;
+
+public class DownloadTask : INotifyPropertyChanged {
+ private static readonly HttpClient Client = new();
+
+ private static readonly List<string> Mirrors = new() {
+ "https://api.osu.direct/d/", "https://osu.direct/api/d/",
+ "https://api.chimu.moe/v1/download/",
+ "https://api.nerinyan.moe/d/", "https://proxy.nerinyan.moe/d/",
+ "https://storage.kurikku.pw/d/",
+ "https://storage.ripple.moe/d/",
+ "https://catboy.best/d/",
+ "https://txy1.sayobot.cn/beatmaps/download/full/"
+ };
+
+ public static readonly Dictionary<string, DomainStats> MirrorStats = Mirrors.ToDictionary(x => x, x => new DomainStats(x));
+
+ private static int _mirrorIndex = 0;
+
+ // private void AllPropertiesChanged() {
+ // typeof(DownloadTask).GetProperties().ToList().ForEach(x => OnPropertyChanged(x.Name));
+ // }
+
+ private static readonly Dictionary<string, DateTime> NextRatelimitFree = Mirrors.ToDictionary(x => x, x => DateTime.MinValue);
+
+ public int Id { get; }
+
+ private long _expectedSize = 0;
+
+ public long ExpectedSize {
+ get => _expectedSize;
+ set {
+ if (value == _expectedSize) return;
+ _expectedSize = value;
+ // OnPropertyChanged();
+ OnPropertyChanged(nameof(ProgressString));
+ }
+ }
+
+ private long _downloadedSize = 0;
+
+ private Stopwatch sw = Stopwatch.StartNew();
+
+ public long DownloadedSize {
+ get => _downloadedSize;
+ set {
+ if (value == _downloadedSize) return;
+ _downloadedSize = value;
+
+ //if (sw.ElapsedMilliseconds < 1000) return;
+ if(!value.ToString().EndsWith('0')) return;
+ OnPropertyChanged(nameof(Progress));
+ OnPropertyChanged(nameof(ProgressString));
+ sw.Restart();
+ }
+ }
+
+ public string OutPath { get; set; } = Path.GetTempFileName();
+
+ public int Attempt { get; set; } = 0;
+
+ private Status _status = Status.NotQueued;
+
+ public Status Status {
+ get => _status;
+ set {
+ if (value == _status) return;
+ _status = value;
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(ProgressString));
+ }
+ }
+
+ public string? OriginalFilename { get; set; }
+ public double Progress => DownloadedSize / (double)(ExpectedSize == 0 ? 1 : ExpectedSize) * 100;
+
+ public string ProgressString => $"{Id}" +
+ $"{(_title == null ? "" : $" - {_title}")}" +
+ // $"{(OriginalFilename == null ? "" : $" ({OriginalFilename})")}" +
+ $": " +
+ Status switch {
+ Status.Downloading => $"{Progress / 100d:P2} ({Util.BytesToString(DownloadedSize)}/{Util.BytesToString(ExpectedSize)})",
+ Status.Finished => $"Finished downloading {Util.BytesToString(DownloadedSize)}",
+ Status.Error => $"Errored, retrying later, so far {Attempt} attempts...",
+ _ => Status
+ };
+
+ private static readonly SemaphoreSlim Semaphore = new(Mirrors.Count, Mirrors.Count);
+ private readonly int _id;
+ private readonly string? _title;
+
+ public static List<DownloadTask> DownloadTasks { get; set; } = new();
+ public DownloadTask(int id, string? title = null) {
+ _id = id;
+ _title = title;
+ Id = id;
+ DownloadTasks.Add(this);
+ }
+
+ public async Task<string?> Download() {
+ if(_status == Status.Error)
+ while (Semaphore.CurrentCount < Mirrors.Count/2) {
+ await Task.Delay(100);
+ }
+ Status = Status.Waiting;
+ var currentMirrorIndex = Interlocked.Increment(ref _mirrorIndex);
+ string mirror = Mirrors[currentMirrorIndex % Mirrors.Count];
+ await Semaphore.WaitAsync();
+ Status = Status.Connecting;
+
+ while (Attempt++ % Mirrors.Count == 0) {
+ mirror = Mirrors[currentMirrorIndex++ % Mirrors.Count];
+
+ if (DateTime.Now < NextRatelimitFree[mirror]) continue;
+ var url = $"{mirror}{Id}";
+ Debug.WriteLine($"Attempting to download {Id} from {mirror}: {url}");
+ var request = new HttpRequestMessage(HttpMethod.Get, url);
+ request.Headers.Add("Accept", "*/*");
+ // request.Headers.Add("User-Agent", "osu_beatmap_download_test");
+ // set user agent to entry binary name
+ request.Headers.Add("User-Agent", Assembly.GetEntryAssembly()?.GetName().Name ?? "LibBeatmapDownload");
+
+ var sw = Stopwatch.StartNew();
+ try {
+ var response = await Client.SendAsync(request);
+ Debug.WriteLine($"Got status code {(int)response.StatusCode} {response.StatusCode} for {url} after {sw.Elapsed}");
+ if (response.IsSuccessStatusCode) {
+ Status = Status.Downloading;
+ OriginalFilename = response.Content.Headers.ContentDisposition?.FileName ?? $"{_id}.osz";
+ ExpectedSize = response.Content.Headers.ContentLength ?? 1;
+ Debug.WriteLine(
+ $"Got success status code {response.StatusCode} from {mirror} for {_id} after {sw.Elapsed}, filename is {OriginalFilename}, expected size is {Util.BytesToString(ExpectedSize)}");
+ var streamWithFileBody = await response.Content.ReadAsStreamAsync();
+ // write to temp file first
+ await using var fileStream = File.Create(OutPath);
+ var buffer = new byte[8192];
+ int read;
+ while ((read = await streamWithFileBody.ReadAsync(buffer, 0, buffer.Length)) > 0) {
+ await fileStream.WriteAsync(buffer, 0, read);
+ DownloadedSize += read;
+ // Debug.WriteLine($"Read {read} bytes from {url}, total downloaded is {Util.BytesToString(DownloadedSize)}");
+ }
+
+ fileStream.Flush();
+ fileStream.Close();
+ Semaphore.Release();
+ Status = Status.Finished;
+ MirrorStats[mirror].Success++;
+ return OutPath;
+ }
+
+ Status = Status.Connecting;
+
+ //ratelimits
+ if (response.Headers.Contains("X-Burst-RateLimit-Reset")) {
+ NextRatelimitFree[mirror] = DateTime.Now.AddSeconds(int.Parse(response.Headers.GetValues("X-Burst-RateLimit-Reset").First()));
+ Debug.WriteLine($"We got burst ratelimited by {mirror}: {response.Headers.GetValues("X-Burst-RateLimit-Reset").First()} seconds");
+ }
+ else if (response.Headers.Contains("X-RateLimit-Reset")) {
+ NextRatelimitFree[mirror] = DateTime.Now.AddSeconds(int.Parse(response.Headers.GetValues("X-RateLimit-Reset").First()));
+ Debug.WriteLine($"We got ratelimited by {mirror}: {response.Headers.GetValues("X-RateLimit-Reset").First()} seconds");
+ }
+ else {
+ Debug.WriteLine($"Got non-success status code {response.StatusCode} from {mirror} for {_id} and no ratelimit headers were found...");
+ Debug.WriteLine(await response.Content.ReadAsStringAsync());
+ }
+ }
+ catch (Exception e) {
+ Debug.WriteLine(e.ToString());
+ }
+
+ MirrorStats[mirror].Failed++;
+ }
+
+ Status = Status.Error;
+ Debug.WriteLine($"Failed to download {_id} after {Attempt + 1} attempts, retrying...");
+ Semaphore.Release();
+ await Task.Delay(500);
+
+ return await Download();
+ }
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null) {
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+ }
+
+ protected bool SetField<T>(ref T field, T value, [CallerMemberName] string? propertyName = null) {
+ if (EqualityComparer<T>.Default.Equals(field, value)) return false;
+ field = value;
+ OnPropertyChanged(propertyName);
+ return true;
+ }
+}
diff --git a/LibBeatmapDownload/DownloadTaskList.cs b/LibBeatmapDownload/DownloadTaskList.cs
new file mode 100644
index 0000000..a449e96
--- /dev/null
+++ b/LibBeatmapDownload/DownloadTaskList.cs
@@ -0,0 +1,64 @@
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using System.Diagnostics;
+using System.Runtime.CompilerServices;
+
+namespace LibBeatmapDownload;
+
+public class DownloadTaskList : INotifyPropertyChanged {
+ public DownloadTaskList() {
+ Tasks.CollectionChanged += (sender, args) => {
+ if (args.NewItems is { Count: > 0 })
+ foreach (var downloadTask in args.NewItems) {
+ if (downloadTask is not DownloadTask task) {
+ Debug.WriteLine($"[DownloadTaskList] New task is {downloadTask?.GetType().FullName ?? "null"}!");
+ continue;
+ }
+
+ task.PropertyChanged += (taskSender, taskArgs) => {
+ if (taskArgs.PropertyName == nameof(DownloadTask.Status)) {
+ if (task.Status == Status.Finished) {
+ Task.Run(async () => {
+ await Task.Delay(1000);
+ Tasks.Remove(task);
+ });
+ }
+
+ SetField(ref _tasks,
+ new ObservableCollection<DownloadTask>(
+ _tasks
+ .OrderByDescending(x => x.Progress)
+ .ThenByDescending(x => x.Status)
+ ),
+ nameof(Tasks));
+ OnPropertyChanged(nameof(Tasks));
+ }
+ };
+ }
+
+ // OnPropertyChanged(nameof(Tasks));
+ };
+ }
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ private ObservableCollection<DownloadTask> _tasks = new();
+
+ public ObservableCollection<DownloadTask> Tasks {
+ get => _tasks;
+ set => SetField(ref _tasks, value);
+ }
+
+ protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null) {
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+ }
+
+ protected bool SetField<T>(ref T field, T value, [CallerMemberName] string? propertyName = null) {
+ //check ObservableCollection by order
+
+ if (EqualityComparer<T>.Default.Equals(field, value)) return false;
+ field = value;
+ OnPropertyChanged(propertyName);
+ return true;
+ }
+}
diff --git a/LibBeatmapDownload/LibBeatmapDownload.csproj b/LibBeatmapDownload/LibBeatmapDownload.csproj
new file mode 100644
index 0000000..829366e
--- /dev/null
+++ b/LibBeatmapDownload/LibBeatmapDownload.csproj
@@ -0,0 +1,14 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <PropertyGroup>
+ <TargetFramework>net7.0</TargetFramework>
+ <ImplicitUsings>enable</ImplicitUsings>
+ <Nullable>enable</Nullable>
+ <LangVersion>preview</LangVersion>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <PackageReference Include="ArcaneLibs" Version="1.0.0-preview6437853305.78f6d30" />
+ </ItemGroup>
+
+</Project>
diff --git a/LibBeatmapDownload/Status.cs b/LibBeatmapDownload/Status.cs
new file mode 100644
index 0000000..f5602a4
--- /dev/null
+++ b/LibBeatmapDownload/Status.cs
@@ -0,0 +1,10 @@
+namespace LibBeatmapDownload;
+
+public enum Status {
+ NotQueued,
+ Error,
+ Waiting,
+ Connecting,
+ Downloading,
+ Finished
+}
|